Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

This commit is contained in:
Daniel J. Geiger 2023-11-18 09:35:29 -06:00
commit 453757756d
99 changed files with 3232 additions and 946 deletions

View File

@ -25,6 +25,9 @@
<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>

View File

@ -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.isMobile) {
if (device.editor.isMobile) {
return (
<Footer>
<button

View File

@ -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.isMobile) {
if (device.editor.isMobile) {
return (
<Footer>
<button
@ -335,7 +335,6 @@ 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` |

View File

@ -34,19 +34,44 @@ function App() {
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
Here are two ways on how you can render **Excalidraw** on **Next.js**.
1. Importing Excalidraw once **client** is rendered.
```jsx showLineNumbers
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

View File

@ -19,4 +19,4 @@ Frames should be ordered where frame children come first, followed by the frame
]
```
If not oredered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
If not ordered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.

View File

@ -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 sotrage
* reason or another. We also save different data to different storage
* (localStorage, indexedDB).
*/

View File

@ -131,5 +131,5 @@ export class Debug {
};
};
}
//@ts-ignore
window.debug = Debug;

View File

@ -694,7 +694,7 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
ref={excalidrawRefCallback}
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}

View File

@ -18,7 +18,9 @@ describe("Test MobileMenu", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
// @ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
});
afterAll(() => {
@ -28,11 +30,15 @@ describe("Test MobileMenu", () => {
it("should set device correctly", () => {
expect(h.app.device).toMatchInlineSnapshot(`
{
"canDeviceFitSidebar": false,
"isLandscape": true,
"editor": {
"canFitSidebar": false,
"isMobile": true,
"isSmScreen": false,
},
"isTouchScreen": false,
"viewport": {
"isLandscape": false,
"isMobile": true,
},
}
`);
});

View File

@ -8,6 +8,7 @@ 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 = {
@ -93,8 +94,6 @@ 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)[],
@ -115,15 +114,15 @@ const test = <U extends `${string}:${"L" | "R"}`>(
"remote reconciliation",
);
const __local = cleanElements(cloneDeep(_remote));
const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneDeep(__local),
cloneDeep(__remote),
cloneJSON(__local),
cloneJSON(__remote),
{} as AppState,
),
),

View File

@ -20,6 +20,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
@ -129,7 +130,7 @@
"test": "yarn test:app",
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui",
"test:ui": "yarn test --ui --coverage.enabled=true",
"autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",

View File

@ -438,5 +438,6 @@ export const actionToggleHandTool = register({
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.H,
keyTest: (event) =>
!event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
});

View File

@ -9,8 +9,8 @@ import {
readSystemClipboard,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
@ -122,20 +122,23 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
exportedElements,
appState,
app.files,
appState,
{
...appState,
exportingFrame,
},
);
return {
commitToHistory: false,
@ -171,16 +174,17 @@ export const actionCopyAsPng = register({
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
});
return {
appState: {
...appState,

View File

@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
getFrameChildren,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
@ -155,7 +155,7 @@ const duplicateElements = (
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameElements(elements, element.id), element]
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@ -181,7 +181,7 @@ const duplicateElements = (
continue;
}
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id);
const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([

View File

@ -217,7 +217,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useDevice().isMobile}
showAriaLabel={useDevice().editor.isMobile}
hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"

View File

@ -1,7 +1,7 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { getFrameChildren } 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 = getFrameElements(
const elementsInFrame = getFrameChildren(
getNonDeletedElements(elements),
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));

View File

@ -17,15 +17,12 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
getFrameElements,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
@ -190,13 +187,6 @@ 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)) {
@ -221,7 +211,19 @@ export const actionUngroup = register({
null,
);
frames.forEach((frame) => {
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElementFrameIds = new Set(
selectedElements
.filter((element) => element.frameId)
.map((element) => element.frameId!),
);
const targetFrames = getFrameElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id),
);
targetFrames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,

View File

@ -3,7 +3,6 @@ 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({
@ -52,23 +51,6 @@ export const actionToggleEditMenu = register({
),
});
export const actionFullScreen = register({
name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
allowFullScreen();
}
if (isFullScreen()) {
exitFullScreen();
}
return {
commitToHistory: false,
};
},
});
export const actionShortcuts = register({
name: "toggleShortcuts",
viewMode: true,

View File

@ -328,7 +328,7 @@ export const actionChangeFillStyle = register({
trackEvent(
"element",
"changeFillStyle",
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
`${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
);
return {
elements: changeProperty(elements, appState, (el) =>

View File

@ -21,8 +21,10 @@ 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 = "{}";
@ -99,16 +101,19 @@ export const actionPasteStyles = register({
if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, {
fontSize,
fontFamily,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getDefaultLineHeight(fontFamily),
});
let container = null;
@ -123,7 +128,10 @@ export const actionPasteStyles = register({
redrawTextBoundingBox(newElement, container);
}
if (newElement.type === "arrow") {
if (
newElement.type === "arrow" &&
isArrowElement(elementStylesToCopyFrom)
) {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,

View File

@ -44,7 +44,6 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export {
actionToggleCanvasMenu,
actionToggleEditMenu,
actionFullScreen,
actionShortcuts,
} from "./actionMenu";

View File

@ -29,7 +29,7 @@ const trackAction = (
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
);
}
}

View File

@ -35,6 +35,7 @@ import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
} from "./icons";
import { KEYS } from "../keys";
@ -203,8 +204,8 @@ export const SelectedShapeActions = ({
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{!device.isMobile && renderAction("duplicateSelection")}
{!device.isMobile && renderAction("deleteSelectedElements")}
{!device.editor.isMobile && renderAction("duplicateSelection")}
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
@ -225,7 +226,6 @@ export const ShapesSwitcher = ({
app: AppClassProperties;
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
@ -275,55 +275,7 @@ export const ShapesSwitcher = ({
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={frameToolIcon}
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}
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", {
@ -346,9 +298,7 @@ export const ShapesSwitcher = ({
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "frame" });
}}
onSelect={() => app.setActiveTool({ type: "frame" })}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
@ -357,9 +307,7 @@ export const ShapesSwitcher = ({
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "embeddable" });
}}
onSelect={() => app.setActiveTool({ type: "embeddable" })}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
@ -367,9 +315,7 @@ export const ShapesSwitcher = ({
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "laser" });
}}
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
@ -377,9 +323,15 @@ export const ShapesSwitcher = ({
>
{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>
)}
<SubtypeToggles />
</>
);

View File

@ -74,7 +74,6 @@ import {
MQ_MAX_WIDTH_LANDSCAPE,
MQ_MAX_WIDTH_PORTRAIT,
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
MQ_SM_MAX_WIDTH,
POINTER_BUTTON,
ROUNDNESS,
SCROLL_TIMEOUT,
@ -88,7 +87,7 @@ import {
ZOOM_STEP,
POINTER_EVENTS,
} from "../constants";
import { exportCanvas, loadFromBlob } from "../data";
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
import {
@ -241,7 +240,6 @@ import {
isInputLike,
isToolIcon,
isWritableElement,
resolvablePromise,
sceneCoordsToViewportCoords,
tupleToCoors,
viewportCoordsToSceneCoords,
@ -328,7 +326,7 @@ import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
import {
getFrameElements,
getFrameChildren,
isCursorInFrame,
bindElementsToFramesAfterDuplication,
addElementsToFrame,
@ -376,6 +374,7 @@ 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 {
@ -390,11 +389,15 @@ const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
const deviceContextInitialValue = {
isSmScreen: false,
viewport: {
isMobile: false,
isTouchScreen: false,
canDeviceFitSidebar: false,
isLandscape: false,
},
editor: {
isMobile: false,
canFitSidebar: false,
},
isTouchScreen: false,
};
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
DeviceContext.displayName = "DeviceContext";
@ -445,6 +448,9 @@ 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;
@ -481,7 +487,6 @@ class App extends React.Component<AppProps, AppState> {
unmounted: boolean = false;
actionManager: ActionManager;
device: Device = deviceContextInitialValue;
detachIsMobileMqHandler?: () => void;
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
@ -544,7 +549,7 @@ class App extends React.Component<AppProps, AppState> {
super(props);
const defaultAppState = getDefaultAppState();
const {
excalidrawRef,
excalidrawAPI,
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
@ -581,14 +586,8 @@ class App extends React.Component<AppProps, AppState> {
this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene);
if (excalidrawRef) {
const readyPromise =
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
resolvablePromise<ExcalidrawImperativeAPI>();
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
ready: true,
readyPromise,
updateScene: this.updateScene,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
@ -617,12 +616,11 @@ class App extends React.Component<AppProps, AppState> {
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
if (typeof excalidrawAPI === "function") {
excalidrawAPI(api);
} else {
excalidrawRef.current = api;
console.error("excalidrawAPI should be a function!");
}
readyPromise.resolve(api);
}
this.excalidrawContainerValue = {
@ -1069,12 +1067,6 @@ 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 = () => {
@ -1119,13 +1111,12 @@ class App extends React.Component<AppProps, AppState> {
boxShadow: "inset 0 0 0 1px var(--color-primary)",
fontFamily: "Assistant",
fontSize: "14px",
transform: `translateY(-${FRAME_NAME_EDIT_PADDING}px)`,
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: "var(--color-gray-80)",
overflow: "hidden",
maxWidth: `${Math.min(
x2 - x1 - FRAME_NAME_EDIT_PADDING,
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING,
)}px`,
maxWidth: `${
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
}px`,
}}
size={frameNameInEdit.length + 1 || 1}
dir="auto"
@ -1147,19 +1138,26 @@ class App extends React.Component<AppProps, AppState> {
key={f.id}
style={{
position: "absolute",
top: `${y1 - FRAME_NAME_GAP - this.state.offsetTop}px`,
left: `${
x1 -
this.state.offsetLeft -
(this.state.editingFrame === f.id ? FRAME_NAME_EDIT_PADDING : 0)
// 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
}px`,
left: `${x1 - this.state.offsetLeft}px`,
zIndex: 2,
fontSize: "14px",
fontSize: FRAME_STYLE.nameFontSize,
color: isDarkTheme
? "var(--color-gray-60)"
: "var(--color-gray-50)",
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
lineHeight: FRAME_STYLE.nameLineHeight,
width: "max-content",
maxWidth: `${x2 - x1 + FRAME_NAME_EDIT_PADDING * 2}px`,
maxWidth: `${f.width}px`,
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@ -1202,22 +1200,27 @@ class App extends React.Component<AppProps, AppState> {
pendingImageElementId: this.state.pendingImageElementId,
});
return (
<div
className={clsx("excalidraw excalidraw-container", {
"excalidraw--view-mode": this.state.viewModeEnabled,
"excalidraw--mobile": this.device.isMobile,
})}
style={{
["--ui-pointerEvents" as any]:
this.state.selectionElement ||
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))
!isTextElement(this.state.editingElement)));
return (
<div
className={clsx("excalidraw excalidraw-container", {
"excalidraw--view-mode": this.state.viewModeEnabled,
"excalidraw--mobile": this.device.editor.isMobile,
})}
style={{
["--ui-pointerEvents" as any]: shouldBlockPointerEvents
? POINTER_EVENTS.disabled
: POINTER_EVENTS.enabled,
}}
@ -1272,7 +1275,11 @@ 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" />
@ -1387,7 +1394,8 @@ class App extends React.Component<AppProps, AppState> {
public onExportImage = async (
type: keyof typeof EXPORT_IMAGE_TYPES,
elements: readonly NonDeletedExcalidrawElement[],
elements: ExportedElements,
opts: { exportingFrame: ExcalidrawFrameElement | null },
) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
@ -1399,6 +1407,7 @@ class App extends React.Component<AppProps, AppState> {
exportBackground: this.state.exportBackground,
name: this.state.name,
viewBackgroundColor: this.state.viewBackgroundColor,
exportingFrame: opts.exportingFrame,
},
)
.catch(muteFSAbortError)
@ -1679,20 +1688,62 @@ class App extends React.Component<AppProps, AppState> {
});
};
private refreshDeviceState = (container: HTMLDivElement) => {
const { width, height } = container.getBoundingClientRect();
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();
const sidebarBreakpoint =
this.props.UIOptions.dockedSidebarBreakpoint != null
? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
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,
const prevEditorState = this.device.editor;
const nextEditorState = updateObject(prevEditorState, {
isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
canFitSidebar: editorWidth > sidebarBreakpoint,
});
if (prevEditorState !== nextEditorState) {
this.device = { ...this.device, editor: nextEditorState };
return true;
}
return false;
};
public async componentDidMount() {
@ -1734,52 +1785,21 @@ 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.refreshDeviceState(this.excalidrawContainerRef.current);
this.refreshViewportBreakpoints();
this.refreshEditorBreakpoints();
}
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
if (supportsResizeObserver && this.excalidrawContainerRef.current) {
this.resizeObserver = new ResizeObserver(() => {
// recompute device dimensions state
// ---------------------------------------------------------------------
this.refreshDeviceState(this.excalidrawContainerRef.current!);
// refresh offsets
// ---------------------------------------------------------------------
this.refreshEditorBreakpoints();
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));
@ -1824,6 +1844,11 @@ 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({});
});
@ -1877,7 +1902,6 @@ class App extends React.Component<AppProps, AppState> {
false,
);
this.detachIsMobileMqHandler?.();
window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
}
@ -1962,11 +1986,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (
this.excalidrawContainerRef.current &&
prevProps.UIOptions.dockedSidebarBreakpoint !==
this.props.UIOptions.dockedSidebarBreakpoint
) {
this.refreshDeviceState(this.excalidrawContainerRef.current);
this.refreshEditorBreakpoints();
}
if (
@ -2351,11 +2374,12 @@ class App extends React.Component<AppProps, AppState> {
},
);
private addElementsFromPasteOrLibrary = (opts: {
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);
@ -2431,7 +2455,7 @@ class App extends React.Component<AppProps, AppState> {
// from library, not when pasting from clipboard. Alas.
openSidebar:
this.state.openSidebar &&
this.device.canDeviceFitSidebar &&
this.device.editor.canFitSidebar &&
jotaiStore.get(isSidebarDockedAtom)
? this.state.openSidebar
: null,
@ -2460,6 +2484,12 @@ 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
@ -2640,7 +2670,7 @@ class App extends React.Component<AppProps, AppState> {
!isPlainPaste &&
textElements.length > 1 &&
PLAIN_PASTE_TOAST_SHOWN === false &&
!this.device.isMobile
!this.device.editor.isMobile
) {
this.setToast({
message: t("toast.pasteAsSingleElement", {
@ -2674,7 +2704,7 @@ class App extends React.Component<AppProps, AppState> {
trackEvent(
"toolbar",
"toggleLock",
`${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
`${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`,
);
}
this.setState((prevState) => {
@ -3169,7 +3199,9 @@ class App extends React.Component<AppProps, AppState> {
trackEvent(
"toolbar",
shape,
`keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
`keyboard (${
this.device.editor.isMobile ? "mobile" : "desktop"
})`,
);
}
this.setActiveTool({ type: shape });
@ -3336,6 +3368,10 @@ class App extends React.Component<AppProps, AppState> {
});
};
setOpenDialog = (dialogType: AppState["openDialog"]) => {
this.setState({ openDialog: dialogType });
};
private setCursor = (cursor: string) => {
setCursor(this.interactiveCanvas, cursor);
};
@ -3900,7 +3936,7 @@ class App extends React.Component<AppProps, AppState> {
element,
this.state,
[scenePointer.x, scenePointer.y],
this.device.isMobile,
this.device.editor.isMobile,
)
);
});
@ -3932,7 +3968,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
this.state,
[lastPointerDownCoords.x, lastPointerDownCoords.y],
this.device.isMobile,
this.device.editor.isMobile,
);
const lastPointerUpCoords = viewportCoordsToSceneCoords(
this.lastPointerUpEvent!,
@ -3942,7 +3978,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
this.state,
[lastPointerUpCoords.x, lastPointerUpCoords.y],
this.device.isMobile,
this.device.editor.isMobile,
);
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
let url = this.hitLinkElement.link;
@ -4287,6 +4323,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElement,
@ -4803,7 +4840,7 @@ class App extends React.Component<AppProps, AppState> {
);
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.device.isMobile && clicklength < 300) {
if (this.device.editor.isMobile && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
@ -5321,7 +5358,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") {
getFrameElements(
getFrameChildren(
previouslySelectedElements,
hitElement.id,
).forEach((element) => {
@ -8188,7 +8225,7 @@ class App extends React.Component<AppProps, AppState> {
>();
selectedFrames.forEach((frame) => {
const elementsInFrame = getFrameElements(
const elementsInFrame = getFrameChildren(
this.scene.getNonDeletedElements(),
frame.id,
);
@ -8258,7 +8295,7 @@ class App extends React.Component<AppProps, AppState> {
const elementsToHighlight = new Set<ExcalidrawElement>();
selectedFrames.forEach((frame) => {
const elementsInFrame = getFrameElements(
const elementsInFrame = getFrameChildren(
this.scene.getNonDeletedElements(),
frame.id,
);

View File

@ -98,7 +98,7 @@ export const ColorInput = ({
}}
/>
{/* TODO reenable on mobile with a better UX */}
{!device.isMobile && (
{!device.editor.isMobile && (
<>
<div
style={{

View File

@ -80,7 +80,7 @@ const ColorPickerPopupContent = ({
);
const { container } = useExcalidrawContainer();
const { isMobile, isLandscape } = useDevice();
const device = useDevice();
const colorInputJSX = (
<div>
@ -136,8 +136,16 @@ const ColorPickerPopupContent = ({
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={isMobile && !isLandscape ? "bottom" : "right"}
align={isMobile && !isLandscape ? "center" : "start"}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{

View File

@ -33,14 +33,16 @@
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;
}
}
}

View File

@ -49,7 +49,7 @@ export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
const device = useDevice();
const isFullscreen = useDevice().viewport.isMobile;
useEffect(() => {
if (!islandNode) {
@ -101,7 +101,9 @@ export const Dialog = (props: DialogProps) => {
return (
<Modal
className={clsx("Dialog", props.className)}
className={clsx("Dialog", props.className, {
"Dialog--fullscreen": isFullscreen,
})}
labelledBy="dialog-title"
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
@ -119,7 +121,7 @@ export const Dialog = (props: DialogProps) => {
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{device.isMobile ? back : CloseIcon}
{isFullscreen ? back : CloseIcon}
</button>
<div className="Dialog__content">{props.children}</div>
</Island>

View File

@ -254,7 +254,6 @@ 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")]}

View File

@ -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.canDeviceFitSidebar) {
if (appState.openSidebar && !device.editor.canFitSidebar) {
return null;
}

View File

@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../packages/utils";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
@ -34,6 +34,8 @@ 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")!;
@ -51,44 +53,47 @@ export const ErrorCanvasPreview = () => {
};
type ImageExportModalProps = {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
appStateSnapshot: Readonly<UIAppState>;
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
};
const ImageExportModal = ({
appState,
elements,
appStateSnapshot,
elementsSnapshot,
files,
actionManager,
onExportImage,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
appStateSnapshot,
);
const appProps = useAppProps();
const [projectName, setProjectName] = useState(appState.name);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [projectName, setProjectName] = useState(appStateSnapshot.name);
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
const [exportWithBackground, setExportWithBackground] = useState(
appState.exportBackground,
appStateSnapshot.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appState.exportWithDarkMode,
appStateSnapshot.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
const [exportScale, setExportScale] = useState(appState.exportScale);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements;
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
appStateSnapshot,
exportSelectionOnly,
);
useEffect(() => {
const previewNode = previewRef.current;
@ -102,10 +107,18 @@ const ImageExportModal = ({
}
exportToCanvas({
elements: exportedElements,
appState,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
setRenderError(null);
@ -119,7 +132,17 @@ const ImageExportModal = ({
console.error(error);
setRenderError(error);
});
}, [appState, files, exportedElements]);
}, [
appStateSnapshot,
files,
exportedElements,
exportingFrame,
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
]);
return (
<div className="ImageExportModal">
@ -136,7 +159,8 @@ const ImageExportModal = ({
value={projectName}
style={{ width: "30ch" }}
disabled={
typeof appProps.name !== "undefined" || appState.viewModeEnabled
typeof appProps.name !== "undefined" ||
appStateSnapshot.viewModeEnabled
}
onChange={(event) => {
setProjectName(event.target.value);
@ -152,16 +176,16 @@ const ImageExportModal = ({
</div>
<div className="ImageExportModal__settings">
<h3>{t("imageExportDialog.header")}</h3>
{someElementIsSelected && (
{hasSelection && (
<ExportSetting
label={t("imageExportDialog.label.onlySelected")}
name="exportOnlySelected"
>
<Switch
name="exportOnlySelected"
checked={exportSelected}
checked={exportSelectionOnly}
onChange={(checked) => {
setExportSelected(checked);
setExportSelectionOnly(checked);
}}
/>
</ExportSetting>
@ -243,7 +267,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
exportingFrame,
})
}
startIcon={downloadIcon}
>
@ -253,7 +279,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
exportingFrame,
})
}
startIcon={downloadIcon}
>
@ -264,7 +292,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
exportingFrame,
})
}
startIcon={copyIcon}
>
@ -325,15 +355,20 @@ export const ImageExportDialog = ({
onExportImage: AppClassProperties["onExportImage"];
onCloseRequest: () => void;
}) => {
if (appState.openDialog !== "imageExport") {
return null;
}
// 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),
};
});
return (
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
<ImageExportModal
elements={elements}
appState={appState}
elementsSnapshot={elementsSnapshot}
appStateSnapshot={appStateSnapshot}
files={files}
actionManager={actionManager}
onExportImage={onExportImage}

View File

@ -161,7 +161,10 @@ const LayerUI = ({
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.saveAsImage) {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog !== "imageExport"
) {
return null;
}
@ -246,7 +249,7 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
isMobile={device.isMobile}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
@ -314,7 +317,7 @@ const LayerUI = ({
)}
>
<UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.isMobile, appState)}
{renderTopRightUI?.(device.editor.isMobile, appState)}
{!appState.viewModeEnabled &&
// hide button when sidebar docked
(!isSidebarDocked ||
@ -335,7 +338,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`toggleDock (${docked ? "dock" : "undock"})`,
`(${device.isMobile ? "mobile" : "desktop"})`,
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
@ -363,7 +366,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.isMobile ? "mobile" : "desktop"})`,
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
);
}
}}
@ -380,7 +383,7 @@ const LayerUI = ({
{appState.errorMessage}
</ErrorDialog>
)}
{eyeDropperState && !device.isMobile && (
{eyeDropperState && !device.editor.isMobile && (
<EyeDropper
colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => {
@ -450,7 +453,7 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && (
{device.editor.isMobile && (
<MobileMenu
app={app}
appState={appState}
@ -469,14 +472,14 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen}
/>
)}
{!device.isMobile && (
{!device.editor.isMobile && (
<>
<div
className="layer-ui__wrapper"
style={
appState.openSidebar &&
isSidebarDocked &&
device.canDeviceFitSidebar
device.editor.canFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
}

View File

@ -47,7 +47,7 @@ export const LibraryUnit = memo(
}, [svg]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const isMobile = useDevice().editor.isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div>
);

View File

@ -0,0 +1,221 @@
@import "../css/variables.module";
$verticalBreakpoint: 860px;
.excalidraw {
.dialog-mermaid {
&-title {
margin-bottom: 5px;
margin-top: 2px;
}
&-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
}
.Modal__content .Island {
box-shadow: none;
}
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
padding: 1.25rem;
.Modal__content {
height: 100%;
max-height: 750px;
@media screen and (max-width: $verticalBreakpoint) {
height: auto;
// When vertical, we want the height to span whole viewport.
// This is also important for the children not to overflow the
// modal/viewport (for some reason).
max-height: 100%;
}
.Island {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
.Dialog__content {
display: flex;
flex: 1 1 auto;
}
}
}
}
}
.dialog-mermaid-body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100%;
column-gap: 4rem;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
}
.dialog-mermaid-panels {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: 4rem;
grid-row: 1;
grid-column: 1 / 3;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
&-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
// acts as min-height
height: 200px;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
@media screen and (max-width: $verticalBreakpoint) {
// acts as min-height
height: 400px;
width: auto;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
&-preview-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
&-preview {
display: flex;
flex-direction: column;
}
.mermaid-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
}
.dialog-mermaid-buttons {
grid-column: 2;
.dialog-mermaid-insert {
&.excalidraw-button {
font-family: "Assistant";
font-weight: 600;
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
width: 7.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}

View File

@ -0,0 +1,243 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const ErrorComp = ({ error }: { error: string }) => {
return (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const resetPreview = () => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
const api = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setMermaidToExcalidrawLib({ loaded: true, api });
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (
!mermaidToExcalidrawLib.loaded ||
!canvasNode ||
!parent ||
!mermaidToExcalidrawLib.api
) {
return;
}
if (!deferredText) {
resetPreview();
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
parent.style.background = "var(--default-bg-color)";
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, mermaidToExcalidrawLib]);
const onClose = () => {
app.setOpenDialog(null);
saveMermaidDataToStorage(text);
};
const onSelect = () => {
const { elements: newElements, files } = data.current;
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
onClose();
};
return (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;

View File

@ -59,12 +59,6 @@
&:focus {
outline: none;
}
@include isMobile {
max-width: 100%;
border: 0;
border-radius: 0;
}
}
@keyframes Modal__background__fade-in {
@ -105,7 +99,7 @@
}
}
@include isMobile {
.Dialog--fullscreen {
.Modal {
padding: 0;
}
@ -116,6 +110,9 @@
left: 0;
right: 0;
bottom: 0;
max-width: 100%;
border: 0;
border-radius: 0;
}
}
}

View File

@ -113,11 +113,11 @@ export const SidebarInner = forwardRef(
if ((event.target as Element).closest(".sidebar-trigger")) {
return;
}
if (!docked || !device.canDeviceFitSidebar) {
if (!docked || !device.editor.canFitSidebar) {
closeLibrary();
}
},
[closeLibrary, docked, device.canDeviceFitSidebar],
[closeLibrary, docked, device.editor.canFitSidebar],
),
);
@ -125,7 +125,7 @@ export const SidebarInner = forwardRef(
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!docked || !device.canDeviceFitSidebar)
(!docked || !device.editor.canFitSidebar)
) {
closeLibrary();
}
@ -134,7 +134,7 @@ export const SidebarInner = forwardRef(
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
}, [closeLibrary, docked, device.editor.canFitSidebar]);
return (
<Island

View File

@ -18,7 +18,7 @@ export const SidebarHeader = ({
const props = useContext(SidebarPropsContext);
const renderDockButton = !!(
device.canDeviceFitSidebar && props.shouldRenderDockButton
device.editor.canFitSidebar && props.shouldRenderDockButton
);
return (

View File

@ -160,6 +160,15 @@
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);

View File

@ -16,6 +16,10 @@
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.25rem;
@include isMobile {
margin: 0;
}
}
}
@ -41,5 +45,6 @@
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
z-index: 1;
}
}

View File

@ -7,8 +7,6 @@
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
row-gap: 0.75rem;

View File

@ -30,7 +30,7 @@ const MenuContent = ({
});
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile,
"dropdown-menu--mobile": device.editor.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.isMobile ? (
{device.editor.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island

View File

@ -14,7 +14,7 @@ const MenuItemContent = ({
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>

View File

@ -18,7 +18,7 @@ const MenuTrigger = ({
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"dropdown-menu-button--mobile": device.isMobile,
"dropdown-menu-button--mobile": device.editor.isMobile,
},
).trim();
return (

View File

@ -1654,6 +1654,22 @@ 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"

View File

@ -29,7 +29,7 @@ const MainMenu = Object.assign(
const device = useDevice();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile
const onClickOutside = device.editor.isMobile
? undefined
: () => setAppState({ openMenu: null });
@ -54,7 +54,7 @@ const MainMenu = Object.assign(
})}
>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
{device.editor.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">
<legend>{t("labels.collaborators")}</legend>
<UserList

View File

@ -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.isMobile && (
{shortcut && !device.editor.isMobile && (
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
)}
</>

View File

@ -105,6 +105,7 @@ export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
Assistant: 4,
};
export const THEME = {
@ -114,13 +115,18 @@ export const THEME = {
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
strokeWidth: 2 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";
@ -220,8 +226,6 @@ 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;

View File

@ -195,7 +195,7 @@
.buttonList label:focus-within,
input:focus-visible {
outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
box-shadow: 0 0 0 1px var(--color-brand-hover);
}
.buttonList {
@ -280,6 +280,11 @@
align-items: center;
justify-content: space-between;
padding: 8px;
.dropdown-menu--mobile {
bottom: 55px;
top: auto;
}
}
.App-mobile-menu {
@ -537,13 +542,13 @@
&:not(:focus) {
&:hover {
background-color: var(--input-hover-bg-color);
border-color: var(--color-brand-hover);
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
border-color: var(--color-brand-hover);
}
}
@ -592,6 +597,8 @@
background-color: var(--island-bg-color);
.ToolIcon__icon {
width: 2rem;
height: 2rem;
border-radius: 0;
}
@ -601,8 +608,8 @@
}
.App-toolbar--mobile {
overflow-x: auto;
max-width: 90vw;
overflow: visible;
max-width: 98vw;
.ToolIcon__keybinding {
display: none;

View File

@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2",
"boundElements": [
{
"id": "id41",
"id": "id45",
"type": "arrow",
},
{
"id": "id42",
"id": "id46",
"type": "arrow",
},
],
@ -45,7 +45,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id42",
"id": "id46",
"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": "id43",
"elementId": "id47",
"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": "id41",
"id": "id45",
"type": "arrow",
},
],
@ -222,7 +222,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0,
"boundElements": [
{
"id": "id44",
"id": "id48",
"type": "arrow",
},
],
@ -266,7 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0,
"boundElements": [
{
"id": "id44",
"id": "id48",
"type": "arrow",
},
],
@ -309,7 +309,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id45",
"id": "id49",
"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": "id44",
"containerId": "id48",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@ -401,173 +401,6 @@ 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",
@ -625,7 +458,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@ -664,6 +497,173 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
}
`;
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",
"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 text when start / end provided without ids 3`] = `
{
"angle": 0,
@ -671,7 +671,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0,
"boundElements": [
{
"id": "id37",
"id": "id41",
"type": "arrow",
},
],
@ -715,7 +715,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0,
"boundElements": [
{
"id": "id37",
"id": "id41",
"type": "arrow",
},
],
@ -1335,7 +1335,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 130,
"height": 0,
"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": 2,
"version": 1,
"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": 130,
"height": 0,
"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": 2,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,

View File

@ -3,11 +3,19 @@ import {
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getNonDeletedElements, isFrameElement } from "../element";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { t } from "../i18n";
import { 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";
@ -15,9 +23,61 @@ 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: readonly NonDeletedExcalidrawElement[],
elements: ExportedElements,
appState: AppState,
files: BinaryFiles,
{
@ -26,12 +86,14 @@ 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) {
@ -49,6 +111,7 @@ export const exportCanvas = async (
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
{ exportingFrame },
);
if (type === "svg") {
return await fileSave(
@ -70,6 +133,7 @@ export const exportCanvas = async (
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {

View File

@ -23,6 +23,7 @@ import {
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import { cloneJSON } from "../utils";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
@ -31,7 +32,7 @@ export const libraryItemsAtom = atom<{
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
JSON.parse(JSON.stringify(libraryItems));
cloneJSON(libraryItems);
/**
* checks if library item does not exist already in current library

View File

@ -1,7 +1,6 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { exportCanvas, prepareElementsForExport } from ".";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
@ -23,18 +22,19 @@ export const resaveAsImageWithScene = async (
exportEmbedScene: true,
};
await exportCanvas(
fileHandleType,
getNonDeletedElements(elements),
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
files,
{
false,
);
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
},
);
exportingFrame,
});
return { fileHandle };
};

View File

@ -309,6 +309,90 @@ describe("Test Transform", () => {
});
});
describe("Test Frames", () => {
it("should transform frames and update frame ids when regenerated", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchObject({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should consider max of calculated and frame dimensions when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
width: 800,
height: 100,
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excaldrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800);
expect(frame.height).toBe(126);
});
});
describe("Test arrow bindings", () => {
it("should bind arrows to shapes when start / end provided without ids", () => {
const elements = [

View File

@ -5,6 +5,7 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import {
getCommonBounds,
newElement,
newLinearElement,
redrawTextBoundingBox,
@ -12,6 +13,7 @@ import {
import { bindLinearElement } from "../element/binding";
import {
ElementConstructorOpts,
newFrameElement,
newImageElement,
newTextElement,
} from "../element/newElement";
@ -38,7 +40,7 @@ import {
VerticalAlign,
} from "../element/types";
import { MarkOptional } from "../utility-types";
import { assertNever, getFontString } from "../utils";
import { assertNever, cloneJSON, getFontString } from "../utils";
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
@ -135,9 +137,7 @@ export type ValidContainer =
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
| ExcalidrawEmbeddableElement
| ExcalidrawFreeDrawElement
| ExcalidrawFrameElement
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
>
| ({
type: Extract<ExcalidrawLinearElement["type"], "line">;
@ -158,7 +158,12 @@ export type ExcalidrawElementSkeleton =
x: number;
y: number;
fileId: FileId;
} & Partial<ExcalidrawImageElement>);
} & Partial<ExcalidrawImageElement>)
| ({
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100,
@ -363,7 +368,8 @@ 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 = JSON.parse(JSON.stringify(linearElement.points));
const newPoints = cloneJSON(linearElement.points) as [number, number][];
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
@ -434,10 +440,7 @@ export const convertToExcalidrawElements = (
if (!elementsSkeleton) {
return [];
}
const elements: ExcalidrawElementSkeleton[] = JSON.parse(
JSON.stringify(elementsSkeleton),
);
const elements = cloneJSON(elementsSkeleton);
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();
@ -536,8 +539,15 @@ export const convertToExcalidrawElements = (
break;
}
case "frame": {
excalidrawElement = newFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw":
case "frame":
case "embeddable": {
excalidrawElement = element;
break;
@ -641,5 +651,60 @@ export const convertToExcalidrawElements = (
}
}
}
// Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed.
for (const [id, element] of elementsWithIds) {
if (element.type !== "frame") {
continue;
}
const frame = elementStore.getElement(id);
if (!frame) {
throw new Error(`Excalidraw element with id ${id} doesn't exist`);
}
const childrenElements: ExcalidrawElement[] = [];
element.children.forEach((id) => {
const newElementId = oldToNewElementIdMap.get(id);
if (!newElementId) {
throw new Error(`Element with ${id} wasn't mapped correctly`);
}
const elementInFrame = elementStore.getElement(newElementId);
if (!elementInFrame) {
throw new Error(`Frame element with id ${newElementId} doesn't exist`);
}
Object.assign(elementInFrame, { frameId: frame.id });
elementInFrame?.boundElements?.forEach((boundElement) => {
const ele = elementStore.getElement(boundElement.id);
if (!ele) {
throw new Error(
`Bound element with id ${boundElement.id} doesn't exist`,
);
}
Object.assign(ele, { frameId: frame.id });
childrenElements.push(ele);
});
childrenElements.push(elementInFrame);
});
let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
const PADDING = 10;
minX = minX - PADDING;
minY = minY - PADDING;
maxX = maxX + PADDING;
maxY = maxY + PADDING;
// Take the max of calculated and provided frame dimensions, whichever is higher
const width = Math.max(frame?.width, maxX - minX);
const height = Math.max(frame?.height, maxY - minY);
Object.assign(frame, { x: minX, y: minY, width, height });
}
return elementStore.getElements();
};

View File

@ -64,6 +64,7 @@ const ALLOWED_DOMAINS = new Set([
"stackblitz.com",
"val.town",
"giphy.com",
"dddice.com",
]);
const createSrcDoc = (body: string) => {
@ -200,7 +201,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
return { link, aspectRatio, type };
};
export const isEmbeddableOrFrameLabel = (
export const isEmbeddableOrLabel = (
element: NonDeletedExcalidrawElement,
): Boolean => {
if (isEmbeddableElement(element)) {

View File

@ -170,13 +170,15 @@ export const newEmbeddableElement = (
};
export const newFrameElement = (
opts: ElementConstructorOpts,
opts: {
name?: string;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
type: "frame",
name: null,
name: opts?.name || null,
},
{},
);

View File

@ -247,7 +247,7 @@ export type SubtypeMethods = {
) => void;
renderSvg: (
svgRoot: SVGElement,
root: SVGElement,
addToRoot: (node: SVGElement, element: ExcalidrawElement) => void,
element: NonDeleted<ExcalidrawElement>,
opt?: { offsetX?: number; offsetY?: number },
) => void;

View File

@ -1065,7 +1065,7 @@ const renderMathElement = function (element, context) {
context.restore();
} as SubtypeMethods["render"];
const renderSvgMathElement = function (svgRoot, root, element, opt) {
const renderSvgMathElement = function (svgRoot, addToRoot, element, opt) {
ensureMathElement(element);
const isMathJaxLoaded = mathJaxLoaded;
@ -1102,7 +1102,7 @@ const renderSvgMathElement = function (svgRoot, root, element, opt) {
const cachedDiv = svgRoot.ownerDocument!.createElement("div");
cachedDiv.innerHTML = svgCache[key];
node.appendChild(cachedDiv.firstElementChild!);
root.appendChild(node);
addToRoot(node, element);
return;
}
@ -1209,7 +1209,7 @@ const renderSvgMathElement = function (svgRoot, root, element, opt) {
svgCache[key] = tempSvg.outerHTML;
}
node.appendChild(tempSvg);
root.appendChild(node);
addToRoot(node, element);
} as SubtypeMethods["renderSvg"];
const wrapMathElement = function (element, containerWidth, next) {

View File

@ -118,7 +118,7 @@ export const redrawTextBoundingBox = (
);
const maxContainerWidth = getBoundTextMaxWidth(container);
if (metrics.height > maxContainerHeight) {
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
const nextHeight = computeContainerDimensionForBoundText(
metrics.height,
container.type,

View File

@ -18,7 +18,7 @@ import {
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
import { getTextEditor } from "../tests/queries/dom";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -26,10 +26,7 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const tab = " ";
const mouse = new Pointer("mouse");
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
fireEvent.change(editor, { target: { value } });
editor.dispatchEvent(new Event("input"));
};
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
describe("textWysiwyg", () => {
describe("start text editing", () => {
@ -195,7 +192,7 @@ describe("textWysiwyg", () => {
mouse.clickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor(false);
const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
@ -217,7 +214,7 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor(false);
const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
@ -253,12 +250,14 @@ describe("textWysiwyg", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// @ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
textElement = UI.createElement("text");
mouse.clickOn(textElement);
textarea = await getTextEditor(true);
textarea = await getTextEditor(textEditorSelector, true);
});
afterAll(() => {
@ -468,7 +467,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
textarea = await getTextEditor(true);
textarea = await getTextEditor(textEditorSelector, true);
updateTextEditor(
textarea,
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
@ -520,7 +519,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
@ -548,7 +547,7 @@ describe("textWysiwyg", () => {
]);
expect(text.angle).toBe(rectangle.angle);
mouse.down();
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
@ -575,7 +574,7 @@ describe("textWysiwyg", () => {
API.setSelectedElements([diamond]);
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n");
@ -610,7 +609,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
let editor = await getTextEditor(true);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -625,7 +624,7 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(rectangle.id);
mouse.down();
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0));
@ -647,7 +646,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(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
@ -682,7 +681,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0));
@ -707,7 +706,7 @@ describe("textWysiwyg", () => {
freedraw.y + freedraw.height / 2,
);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
@ -741,7 +740,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
@ -756,7 +755,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(
editor,
@ -801,7 +800,7 @@ describe("textWysiwyg", () => {
mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = await getTextEditor(true);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
@ -814,7 +813,7 @@ describe("textWysiwyg", () => {
rectangle.y + rectangle.height / 2,
);
mouse.down();
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
editor.select();
fireEvent.click(screen.getByTitle(/code/i));
@ -847,7 +846,7 @@ describe("textWysiwyg", () => {
Keyboard.keyDown(KEYS.ENTER);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = await getTextEditor(true);
let editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
@ -868,7 +867,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello");
await new Promise((r) => setTimeout(r, 0));
@ -897,7 +896,7 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
@ -934,7 +933,7 @@ describe("textWysiwyg", () => {
// Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
editor.blur();
@ -955,7 +954,7 @@ describe("textWysiwyg", () => {
it("should respect text alignment when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
let editor = await getTextEditor(true);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@ -972,7 +971,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
editor.select();
@ -995,7 +994,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
editor.select();
@ -1033,7 +1032,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
@ -1048,7 +1047,7 @@ describe("textWysiwyg", () => {
it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@ -1068,7 +1067,7 @@ describe("textWysiwyg", () => {
it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@ -1100,7 +1099,7 @@ describe("textWysiwyg", () => {
it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@ -1137,7 +1136,7 @@ describe("textWysiwyg", () => {
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, " ");
@ -1192,7 +1191,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(true);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@ -1204,7 +1203,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -1220,7 +1219,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
editor.blur();
@ -1245,7 +1244,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = await getTextEditor(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
editor.blur();
expect(
@ -1277,12 +1276,12 @@ describe("textWysiwyg", () => {
beforeEach(async () => {
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello");
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
editor.select();
});
@ -1393,7 +1392,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(true);
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(
editor,
@ -1481,7 +1480,7 @@ describe("textWysiwyg", () => {
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = await getTextEditor(true);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
expect(
@ -1506,7 +1505,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
editor = await getTextEditor(true);
editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw");
editor.blur();

View File

@ -1,6 +1,7 @@
import { ROUNDNESS } from "../constants";
import { AppState } from "../types";
import { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import {
ExcalidrawElement,
ExcalidrawTextElement,
@ -140,17 +141,32 @@ export const isTextBindableContainer = (
);
};
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 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 hasBoundTextElement = (

View File

@ -201,24 +201,52 @@ 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, getFrameElements(elements, frameId));
frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
}
}
return frameElementsMap;
};
export const getFrameElements = (
export const getFrameChildren = (
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 = getFrameElements(allElements, frame.id);
const prevElementsInFrame = getFrameChildren(allElements, frame.id);
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
const elementsCompletelyInFrame = new Set([
@ -449,7 +477,7 @@ export const removeAllElementsFromFrame = (
frame: ExcalidrawFrameElement,
appState: AppState,
) => {
const elementsInFrame = getFrameElements(allElements, frame.id);
const elementsInFrame = getFrameChildren(allElements, frame.id);
return removeElementsFromFrame(allElements, elementsInFrame, appState);
};

View File

@ -1,4 +1,4 @@
import { useState, useRef, useLayoutEffect } from "react";
import { useState, useLayoutEffect } from "react";
import { useDevice, useExcalidrawContainer } from "../components/App";
import { useUIAppState } from "../context/ui-appState";
@ -10,16 +10,17 @@ 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.classList.toggle("excalidraw--mobile", device.isMobile);
div.className = "";
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
div.classList.toggle("theme--dark", theme === "dark");
}
}, [div, device.isMobile]);
}, [div, theme, device.editor.isMobile, opts?.className]);
useLayoutEffect(() => {
const container = opts?.parentSelector
@ -32,10 +33,6 @@ 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);
@ -43,7 +40,7 @@ export const useCreatePortalContainer = (opts?: {
return () => {
container.removeChild(div);
};
}, [excalidrawContainer, theme, opts?.className, opts?.parentSelector]);
}, [excalidrawContainer, opts?.parentSelector]);
return div;
};

View File

@ -242,7 +242,8 @@
"embeddable": "Web Embed",
"laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools"
"extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw"
},
"headings": {
"canvasActions": "Canvas actions",
@ -501,5 +502,12 @@
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
}
}
},
"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"
}
}

View File

@ -1,7 +1,7 @@
[
{
"path": "dist/excalidraw.production.min.js",
"limit": "320 kB"
"limit": "325 kB"
},
{
"path": "dist/excalidraw-assets/locales",
@ -11,6 +11,6 @@
{
"path": "dist/excalidraw-assets/vendor-*.js",
"name": "dist/excalidraw-assets/vendor*.js",
"limit": "30 kB"
"limit": "900 kB"
}
]

View File

@ -15,10 +15,28 @@ 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)
## 0.16.1 (2023-09-21)
## Excalidraw Library

View File

@ -665,7 +665,9 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
</div>
<div className="excalidraw-wrapper">
<Excalidraw
ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
excalidrawAPI={(api: ExcalidrawImperativeAPI) =>
setExcalidrawAPI(api)
}
initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) => {
console.info("Elements :", elements, "State : ", state);

View File

@ -8,7 +8,7 @@ const MobileFooter = ({
excalidrawAPI: ExcalidrawImperativeAPI;
}) => {
const device = useDevice();
if (device.isMobile) {
if (device.editor.isMobile) {
return (
<Footer>
<CustomFooter excalidrawAPI={excalidrawAPI} />

View File

@ -7,6 +7,7 @@ const elements: ExcalidrawElementSkeleton[] = [
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
@ -19,6 +20,7 @@ const elements: ExcalidrawElementSkeleton[] = [
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "arrow",
@ -36,6 +38,11 @@ const elements: ExcalidrawElementSkeleton[] = [
height: 230,
fileId: "rocket" as FileId,
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
},
];
export default {
elements,

View File

@ -1,4 +1,4 @@
import React, { useEffect, forwardRef } from "react";
import React, { useEffect } 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, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { AppProps, 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,
excalidrawRef,
excalidrawAPI,
isCollaborating = false,
onPointerUpdate,
renderTopRightUI,
@ -95,7 +95,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<App
onChange={onChange}
initialData={initialData}
excalidrawRef={excalidrawRef}
excalidrawAPI={excalidrawAPI}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
renderTopRightUI={renderTopRightUI}
@ -127,12 +127,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
);
};
type PublicExcalidrawProps = Omit<ExcalidrawProps, "forwardedRef">;
const areEqual = (
prevProps: PublicExcalidrawProps,
nextProps: PublicExcalidrawProps,
) => {
const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
// short-circuit early
if (prevProps.children !== nextProps.children) {
return false;
@ -189,12 +184,7 @@ const areEqual = (
return isUIOptionsSame && isShallowEqual(prev, next);
};
const forwardedRefComp = forwardRef<
ExcalidrawAPIRefValue,
PublicExcalidrawProps
>((props, ref) => <ExcalidrawBase {...props} excalidrawRef={ref} />);
export const Excalidraw = React.memo(forwardedRefComp, areEqual);
export const Excalidraw = React.memo(ExcalidrawBase, areEqual);
Excalidraw.displayName = "Excalidraw";
export {
@ -254,6 +244,7 @@ export { DefaultSidebar } from "../../components/DefaultSidebar";
export { normalizeLink } from "../../data/url";
export { convertToExcalidrawElements } from "../../data/transform";
export { getCommonBounds } from "../../element/bounds";
export {
elementsOverlappingBBox,

View File

@ -41,6 +41,14 @@ 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:

View File

@ -44,6 +44,14 @@ 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:

View File

@ -4,7 +4,11 @@ import {
} from "../scene/export";
import { getDefaultAppState } from "../appState";
import { AppState, BinaryFiles } from "../types";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeleted,
} from "../element/types";
import { restore } from "../data/restore";
import { MIME_TYPES } from "../constants";
import { encodePngMetadata } from "../data/image";
@ -14,24 +18,6 @@ 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 };
@ -40,6 +26,7 @@ type ExportOpts = {
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameElement | null;
getDimensions?: (
width: number,
height: number,
@ -53,6 +40,7 @@ export const exportToCanvas = ({
maxWidthOrHeight,
getDimensions,
exportPadding,
exportingFrame,
}: ExportOpts & {
exportPadding?: number;
}) => {
@ -63,10 +51,10 @@ export const exportToCanvas = ({
);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
passElementsSafely(restoredElements),
restoredElements,
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {},
{ exportBackground, exportPadding, viewBackgroundColor },
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
(width: number, height: number) => {
const canvas = document.createElement("canvas");
@ -135,10 +123,8 @@ export const exportToBlob = async (
};
}
const canvas = await exportToCanvas({
...opts,
elements: passElementsSafely(opts.elements),
});
const canvas = await exportToCanvas(opts);
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
@ -179,6 +165,7 @@ export const exportToSvg = async ({
files = {},
exportPadding,
renderEmbeddables,
exportingFrame,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
renderEmbeddables?: boolean;
@ -194,20 +181,10 @@ export const exportToSvg = async ({
exportPadding,
};
return _exportToSvg(
passElementsSafely(restoredElements),
exportAppState,
files,
{
return _exportToSvg(restoredElements, exportAppState, files, {
exportingFrame,
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 (

View File

@ -6,13 +6,14 @@ 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 } from "../element/bounds";
import { Bounds, getElementBounds } from "../element/bounds";
type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[];
@ -146,7 +147,7 @@ export const elementsOverlappingBBox = ({
errorMargin = 0,
}: {
elements: Elements;
bounds: Bounds;
bounds: Bounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */
errorMargin?: number;
/**
@ -156,6 +157,9 @@ export const elementsOverlappingBBox = ({
**/
type: "overlap" | "contain" | "inside";
}) => {
if (isExcalidrawElement(bounds)) {
bounds = getElementBounds(bounds);
}
const adjustedBBox: Bounds = [
bounds[0] - errorMargin,
bounds[1] - errorMargin,

View File

@ -20,7 +20,13 @@ import type { Drawable } from "roughjs/bin/core";
import type { RoughSVG } from "roughjs/bin/svg";
import { StaticCanvasRenderConfig } from "../scene/types";
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
import {
distance,
getFontString,
getFontFamilyString,
isRTL,
isTestEnv,
} from "../utils";
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import {
@ -596,11 +602,7 @@ export const renderElement = (
) => {
switch (element.type) {
case "frame": {
if (
!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.outline
) {
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
context.save();
context.translate(
element.x + appState.scrollX,
@ -608,7 +610,7 @@ export const renderElement = (
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = 2 / appState.zoom.value;
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
if (FRAME_STYLE.radius && context.roundRect) {
@ -848,10 +850,13 @@ const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
exportedFrameId?: string | null,
frameRendering: AppState["frameRendering"],
) => {
if (!frameRendering.enabled || !frameRendering.clip) {
return null;
}
const frame = getContainingFrame(element);
if (frame && frame.id === exportedFrameId) {
if (frame) {
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
nodes.forEach((node) => g.appendChild(node));
@ -868,9 +873,11 @@ export const renderElementToSvg = (
files: BinaryFiles,
offsetX: number,
offsetY: number,
exportWithDarkMode?: boolean,
exportingFrameId?: string | null,
renderEmbeddables?: boolean,
renderConfig: {
exportWithDarkMode: boolean;
renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
},
) => {
const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -904,11 +911,19 @@ export const renderElementToSvg = (
root = anchorTag;
}
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
if (isTestEnv()) {
node.setAttribute("data-id", element.id);
}
root.appendChild(node);
};
const map = getSubtypeMethods(element.subtype);
if (map?.renderSvg) {
map.renderSvg(svgRoot, root, element, { offsetX, offsetY });
map.renderSvg(svgRoot, addToRoot, element, { offsetX, offsetY });
return;
}
const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@ -943,10 +958,10 @@ export const renderElementToSvg = (
element,
root,
[node],
exportingFrameId,
renderConfig.frameRendering,
);
g ? root.appendChild(g) : root.appendChild(node);
addToRoot(g || node, element);
break;
}
case "embeddable": {
@ -969,7 +984,7 @@ export const renderElementToSvg = (
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
root.appendChild(node);
addToRoot(node, element);
const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element);
@ -980,9 +995,7 @@ export const renderElementToSvg = (
files,
label.x + offset.x - element.x,
label.y + offset.y - element.y,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
renderConfig,
);
// render embeddable element + iframe
@ -1011,7 +1024,10 @@ 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 (renderEmbeddables === false || embedLink?.type === "document") {
if (
renderConfig.renderEmbeddables === false ||
embedLink?.type === "document"
) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
anchorTag.setAttribute("target", "_blank");
@ -1045,8 +1061,7 @@ export const renderElementToSvg = (
embeddableNode.appendChild(foreignObject);
}
root.appendChild(embeddableNode);
addToRoot(embeddableNode, element);
break;
}
case "line":
@ -1131,12 +1146,13 @@ export const renderElementToSvg = (
element,
root,
[group, maskPath],
exportingFrameId,
renderConfig.frameRendering,
);
if (g) {
addToRoot(g, element);
root.appendChild(g);
} else {
root.appendChild(group);
addToRoot(group, element);
root.append(maskPath);
}
break;
@ -1170,10 +1186,10 @@ export const renderElementToSvg = (
element,
root,
[node],
exportingFrameId,
renderConfig.frameRendering,
);
g ? root.appendChild(g) : root.appendChild(node);
addToRoot(g || node, element);
break;
}
case "image": {
@ -1203,7 +1219,10 @@ export const renderElementToSvg = (
use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter
if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) {
if (
renderConfig.exportWithDarkMode &&
fileData.mimeType !== MIME_TYPES.svg
) {
use.setAttribute("filter", IMAGE_INVERT_FILTER);
}
@ -1239,14 +1258,39 @@ export const renderElementToSvg = (
element,
root,
[g],
exportingFrameId,
renderConfig.frameRendering,
);
clipG ? root.appendChild(clipG) : root.appendChild(g);
addToRoot(clipG || g, element);
}
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: {
@ -1300,10 +1344,10 @@ export const renderElementToSvg = (
element,
root,
[node],
exportingFrameId,
renderConfig.frameRendering,
);
g ? root.appendChild(g) : root.appendChild(node);
addToRoot(g || node, element);
} else {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);

View File

@ -60,7 +60,7 @@ import {
TransformHandles,
TransformHandleType,
} from "../element/transformHandles";
import { throttleRAF, isOnlyExportingSingleFrame } from "../utils";
import { throttleRAF } from "../utils";
import { UserIdleState } from "../types";
import { FRAME_STYLE, THEME_FILTER } from "../constants";
import {
@ -74,7 +74,7 @@ import {
isLinearElement,
} from "../element/typeChecks";
import {
isEmbeddableOrFrameLabel,
isEmbeddableOrLabel,
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 && !renderConfig.isExporting) {
if (context.roundRect) {
context.roundRect(
0,
0,
@ -963,20 +963,15 @@ const _renderStaticScene = ({
// Paint visible elements
visibleElements
.filter((el) => !isEmbeddableOrFrameLabel(el))
.filter((el) => !isEmbeddableOrLabel(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 &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
appState.frameRendering.clip
) {
context.save();
@ -1001,7 +996,7 @@ const _renderStaticScene = ({
// render embeddables on top
visibleElements
.filter((el) => isEmbeddableOrFrameLabel(el))
.filter((el) => isEmbeddableOrLabel(el))
.forEach((element) => {
try {
const render = () => {
@ -1027,10 +1022,8 @@ const _renderStaticScene = ({
if (
frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
appState.frameRendering.clip
) {
context.save();
@ -1298,7 +1291,7 @@ const renderFrameHighlight = (
const height = y2 - y1;
context.strokeStyle = "rgb(0,118,255)";
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value;
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.save();
context.translate(appState.scrollX, appState.scrollY);
@ -1454,24 +1447,29 @@ export const renderSceneToSvg = (
{
offsetX = 0,
offsetY = 0,
exportWithDarkMode = false,
exportingFrameId = null,
exportWithDarkMode,
renderEmbeddables,
frameRendering,
}: {
offsetX?: number;
offsetY?: number;
exportWithDarkMode?: boolean;
exportingFrameId?: string | null;
renderEmbeddables?: boolean;
} = {},
exportWithDarkMode: boolean;
renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
},
) => {
if (!svgRoot) {
return;
}
const renderConfig = {
exportWithDarkMode,
renderEmbeddables,
frameRendering,
};
// render elements
elements
.filter((el) => !isEmbeddableOrFrameLabel(el))
.filter((el) => !isEmbeddableOrLabel(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
@ -1482,9 +1480,7 @@ export const renderSceneToSvg = (
files,
element.x + offsetX,
element.y + offsetY,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
renderConfig,
);
} catch (error: any) {
console.error(error);
@ -1505,9 +1501,7 @@ export const renderSceneToSvg = (
files,
element.x + offsetX,
element.y + offsetY,
exportWithDarkMode,
exportingFrameId,
renderEmbeddables,
renderConfig,
);
} catch (error: any) {
console.error(error);

View File

@ -66,18 +66,31 @@ class Scene {
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
static mapElementToScene(
elementKey: ElementKey,
scene: Scene,
/**
* needed because of frame exporting hack.
* elementId:Scene mapping will be removed completely, soon.
*/
mapElementIds = true,
) {
if (isIdKey(elementKey)) {
if (!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);
}
}
}
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
@ -217,7 +230,10 @@ class Scene {
return didChange;
}
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
replaceAllElements(
nextElements: readonly ExcalidrawElement[],
mapElementIds = true,
) {
this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = [];
this.elementsMap.clear();

View File

@ -14,18 +14,34 @@ 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(size: number, roughness: number): number {
if (size >= 50) {
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)
) {
return roughness;
}
const factor = 2 + (50 - size) / 10;
return roughness / factor;
return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
}
export const generateRoughOptions = (
@ -54,10 +70,7 @@ 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(
Math.min(element.width, element.height),
element.roughness,
),
roughness: adjustRoughness(element),
stroke: element.strokeColor,
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,

View File

@ -1,24 +1,144 @@
import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
Bounds,
getCommonBounds,
getElementAbsoluteCoords,
} from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils";
import { distance, getFontString } from "../utils";
import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
import {
DEFAULT_EXPORT_PADDING,
FRAME_STYLE,
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
scene.replaceAllElements(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: 4,
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,
};
};
export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
@ -27,10 +147,12 @@ export const exportToCanvas = async (
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
exportingFrame,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameElement | null;
},
createCanvas: (
width: number,
@ -42,7 +164,26 @@ export const exportToCanvas = async (
return { canvas, scale: appState.exportScale };
},
) => {
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements();
let nextElements: ExcalidrawElement[];
if (exportingFrame) {
exportPadding = 0;
nextElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else {
nextElements = addFrameLabelsAsTextElements(elements, appState);
}
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(nextElements),
exportPadding,
);
const { canvas, scale = 1 } = createCanvas(width, height);
@ -50,25 +191,27 @@ export const exportToCanvas = async (
const { imageCache } = await updateImageCache({
imageCache: new Map(),
fileIds: getInitializedImageElements(elements).map(
fileIds: getInitializedImageElements(nextElements).map(
(element) => element.fileId,
),
files,
});
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderStaticScene({
canvas,
rc: rough.canvas(canvas),
elements,
visibleElements: elements,
elements: nextElements,
visibleElements: nextElements,
scale,
appState: {
...appState,
frameRendering: getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
),
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollX: -minX + exportPadding,
scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? "dark" : "light",
@ -80,6 +223,8 @@ export const exportToCanvas = async (
},
});
tempScene.destroy();
return canvas;
};
@ -92,35 +237,65 @@ export const exportToSvg = async (
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
renderFrame?: boolean;
frameRendering?: AppState["frameRendering"];
},
files: BinaryFiles | null,
opts?: {
serializeAsJSON?: () => string;
renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameElement | null;
},
): Promise<SVGSVGElement> => {
const {
const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements();
let {
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = appState;
const { exportingFrame = null } = opts || {};
let nextElements: ExcalidrawElement[] = [];
if (exportingFrame) {
exportPadding = 0;
nextElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else {
nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode: appState.exportWithDarkMode ?? false,
});
}
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({
text: opts?.serializeAsJSON
? opts?.serializeAsJSON?.()
: serializeAsJSON(elements, appState, files || {}, "local"),
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
text: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(nextElements),
exportPadding,
);
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
@ -148,33 +323,23 @@ export const exportToSvg = async (
assetPath = `${assetPath}/dist/excalidraw-assets/`;
}
// do not apply clipping when we're exporting the whole scene
const isExportingWholeCanvas =
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
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");
const frameElements = getFrameElements(elements);
let exportingFrameClipPath = "";
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);
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);
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}"
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}"
>
</rect>
</clipPath>`;
@ -193,6 +358,10 @@ 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>
@ -210,14 +379,19 @@ export const exportToSvg = async (
}
const rsvg = rough.svg(svgRoot);
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
renderSceneToSvg(nextElements, rsvg, svgRoot, files || {}, {
offsetX,
offsetY,
exportWithDarkMode: appState.exportWithDarkMode,
exportingFrameId: exportingFrame?.id || null,
renderEmbeddables: opts?.renderEmbeddables,
exportWithDarkMode: appState.exportWithDarkMode ?? false,
renderEmbeddables: opts?.renderEmbeddables ?? false,
frameRendering: getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
),
});
tempScene.destroy();
return svgRoot;
};
@ -226,36 +400,9 @@ 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) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
const height =
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
const width = distance(minX, maxX) + exportPadding * 2;
const height = distance(minY, maxY) + exportPadding * 2;
return [minX, minY, width, height];
};

View File

@ -8,7 +8,7 @@ import { isBoundToContainer } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameElements,
getFrameChildren,
} 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") {
getFrameElements(elements, element.id).forEach((e) =>
getFrameChildren(elements, element.id).forEach((e) =>
elementsToInclude.push(e),
);
}

View File

@ -0,0 +1,167 @@
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>
`);
});
});

View File

@ -0,0 +1,10 @@
// 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] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|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>"
`;

View File

@ -14,8 +14,12 @@ 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)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\" data-id=\\"id1\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\" data-id=\\"id2\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\" data-id=\\"id3\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\" data-id=\\"id4\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
`;

View File

@ -27,6 +27,7 @@ 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");
@ -206,16 +207,14 @@ const checkElementsBoundingBox = async (
};
const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0];
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
};
const checkTwoPointsLineHorizontalFlip = async () => {
const originalElement = JSON.parse(
JSON.stringify(h.elements[0]),
) as ExcalidrawLinearElement;
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0] as ExcalidrawLinearElement;
await waitFor(() => {
@ -239,9 +238,7 @@ const checkTwoPointsLineHorizontalFlip = async () => {
};
const checkTwoPointsLineVerticalFlip = async () => {
const originalElement = JSON.parse(
JSON.stringify(h.elements[0]),
) as ExcalidrawLinearElement;
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
h.app.actionManager.executeAction(actionFlipVertical);
const newElement = h.elements[0] as ExcalidrawLinearElement;
await waitFor(() => {
@ -268,7 +265,7 @@ const checkRotatedHorizontalFlip = async (
expectedAngle: number,
toleranceInPx: number = 0.00001,
) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipHorizontal);
const newElement = h.elements[0];
await waitFor(() => {
@ -281,7 +278,7 @@ const checkRotatedVerticalFlip = async (
expectedAngle: number,
toleranceInPx: number = 0.00001,
) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipVertical);
const newElement = h.elements[0];
await waitFor(() => {
@ -291,7 +288,7 @@ const checkRotatedVerticalFlip = async (
};
const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipVertical);
@ -300,7 +297,7 @@ const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
};
const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
const originalElement = cloneJSON(h.elements[0]);
h.app.actionManager.executeAction(actionFlipHorizontal);
h.app.actionManager.executeAction(actionFlipVertical);

View File

@ -6,6 +6,7 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
FileId,
ExcalidrawFrameElement,
} from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@ -160,6 +161,8 @@ export class API {
? ExcalidrawTextElement
: T extends "image"
? ExcalidrawImageElement
: T extends "frame"
? ExcalidrawFrameElement
: ExcalidrawGenericElement => {
let element: Mutable<ExcalidrawElement> = null!;

View File

@ -468,16 +468,16 @@ export class UI {
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const openedEditor = document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector);
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}

View File

@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(line.points.length).toEqual(3);
@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[1] + delta,
]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
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(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(20);
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(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5);

View File

@ -15,7 +15,9 @@ describe("event callbacks", () => {
beforeEach(async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render(
<Excalidraw ref={(api) => excalidrawAPIPromise.resolve(api as any)} />,
<Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>,
);
excalidrawAPI = await excalidrawAPIPromise;
});

View File

@ -92,7 +92,10 @@ describe("exportToSvg", () => {
expect(passedOptionsWhenDefault).toMatchSnapshot();
});
it("with deleted elements", async () => {
// FIXME the utils.exportToSvg no longer filters out deleted elements.
// It's already supposed to be passed non-deleted elements by we're not
// type-checking for it correctly.
it.skip("with deleted elements", async () => {
await utils.exportToSvg({
...diagramFactory({
overrides: { appState: void 0 },

View File

@ -1,13 +1,19 @@
import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (waitForEditor = true) => {
const query = () =>
document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
export const getTextEditor = async (selector: string, waitForEditor = true) => {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));
return query();
}
return query();
};
export const updateTextEditor = (
editor: HTMLTextAreaElement,
value: string,
) => {
fireEvent.change(editor, { target: { value } });
editor.dispatchEvent(new Event("input"));
};

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,10 @@ import {
ellipseFixture,
rectangleWithLinkFixture,
} from "../fixtures/elementFixture";
import { API } from "../helpers/api";
import { exportToCanvas, exportToSvg } from "../../packages/utils";
import { FRAME_STYLE } from "../../constants";
import { prepareElementsForExport } from "../../data";
describe("exportToSvg", () => {
window.EXCALIDRAW_ASSET_PATH = "/";
@ -127,3 +131,280 @@ describe("exportToSvg", () => {
expect(svgElement.innerHTML).toMatchSnapshot();
});
});
describe("exporting frames", () => {
const getFrameNameHeight = (exportType: "canvas" | "svg") => {
const height =
FRAME_STYLE.nameFontSize * FRAME_STYLE.nameLineHeight +
FRAME_STYLE.nameOffsetY;
// canvas truncates dimensions to integers
if (exportType === "canvas") {
return Math.trunc(height);
}
return height;
};
// a few tests with exportToCanvas (where we can't inspect elements)
// ---------------------------------------------------------------------------
describe("exportToCanvas", () => {
it("exporting canvas with a single frame shouldn't crop if not exporting frame directly", async () => {
const elements = [
API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
}),
API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 100,
y: 0,
}),
];
const canvas = await exportToCanvas({
elements,
files: null,
exportPadding: 0,
});
expect(canvas.width).toEqual(200);
expect(canvas.height).toEqual(100 + getFrameNameHeight("canvas"));
});
it("exporting canvas with a single frame should crop when exporting frame directly", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const elements = [
frame,
API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 100,
y: 0,
}),
];
const canvas = await exportToCanvas({
elements,
files: null,
exportPadding: 0,
exportingFrame: frame,
});
expect(canvas.width).toEqual(frame.width);
expect(canvas.height).toEqual(frame.height);
});
});
// exportToSvg (so we can test for element existence)
// ---------------------------------------------------------------------------
describe("exportToSvg", () => {
it("exporting frame should include overlapping elements, but crop to frame", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 0,
y: 50,
frameId: frame.id,
});
const rectOverlapping = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 50,
y: 0,
});
const svg = await exportToSvg({
elements: [rectOverlapping, frame, frameChild],
files: null,
exportPadding: 0,
exportingFrame: frame,
});
// frame itself isn't exported
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
// frame child is exported
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
// overlapping element is exported
expect(
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
).not.toBeNull();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(frame.height.toString());
});
it("should filter non-overlapping elements when exporting a frame", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 0,
y: 50,
frameId: frame.id,
});
const elementOutside = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 200,
y: 0,
});
const svg = await exportToSvg({
elements: [frameChild, frame, elementOutside],
files: null,
exportPadding: 0,
exportingFrame: frame,
});
// frame itself isn't exported
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
// frame child is exported
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
// non-overlapping element is not exported
expect(svg.querySelector(`[data-id="${elementOutside.id}"]`)).toBeNull();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(frame.height.toString());
});
it("should export multiple frames when selected, excluding overlapping elements", async () => {
const frame1 = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frame2 = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 200,
y: 0,
});
const frame1Child = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 0,
y: 50,
frameId: frame1.id,
});
const frame2Child = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 200,
y: 0,
frameId: frame2.id,
});
const frame2Overlapping = API.createElement({
type: "rectangle",
width: 100,
height: 100,
x: 350,
y: 0,
});
// low-level exportToSvg api expects elements to be pre-filtered, so let's
// use the filter we use in the editor
const { exportedElements, exportingFrame } = prepareElementsForExport(
[frame1Child, frame1, frame2Child, frame2, frame2Overlapping],
{
selectedElementIds: { [frame1.id]: true, [frame2.id]: true },
},
true,
);
const svg = await exportToSvg({
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
// frames themselves should be exported when multiple frames selected
expect(svg.querySelector(`[data-id="${frame1.id}"]`)).not.toBeNull();
expect(svg.querySelector(`[data-id="${frame2.id}"]`)).not.toBeNull();
// children should be epxorted
expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull();
expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).not.toBeNull();
// overlapping elements or non-overlapping elements should not be exported
expect(
svg.querySelector(`[data-id="${frame2Overlapping.id}"]`),
).toBeNull();
expect(svg.getAttribute("width")).toBe(
(frame2.x + frame2.width).toString(),
);
expect(svg.getAttribute("height")).toBe(
(frame2.y + frame2.height + getFrameNameHeight("svg")).toString(),
);
});
it("should render frame alone when not selected", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
// low-level exportToSvg api expects elements to be pre-filtered, so let's
// use the filter we use in the editor
const { exportedElements, exportingFrame } = prepareElementsForExport(
[frame],
{
selectedElementIds: {},
},
false,
);
const svg = await exportToSvg({
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
// frame itself isn't exported
expect(svg.querySelector(`[data-id="${frame.id}"]`)).not.toBeNull();
expect(svg.getAttribute("width")).toBe(frame.width.toString());
expect(svg.getAttribute("height")).toBe(
(frame.height + getFrameNameHeight("svg")).toString(),
);
});
});
});

View File

@ -173,14 +173,18 @@ export const withExcalidrawDimensions = async (
) => {
mockBoundingClientRect(dimensions);
// @ts-ignore
window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
window.h.app.refresh();
await cb();
restoreOriginalGetBoundingClientRect();
// @ts-ignore
window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
window.h.app.refresh();
};

View File

@ -14,7 +14,9 @@ describe("setActiveTool()", () => {
beforeEach(async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render(
<Excalidraw ref={(api) => excalidrawAPIPromise.resolve(api as any)} />,
<Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>,
);
excalidrawAPI = await excalidrawAPIPromise;
});

View File

@ -24,7 +24,7 @@ import { LinearElementEditor } from "./element/linearElementEditor";
import { SuggestedBinding } from "./element/binding";
import { ImportedDataState } from "./data/types";
import type App from "./components/App";
import type { ResolvablePromise, throttleRAF } from "./utils";
import type { throttleRAF } from "./utils";
import { Spreadsheet } from "./charts";
import { Language } from "./i18n";
import { ClipboardData } from "./clipboard";
@ -41,7 +41,7 @@ import type { FileSystemHandle } from "./data/filesystem";
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import { ContextMenuItems } from "./components/ContextMenu";
import { SnapLine } from "./snapping";
import { Merge, ForwardRef, ValueOf } from "./utility-types";
import { Merge, ValueOf } from "./utility-types";
export type Point = Readonly<RoughPoint>;
@ -252,7 +252,7 @@ export type AppState = {
openMenu: "canvas" | "shape" | null;
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: "imageExport" | "help" | "jsonExport" | null;
openDialog: "imageExport" | "help" | "jsonExport" | "mermaid" | null;
/**
* Reflects user preference for whether the default sidebar should be docked.
*
@ -373,15 +373,6 @@ export type LibraryItemsSource =
| Promise<LibraryItems_anyVersion | Blob>;
// -----------------------------------------------------------------------------
// NOTE ready/readyPromise props are optional for host apps' sake (our own
// implem guarantees existence)
export type ExcalidrawAPIRefValue =
| ExcalidrawImperativeAPI
| {
readyPromise?: ResolvablePromise<ExcalidrawImperativeAPI>;
ready?: false;
};
export type ExcalidrawInitialDataState = Merge<
ImportedDataState,
{
@ -401,7 +392,7 @@ export interface ExcalidrawProps {
| ExcalidrawInitialDataState
| null
| Promise<ExcalidrawInitialDataState | null>;
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
isCollaborating?: boolean;
onPointerUpdate?: (payload: {
pointer: { x: number; y: number; tool: "pointer" | "laser" };
@ -548,8 +539,12 @@ export type AppClassProperties = {
onInsertElements: App["onInsertElements"];
onExportImage: App["onExportImage"];
lastViewportPosition: App["lastViewportPosition"];
scrollToContent: App["scrollToContent"];
addFiles: App["addFiles"];
addElementsFromPasteOrLibrary: App["addElementsFromPasteOrLibrary"];
togglePenMode: App["togglePenMode"];
setActiveTool: App["setActiveTool"];
setOpenDialog: App["setOpenDialog"];
};
export type PointerDownState = Readonly<{
@ -642,8 +637,6 @@ export type ExcalidrawImperativeAPI = {
refresh: InstanceType<typeof App>["refresh"];
setToast: InstanceType<typeof App>["setToast"];
addFiles: (data: BinaryFileData[]) => void;
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
id: string;
setActiveTool: InstanceType<typeof App>["setActiveTool"];
setCursor: InstanceType<typeof App>["setCursor"];
@ -679,11 +672,15 @@ export type ExcalidrawImperativeAPI = {
};
export type Device = Readonly<{
isSmScreen: boolean;
viewport: {
isMobile: boolean;
isTouchScreen: boolean;
canDeviceFitSidebar: boolean;
isLandscape: boolean;
};
editor: {
isMobile: boolean;
canFitSidebar: boolean;
};
isTouchScreen: boolean;
}>;
type FrameNameBounds = {

View File

@ -834,11 +834,18 @@ export const isOnlyExportingSingleFrame = (
);
};
/**
* supply `null` as message if non-never value is valid, you just need to
* typecheck against it
*/
export const assertNever = (
value: never,
message: string,
message: string | null,
softAssert?: boolean,
): never => {
if (!message) {
return value;
}
if (softAssert) {
console.error(message);
return value;
@ -931,3 +938,5 @@ export const isMemberOf = <T extends string>(
? collection.includes(value as T)
: collection.hasOwnProperty(value);
};
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));

View File

@ -6,7 +6,7 @@ export default defineConfig({
globals: true,
environment: "jsdom",
coverage: {
reporter: ["text", "json-summary", "json"],
reporter: ["text", "json-summary", "json", "html"],
lines: 70,
branches: 70,
functions: 68,

730
yarn.lock
View File

@ -1321,6 +1321,11 @@
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f"
integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==
"@braintree/sanitize-url@^6.0.2":
version "6.0.4"
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783"
integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==
"@esbuild/android-arm64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz#bafb75234a5d3d1b690e7c2956a599345e84a2fd"
@ -1578,6 +1583,20 @@
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba"
integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw==
"@excalidraw/markdown-to-text@0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.1.2.tgz#be7b412536fc00b7986ccdccba8e7c33592aa004"
integrity sha512-LFk+cLGhXlvRTaf0f6ClCFIZFRsbZPb1ke2cytr5/JlnOefnXQQHgWITafskjcIO2c34KXFGO0HjgYPNFLUknw==
dependencies:
"@excalidraw/markdown-to-text" "0.1.2"
mermaid "10.2.3"
nanoid "4.0.2"
"@excalidraw/prettier-config@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
@ -2556,6 +2575,13 @@
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc"
integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==
"@types/debug@^4.0.0":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
integrity sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==
dependencies:
"@types/ms" "*"
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@ -2628,6 +2654,18 @@
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==
"@types/mdast@^3.0.0":
version "3.0.12"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.12.tgz#beeb511b977c875a5b0cc92eab6fcac2f0895514"
integrity sha512-DT+iNIRNX884cx0/Q1ja7NyUPpZuv0KPyL5rGNxm1WC1OtHstl7n4Jb7nk+xacNShQMbczJjt8uFzznpp6kYBg==
dependencies:
"@types/unist" "^2"
"@types/ms@*":
version "0.7.31"
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0":
version "18.15.11"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f"
@ -2738,6 +2776,11 @@
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311"
integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
"@types/unist@^2", "@types/unist@^2.0.0":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.7.tgz#5b06ad6894b236a1d2bd6b2f07850ca5c59cf4d6"
integrity sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==
"@types/yargs-parser@*":
version "21.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
@ -3436,6 +3479,11 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
character-entities@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
check-error@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
@ -3539,6 +3587,11 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
commander@7:
version "7.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commander@9.2.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9"
@ -3611,6 +3664,20 @@ corser@^2.0.1:
resolved "https://registry.yarnpkg.com/corser/-/corser-2.0.1.tgz#8eda252ecaab5840dcd975ceb90d9370c819ff87"
integrity sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==
cose-base@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-1.0.3.tgz#650334b41b869578a543358b80cda7e0abe0a60a"
integrity sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==
dependencies:
layout-base "^1.0.0"
cose-base@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-2.2.0.tgz#1c395c35b6e10bb83f9769ca8b817d614add5c01"
integrity sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==
dependencies:
layout-base "^2.0.0"
cosmiconfig@^7.0.0, cosmiconfig@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6"
@ -3679,6 +3746,280 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
cytoscape-cose-bilkent@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz#762fa121df9930ffeb51a495d87917c570ac209b"
integrity sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==
dependencies:
cose-base "^1.0.0"
cytoscape-fcose@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz#e4d6f6490df4fab58ae9cea9e5c3ab8d7472f471"
integrity sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==
dependencies:
cose-base "^2.2.0"
cytoscape@^3.23.0:
version "3.26.0"
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.26.0.tgz#b4c6961445fd51e1fd3cca83c3ffe924d9a8abc9"
integrity sha512-IV+crL+KBcrCnVVUCZW+zRRRFUZQcrtdOPXki+o4CFUWLdAEYvuZLcBSJC9EBK++suamERKzeY7roq2hdovV3w==
dependencies:
heap "^0.2.6"
lodash "^4.17.21"
"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0:
version "3.2.4"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
dependencies:
internmap "1 - 2"
d3-axis@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322"
integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==
d3-brush@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c"
integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==
dependencies:
d3-dispatch "1 - 3"
d3-drag "2 - 3"
d3-interpolate "1 - 3"
d3-selection "3"
d3-transition "3"
d3-chord@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-3.0.1.tgz#d156d61f485fce8327e6abf339cb41d8cbba6966"
integrity sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==
dependencies:
d3-path "1 - 3"
"d3-color@1 - 3", d3-color@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
d3-contour@4:
version "4.0.2"
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-4.0.2.tgz#bb92063bc8c5663acb2422f99c73cbb6c6ae3bcc"
integrity sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==
dependencies:
d3-array "^3.2.0"
d3-delaunay@6:
version "6.0.4"
resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz#98169038733a0a5babbeda55054f795bb9e4a58b"
integrity sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==
dependencies:
delaunator "5"
"d3-dispatch@1 - 3", d3-dispatch@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
"d3-drag@2 - 3", d3-drag@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
dependencies:
d3-dispatch "1 - 3"
d3-selection "3"
"d3-dsv@1 - 3", d3-dsv@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73"
integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==
dependencies:
commander "7"
iconv-lite "0.6"
rw "1"
"d3-ease@1 - 3", d3-ease@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
d3-fetch@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-3.0.1.tgz#83141bff9856a0edb5e38de89cdcfe63d0a60a22"
integrity sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==
dependencies:
d3-dsv "1 - 3"
d3-force@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4"
integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==
dependencies:
d3-dispatch "1 - 3"
d3-quadtree "1 - 3"
d3-timer "1 - 3"
"d3-format@1 - 3", d3-format@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
d3-geo@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.1.0.tgz#74fd54e1f4cebd5185ac2039217a98d39b0a4c0e"
integrity sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==
dependencies:
d3-array "2.5.0 - 3"
d3-hierarchy@3:
version "3.1.2"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6"
integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
dependencies:
d3-color "1 - 3"
"d3-path@1 - 3", d3-path@3, d3-path@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
d3-polygon@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398"
integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==
"d3-quadtree@1 - 3", d3-quadtree@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
d3-random@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4"
integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==
d3-scale-chromatic@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a"
integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
dependencies:
d3-color "1 - 3"
d3-interpolate "1 - 3"
d3-scale@4:
version "4.0.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
dependencies:
d3-array "2.10.0 - 3"
d3-format "1 - 3"
d3-interpolate "1.2.0 - 3"
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
"d3-selection@2 - 3", d3-selection@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
d3-shape@3:
version "3.2.0"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
dependencies:
d3-path "^3.1.0"
"d3-time-format@2 - 4", d3-time-format@4:
version "4.1.0"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
dependencies:
d3-time "1 - 3"
"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
dependencies:
d3-array "2 - 3"
"d3-timer@1 - 3", d3-timer@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
"d3-transition@2 - 3", d3-transition@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
dependencies:
d3-color "1 - 3"
d3-dispatch "1 - 3"
d3-ease "1 - 3"
d3-interpolate "1 - 3"
d3-timer "1 - 3"
d3-zoom@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
dependencies:
d3-dispatch "1 - 3"
d3-drag "2 - 3"
d3-interpolate "1 - 3"
d3-selection "2 - 3"
d3-transition "2 - 3"
d3@^7.4.0, d3@^7.8.2:
version "7.8.5"
resolved "https://registry.yarnpkg.com/d3/-/d3-7.8.5.tgz#fde4b760d4486cdb6f0cc8e2cbff318af844635c"
integrity sha512-JgoahDG51ncUfJu6wX/1vWQEqOflgXyl4MaHqlcSruTez7yhaRKR9i8VjjcQGeS2en/jnFivXuaIMnseMMt0XA==
dependencies:
d3-array "3"
d3-axis "3"
d3-brush "3"
d3-chord "3"
d3-color "3"
d3-contour "4"
d3-delaunay "6"
d3-dispatch "3"
d3-drag "3"
d3-dsv "3"
d3-ease "3"
d3-fetch "3"
d3-force "3"
d3-format "3"
d3-geo "3"
d3-hierarchy "3"
d3-interpolate "3"
d3-path "3"
d3-polygon "3"
d3-quadtree "3"
d3-random "3"
d3-scale "4"
d3-scale-chromatic "3"
d3-selection "3"
d3-shape "3"
d3-time "3"
d3-time-format "4"
d3-timer "3"
d3-transition "3"
d3-zoom "3"
dagre-d3-es@7.0.10:
version "7.0.10"
resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc"
integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==
dependencies:
d3 "^7.8.2"
lodash-es "^4.17.21"
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@ -3693,7 +4034,12 @@ data-urls@^4.0.0:
whatwg-mimetype "^3.0.0"
whatwg-url "^12.0.0"
debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4:
dayjs@^1.11.7:
version "1.11.9"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"
integrity sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==
debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@ -3719,6 +4065,13 @@ decimal.js@^10.4.3:
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23"
integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==
decode-named-character-reference@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e"
integrity sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==
dependencies:
character-entities "^2.0.0"
decode-uri-component@^0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
@ -3779,11 +4132,23 @@ define-properties@^1.1.3, define-properties@^1.1.4:
has-property-descriptors "^1.0.0"
object-keys "^1.1.1"
delaunator@5:
version "5.0.0"
resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.0.tgz#60f052b28bd91c9b4566850ebf7756efe821d81b"
integrity sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==
dependencies:
robust-predicates "^3.0.0"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
dequal@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
@ -3799,6 +4164,11 @@ diff-sequences@^29.4.3:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2"
integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==
diff@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@ -3844,6 +4214,11 @@ domexception@^4.0.0:
dependencies:
webidl-conversions "^7.0.0"
dompurify@3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.3.tgz#4b115d15a091ddc96f232bcef668550a2f6f1430"
integrity sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ==
dotenv@16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d"
@ -3866,6 +4241,11 @@ electron-to-chromium@^1.4.284:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.359.tgz#5c4d13cb08032469fcd6bd36457915caa211356b"
integrity sha512-OoVcngKCIuNXtZnsYoqlCvr0Cf3NIPzDIgwUfI9bdTFjXCrr79lI0kwQstLPZ7WhCezLlGksZk/BFAzoXC7GDw==
elkjs@^0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e"
integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@ -4774,6 +5154,11 @@ he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
heap@^0.2.6:
version "0.2.7"
resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.7.tgz#1e6adf711d3f27ce35a81fe3b7bd576c2260a8fc"
integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==
html-encoding-sniffer@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9"
@ -4853,7 +5238,7 @@ i18next-browser-languagedetector@6.1.4:
dependencies:
"@babel/runtime" "^7.14.6"
iconv-lite@0.6.3:
iconv-lite@0.6, iconv-lite@0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
@ -4949,6 +5334,11 @@ internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5:
has "^1.0.3"
side-channel "^1.0.4"
"internmap@1 - 2":
version "2.0.3"
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@ -5445,6 +5835,11 @@ jsonpointer@^5.0.0:
array-includes "^3.1.5"
object.assign "^4.1.3"
khroma@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.0.0.tgz#7577de98aed9f36c7a474c4d453d94c0d6c6588b"
integrity sha512-2J8rDNlQWbtiNYThZRvmMv5yt44ZakX+Tz5ZIp/mN1pt4snn+m030Va5Z4v8xA0cQFDXBwO/8i42xL4QPsVk3g==
klaw-sync@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c"
@ -5452,6 +5847,11 @@ klaw-sync@^6.0.0:
dependencies:
graceful-fs "^4.1.11"
kleur@^4.0.3:
version "4.1.5"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
language-subtag-registry@~0.3.2:
version "0.3.22"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d"
@ -5464,6 +5864,16 @@ language-tags@=1.0.5:
dependencies:
language-subtag-registry "~0.3.2"
layout-base@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-1.0.2.tgz#1291e296883c322a9dd4c5dd82063721b53e26e2"
integrity sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==
layout-base@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285"
integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==
leven@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
@ -5540,6 +5950,11 @@ localforage@^1.8.1:
dependencies:
lie "3.1.1"
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
@ -5671,6 +6086,31 @@ mathjax-full@3.2.2:
mj-context-menu "^0.6.1"
speech-rule-engine "^4.0.6"
mdast-util-from-markdown@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0"
integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==
dependencies:
"@types/mdast" "^3.0.0"
"@types/unist" "^2.0.0"
decode-named-character-reference "^1.0.0"
mdast-util-to-string "^3.1.0"
micromark "^3.0.0"
micromark-util-decode-numeric-character-reference "^1.0.0"
micromark-util-decode-string "^1.0.0"
micromark-util-normalize-identifier "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
unist-util-stringify-position "^3.0.0"
uvu "^0.5.0"
mdast-util-to-string@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz#66f7bb6324756741c5f47a53557f0cbf16b6f789"
integrity sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==
dependencies:
"@types/mdast" "^3.0.0"
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -5681,11 +6121,228 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
mermaid@10.2.3:
version "10.2.3"
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.2.3.tgz#789d3b582c5da8c69aa4a7c0e2b826562c8c8b12"
integrity sha512-cMVE5s9PlQvOwfORkyVpr5beMsLdInrycAosdr+tpZ0WFjG4RJ/bUHST7aTgHNJbujHkdBRAm+N50P3puQOfPw==
dependencies:
"@braintree/sanitize-url" "^6.0.2"
cytoscape "^3.23.0"
cytoscape-cose-bilkent "^4.1.0"
cytoscape-fcose "^2.1.0"
d3 "^7.4.0"
dagre-d3-es "7.0.10"
dayjs "^1.11.7"
dompurify "3.0.3"
elkjs "^0.8.2"
khroma "^2.0.0"
lodash-es "^4.17.21"
mdast-util-from-markdown "^1.3.0"
non-layered-tidy-tree-layout "^2.0.2"
stylis "^4.1.3"
ts-dedent "^2.2.0"
uuid "^9.0.0"
web-worker "^1.2.0"
mhchemparser@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/mhchemparser/-/mhchemparser-4.1.0.tgz#db204c394c46c0070e115270a7e45c15b5f0c2f5"
integrity sha512-rFj6nGMLJQQ0WcDw3j4LY/kWCq1EftcsarQWnDg38U47XMR36Tlda19WsN4spHr0Qc9Wn4oj6YtvXuwVnOKC/g==
micromark-core-commonmark@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8"
integrity sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==
dependencies:
decode-named-character-reference "^1.0.0"
micromark-factory-destination "^1.0.0"
micromark-factory-label "^1.0.0"
micromark-factory-space "^1.0.0"
micromark-factory-title "^1.0.0"
micromark-factory-whitespace "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-chunked "^1.0.0"
micromark-util-classify-character "^1.0.0"
micromark-util-html-tag-name "^1.0.0"
micromark-util-normalize-identifier "^1.0.0"
micromark-util-resolve-all "^1.0.0"
micromark-util-subtokenize "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.1"
uvu "^0.5.0"
micromark-factory-destination@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz#eb815957d83e6d44479b3df640f010edad667b9f"
integrity sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-factory-label@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz#cc95d5478269085cfa2a7282b3de26eb2e2dec68"
integrity sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
uvu "^0.5.0"
micromark-factory-space@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz#c8f40b0640a0150751d3345ed885a080b0d15faf"
integrity sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-types "^1.0.0"
micromark-factory-title@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz#dd0fe951d7a0ac71bdc5ee13e5d1465ad7f50ea1"
integrity sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==
dependencies:
micromark-factory-space "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-factory-whitespace@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz#798fb7489f4c8abafa7ca77eed6b5745853c9705"
integrity sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==
dependencies:
micromark-factory-space "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-character@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.2.0.tgz#4fedaa3646db249bc58caeb000eb3549a8ca5dcc"
integrity sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-chunked@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b"
integrity sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-classify-character@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz#6a7f8c8838e8a120c8e3c4f2ae97a2bff9190e9d"
integrity sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-combine-extensions@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz#192e2b3d6567660a85f735e54d8ea6e3952dbe84"
integrity sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==
dependencies:
micromark-util-chunked "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-decode-numeric-character-reference@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz#b1e6e17009b1f20bc652a521309c5f22c85eb1c6"
integrity sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-decode-string@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz#dc12b078cba7a3ff690d0203f95b5d5537f2809c"
integrity sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==
dependencies:
decode-named-character-reference "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-decode-numeric-character-reference "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-encode@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5"
integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==
micromark-util-html-tag-name@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588"
integrity sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==
micromark-util-normalize-identifier@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz#7a73f824eb9f10d442b4d7f120fecb9b38ebf8b7"
integrity sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-resolve-all@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz#4652a591ee8c8fa06714c9b54cd6c8e693671188"
integrity sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==
dependencies:
micromark-util-types "^1.0.0"
micromark-util-sanitize-uri@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz#613f738e4400c6eedbc53590c67b197e30d7f90d"
integrity sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-encode "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-subtokenize@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1"
integrity sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==
dependencies:
micromark-util-chunked "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
uvu "^0.5.0"
micromark-util-symbol@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142"
integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==
micromark-util-types@^1.0.0, micromark-util-types@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283"
integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==
micromark@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9"
integrity sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==
dependencies:
"@types/debug" "^4.0.0"
debug "^4.0.0"
decode-named-character-reference "^1.0.0"
micromark-core-commonmark "^1.0.1"
micromark-factory-space "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-chunked "^1.0.0"
micromark-util-combine-extensions "^1.0.0"
micromark-util-decode-numeric-character-reference "^1.0.0"
micromark-util-encode "^1.0.0"
micromark-util-normalize-identifier "^1.0.0"
micromark-util-resolve-all "^1.0.0"
micromark-util-sanitize-uri "^1.0.0"
micromark-util-subtokenize "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.1"
uvu "^0.5.0"
micromatch@^4.0.2, micromatch@^4.0.4:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
@ -5769,6 +6426,11 @@ moo-color@^1.0.2:
dependencies:
color-name "^1.1.4"
mri@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
mrmime@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27"
@ -5802,6 +6464,11 @@ nanoid@3.3.3:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25"
integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==
nanoid@4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
nanoid@^3.3.6:
version "3.3.6"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
@ -5827,6 +6494,11 @@ node-releases@^2.0.8:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f"
integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==
non-layered-tidy-tree-layout@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804"
integrity sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@ -6547,6 +7219,11 @@ rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
robust-predicates@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
rollup-plugin-terser@^7.0.0:
version "7.0.2"
resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d"
@ -6600,6 +7277,11 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
rw@1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
rxjs@^7.5.5:
version "7.8.0"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4"
@ -6607,6 +7289,13 @@ rxjs@^7.5.5:
dependencies:
tslib "^2.1.0"
sade@^1.7.3:
version "1.8.1"
resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
dependencies:
mri "^1.1.0"
safari-14-idb-fix@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz#450fc049b996ec7f3fd9ca2f89d32e0761583440"
@ -6999,6 +7688,11 @@ strip-literal@^1.0.1:
dependencies:
acorn "^8.8.2"
stylis@^4.1.3:
version "4.3.0"
resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.0.tgz#abe305a669fc3d8777e10eefcfc73ad861c5588c"
integrity sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@ -7168,6 +7862,11 @@ tr46@^4.1.1:
dependencies:
punycode "^2.3.0"
ts-dedent@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5"
integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
tsconfig-paths@^3.14.1:
version "3.14.2"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088"
@ -7309,6 +8008,13 @@ unique-string@^2.0.0:
dependencies:
crypto-random-string "^2.0.0"
unist-util-stringify-position@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d"
integrity sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==
dependencies:
"@types/unist" "^2.0.0"
universalify@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
@ -7377,6 +8083,21 @@ use-sync-external-store@1.2.0:
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
uuid@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
uvu@^0.5.0:
version "0.5.6"
resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df"
integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==
dependencies:
dequal "^2.0.0"
diff "^5.0.0"
kleur "^4.0.3"
sade "^1.7.3"
v8-compile-cache@^2.0.3:
version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@ -7562,6 +8283,11 @@ w3c-xmlserializer@^4.0.0:
dependencies:
xml-name-validator "^4.0.0"
web-worker@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da"
integrity sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"