Compare commits

..

28 Commits

Author SHA1 Message Date
Daniel J. Geiger 7958b7144a feat: Support subtypes for ExcalidrawTextElement 2023-10-14 17:46:57 -05:00
mazijian-pp 44d9d5fcac fix: wysiwyg left in undefined state on reload (#7123) 2023-10-13 14:29:54 +02:00
Alex Kim 89a3bbddb7 test: add more resizing tests (#7028)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-12 20:59:02 +02:00
David Luzar b86184a849 fix: ensure relative z-index of elements added to frame is retained (#7134) 2023-10-12 15:00:23 +02:00
Barnabás Molnár b552166924 feat: new dark mode theme & light theme tweaks (#7104)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-12 14:58:33 +02:00
David Luzar 26ff3993bb feat: better laser cursor for dark mode (#7132) 2023-10-11 11:17:27 +02:00
David Luzar 7ad02c359a fix: memoize static canvas on props.renderConfig (#7131) 2023-10-10 23:31:23 +02:00
David Luzar 2523fe82e3 feat: laser pointer improvements (#7128) 2023-10-10 13:55:55 +02:00
zsviczian 4ea079eb85 fix: regression from #6739 preventing redirect link in view mode (#7120)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-09 12:26:49 +02:00
Ryan Di f20ba90ffa perf: improve element in frame check (#7124) 2023-10-09 16:32:27 +08:00
Emmanuel Ferdman 03da9112cf fix: update links to excalidraw-app (#7072) 2023-10-08 19:37:17 -05:00
David Luzar a249f332a2 fix: ensure we do not stop laser update prematurely (#7100) 2023-10-06 12:00:35 +02:00
Are 2e61926a6b feat: initial Laser Pointer MVP (#6739)
* feat: initial Laser pointer mvp

* feat: add laser-pointer package and integrate it with collab

* chore: fix yarn.lock

* feat: update laser-pointer package, prevent panning from showing

* feat: add laser pointer tool button when collaborating, migrate to official package

* feat: reduce laser tool button size

* update icon

* fix icon & rotate

* fix: lock zoom level

* fix icon

* add `selected` state, simplify and reduce api

* set up pointer callbacks in viewMode if laser tool active

* highlight extra-tools button if one of the nested tools active

* add shortcut to laser pointer

* feat: don't update paths if nothing changed

* ensure we reset flag if no rAF scheduled

* move `lastUpdate` to instance to optimize

* return early

* factor out into constants and add doc

* skip iteration instead of exit

* fix naming

* feat: remove testing variable on window

* destroy on editor unmount

* fix incorrectly resetting `lastUpdate` in `stop()`

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-05 17:05:16 +02:00
DanielJGeiger e921bfb1ae feat: Export iconFillColor() (#6996) 2023-10-04 18:17:22 -05:00
David Luzar e6f74350ac refactor: DRY out tool typing (#7086) 2023-10-04 23:39:00 +02:00
David Luzar fa33aa08ab refactor: refactor event globals to differentiate from lastPointerUp (#7084) 2023-10-04 16:18:22 +02:00
David Luzar 8b838049df fix: remove invisible elements safely (#7083) 2023-10-04 16:09:59 +02:00
David Luzar 1f4f5e11ae refactor: DRY out and simplify setting active tool from toolbar (#7079) 2023-10-04 00:16:54 +02:00
David Luzar 12420592ef feat: support menu / dropdown items to have selected state (#7078) 2023-10-03 23:35:47 +02:00
DanielJGeiger bfd318e765 docs: Update the excalidraw-app source-code link in README.md (#7035)
chore: Update the `excalidraw-app` source-code link in README.md
2023-10-03 08:41:13 -05:00
Thomas Steiner 6a821f3b76 fix: Icon size in manifest (#7073) 2023-10-03 11:07:02 +02:00
Tanmoy 84fd13e872 docs: fix minor grammar and spellings (#7039) 2023-10-02 10:11:02 +02:00
Alberto Torrigiotti 7d2b6f3374 docs: fix typo on homepage of developer docs (#7047) 2023-09-29 20:52:53 -05:00
David Luzar ceb637f5ea fix: elements being dropped/duplicated when added to frame (#7057) 2023-09-29 15:40:14 +02:00
hugofqt 4c35eba72d feat: element alignments - snapping (#6256)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-09-28 16:28:08 +02:00
David Luzar 4765f5536e fix: frame name not editable on dbl-click (#7037) 2023-09-25 16:54:23 +02:00
David Luzar 556175558a fix: polyfill Element.replaceChildren (#7034) 2023-09-24 19:07:35 +02:00
Aakansha Doshi 4db73a7f95 docs: release @excalidraw/excalidraw@0.16.1 🎉 (#7020) 2023-09-21 10:28:21 +05:30
116 changed files with 6017 additions and 2657 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
## Excalidraw.com
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/excalidraw-app) is part of this repository as well, and the app features:
- 📡&nbsp;PWA support (works offline).
- 🤼&nbsp;Real-time collaboration.
@@ -38,6 +38,7 @@ To render an item, its recommended to use `MainMenu.Item`.
| Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- |
| `onSelect` | `function` | Yes | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
| `selected` | `boolean` | No | `false` | Whether item is active |
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
| `shortcut` | `string` | No | - | The shortcut to be shown for the menu item |
@@ -70,6 +71,7 @@ function App() {
| Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- |
| `onSelect` | `function` | No | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
| `selected` | `boolean` | No | `false` | Whether item is active |
| `href` | `string` | Yes | - | The `href` attribute to be added to the `anchor` element. |
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
@@ -1,6 +1,6 @@
# Customizing Styles
Excalidraw is using CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
Excalidraw uses CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector:
+2 -2
View File
@@ -2,7 +2,7 @@
### Does this package support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
### Turning off Aggressive Anti-Fingerprinting in Brave browser
@@ -18,7 +18,7 @@ We strongly recommend turning it off. You can follow the steps below on how to d
2. Once opened, look for **Aggressively Block Fingerprinting**
![Aggresive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
3. Switch to **Block Fingerprinting**
@@ -34,7 +34,7 @@ function App() {
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
```jsx showLineNumbers
import { useState, useEffect } from "react";
+22
View File
@@ -0,0 +1,22 @@
# Frames
## Ordering
Frames should be ordered where frame children come first, followed by the frame element itself:
```
[
other_element,
frame1_child1,
frame1_child2,
frame1,
other_element,
frame2_child1,
frame2_child2,
frame2,
other_element,
...
]
```
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.
+1 -1
View File
@@ -15,7 +15,7 @@ In case you want to pick up something from the roadmap, comment on that issue an
1. Run `yarn` to install dependencies
1. Create a branch for your PR with `git checkout -b your-branch-name`
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork, run:
>
> ```bash
> git remote add upstream https://github.com/excalidraw/excalidraw.git
+5 -1
View File
@@ -23,7 +23,11 @@ const sidebars = {
},
items: ["introduction/development", "introduction/contributing"],
},
{ type: "category", label: "Codebase", items: ["codebase/json-schema"] },
{
type: "category",
label: "Codebase",
items: ["codebase/json-schema", "codebase/frames"],
},
{
type: "category",
label: "@excalidraw/excalidraw",
+1 -1
View File
@@ -15,7 +15,7 @@ const FeatureList = [
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
description: (
<>
Want to build your own app powered by Excalidraw by don't know where to
Want to build your own app powered by Excalidraw but don't know where to
start?
</>
),
+1 -1
View File
@@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
type: "MOUSE_LOCATION";
payload: {
socketId: string;
pointer: { x: number; y: number };
pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;
+1 -1
View File
@@ -131,5 +131,5 @@ export class Debug {
};
};
}
//@ts-ignore
window.debug = Debug;
+2 -2
View File
@@ -20,7 +20,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/mermaid-to-excalidraw": "0.1.1",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
@@ -125,7 +125,7 @@
"test": "yarn test:app",
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"test:ui": "yarn test --ui",
"autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
+1 -1
View File
@@ -11,7 +11,7 @@
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "256x256"
"sizes": "180x180"
}
],
"start_url": "/",
+6 -6
View File
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
measureText,
measureTextElement,
redrawTextBoundingBox,
} from "../element/textElement";
import {
@@ -31,7 +31,6 @@ import {
} from "../element/types";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
@@ -48,10 +47,11 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
const { width, height, baseline } = measureTextElement(
boundTextElement,
{
text: boundTextElement.originalText,
},
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
+2 -1
View File
@@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
@@ -21,6 +21,7 @@ import {
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
+5 -2
View File
@@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils";
import { updateActiveTool } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@@ -15,6 +15,7 @@ import {
} from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
export const actionFinalize = register({
name: "finalize",
@@ -90,7 +91,9 @@ export const actionFinalize = register({
}
}
if (isInvisiblySmallElement(multiPointElement)) {
newElements = newElements.slice(0, -1);
newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
}
// If the multi point line closes the loop,
+2 -1
View File
@@ -4,7 +4,8 @@ import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
+1
View File
@@ -15,6 +15,7 @@ export const actionToggleGridMode = register({
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
};
@@ -0,0 +1,28 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.objectsSnapModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
commitToHistory: false,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.objectsSnapModeEnabled === "undefined";
},
contextItemLabel: "buttons.objectsSnapMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
});
+1
View File
@@ -80,6 +80,7 @@ export {
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
+2
View File
@@ -28,6 +28,7 @@ export type ShortcutName =
| "ungroup"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "addToLibrary"
| "viewMode"
@@ -74,6 +75,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
objectsSnapMode: [getShortcutKey("Alt+S")],
stats: [getShortcutKey("Alt+/")],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],
+1
View File
@@ -51,6 +51,7 @@ export type ActionName =
| "pasteStyles"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
+9
View File
@@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
snapLines: [],
originSnapOffset: {
x: 0,
y: 0,
},
objectsSnapModeEnabled: false,
};
};
@@ -206,6 +212,9 @@ const APP_STATE_STORAGE_CONF = (<
pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
snapLines: { browser: false, export: false, server: false },
originSnapOffset: { browser: false, export: false, server: false },
objectsSnapModeEnabled: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <
+2 -2
View File
@@ -2,13 +2,13 @@
.undo-redo-buttons {
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
}
.zoom-button,
.undo-redo-buttons button {
border: 1px solid var(--default-border-color) !important;
border-radius: 0 !important;
background-color: transparent !important;
background-color: var(--color-surface-low) !important;
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
+113 -86
View File
@@ -14,13 +14,8 @@ import {
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
import { UIAppState, Zoom } from "../types";
import {
capitalizeString,
isTransparent,
updateActiveTool,
setCursorForShape,
} from "../utils";
import { AppClassProperties, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
@@ -40,7 +35,7 @@ import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
} from "./icons";
import { KEYS } from "../keys";
@@ -220,17 +215,23 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({
interactiveCanvas,
activeTool,
setAppState,
onImageAction,
appState,
app,
}: {
interactiveCanvas: HTMLCanvasElement | null;
activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState;
app: AppClassProperties;
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@@ -255,29 +256,14 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
activeEmbeddable: null,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});
app.setActiveTool({ type: value });
if (value === "image") {
onImageAction({ pointerType });
}
@@ -286,70 +272,111 @@ export const ShapesSwitcher = ({
);
})}
<div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className="App-toolbar__extra-tools-trigger"
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(
t("toolBar.frame"),
)}${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
app.setActiveTool({ type: "frame" });
}}
selected={activeTool.type === "frame"}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={EmbedIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "mermaid",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
});
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);
}
}}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
app.setActiveTool({ type: "embeddable" });
}}
selected={activeTool.type === "embeddable"}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className={clsx("App-toolbar__extra-tools-trigger", {
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "frame" });
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "embeddable" });
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "laser" });
}}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
{t("toolBar.laser")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</>
);
};
+350 -130
View File
@@ -35,6 +35,7 @@ import {
actionLink,
actionToggleElementLock,
actionToggleLinearEditor,
actionToggleObjectsSnapMode,
} from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
@@ -210,7 +211,7 @@ import {
import Scene from "../scene/Scene";
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, SHAPES } from "../shapes";
import { findShapeByKey } from "../shapes";
import {
AppClassProperties,
AppProps,
@@ -228,6 +229,9 @@ import {
FrameNameBoundsCache,
SidebarName,
SidebarTabName,
KeyboardModifiersObject,
CollaboratorPointer,
ToolType,
} from "../types";
import {
debounce,
@@ -237,18 +241,14 @@ import {
isInputLike,
isToolIcon,
isWritableElement,
resetCursor,
resolvablePromise,
sceneCoordsToViewportCoords,
setCursor,
setCursorForShape,
tupleToCoors,
viewportCoordsToSceneCoords,
withBatchedUpdates,
wrapEvent,
withBatchedUpdatesThrottled,
updateObject,
setEraserCursor,
updateActiveTool,
getShortcutKey,
isTransparent,
@@ -342,6 +342,17 @@ import {
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import {
getSnapLinesAtPointer,
snapDraggedElements,
isActiveToolNonLinearSnappable,
snapNewElement,
snapResizingElements,
isSnappingEnabled,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
} from "../snapping";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { activeEyeDropperAtom } from "./EyeDropper";
@@ -354,7 +365,14 @@ 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 {
setEraserCursor,
setCursor,
resetCursor,
setCursorForShape,
} from "../cursor";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -479,10 +497,13 @@ class App extends React.Component<AppProps, AppState> {
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDown: React.PointerEvent<HTMLElement> | null = null;
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null;
lastViewportPosition = { x: 0, y: 0 };
laserPathManager: LaserPathManager = new LaserPathManager(this);
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
@@ -491,6 +512,7 @@ class App extends React.Component<AppProps, AppState> {
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
objectsSnapModeEnabled = false,
theme = defaultAppState.theme,
name = defaultAppState.name,
} = props;
@@ -501,6 +523,7 @@ class App extends React.Component<AppProps, AppState> {
...this.getCanvasOffsets(),
viewModeEnabled,
zenModeEnabled,
objectsSnapModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
name,
width: window.innerWidth,
@@ -1087,7 +1110,7 @@ class App extends React.Component<AppProps, AppState> {
cursor: CURSOR_TYPE.MOVE,
pointerEvents: this.state.viewModeEnabled
? POINTER_EVENTS.disabled
: POINTER_EVENTS.inheritFromUI,
: POINTER_EVENTS.enabled,
}}
onPointerDown={(event) => this.handleCanvasPointerDown(event)}
onWheel={(event) => this.handleWheel(event)}
@@ -1134,6 +1157,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement ||
this.state.draggingElement ||
this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down") ||
(this.state.editingElement &&
!isTextElement(this.state.editingElement))
? POINTER_EVENTS.disabled
@@ -1189,20 +1215,14 @@ class App extends React.Component<AppProps, AppState> {
!this.scene.getElementsIncludingDeleted().length
}
app={this}
isCollaborating={this.props.isCollaborating}
>
{this.props.children}
{this.state.activeTool.type === "mermaid" && (
<MermaidToExcalidraw
selectedElements={this.scene.getSelectedElements(
this.state,
)}
/>
)}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<LaserToolOverlay manager={this.laserPathManager} />
{selectedElements.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
@@ -1730,7 +1750,9 @@ class App extends React.Component<AppProps, AppState> {
this.removeEventListeners();
this.scene.destroy();
this.library.destroy();
this.laserPathManager.destroy();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
isSomeElementSelected.clearCache();
selectGroupsForSelectedElements.clearCache();
@@ -2262,12 +2284,11 @@ class App extends React.Component<AppProps, AppState> {
},
);
addElementsFromPasteOrLibrary = (opts: {
private addElementsFromPasteOrLibrary = (opts: {
elements: readonly ExcalidrawElement[];
files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
fitToContent?: boolean;
}) => {
const elements = restoreElements(opts.elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -2372,12 +2393,6 @@ class App extends React.Component<AppProps, AppState> {
},
);
this.setActiveTool({ type: "selection" });
if (opts.fitToContent) {
this.scrollToContent(newElements, {
fitToContent: true,
});
}
};
private addTextFromPaste(text: string, isPlainPaste = false) {
@@ -2552,10 +2567,11 @@ class App extends React.Component<AppProps, AppState> {
});
};
togglePenMode = () => {
togglePenMode = (force?: boolean) => {
this.setState((prevState) => {
return {
penMode: !prevState.penMode,
penMode: force ?? !prevState.penMode,
penDetected: true,
};
});
};
@@ -3049,6 +3065,15 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" });
} else {
this.setActiveTool({ type: "laser" });
}
return;
}
if (
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@@ -3108,16 +3133,10 @@ class App extends React.Component<AppProps, AppState> {
}
});
public setActiveTool = (
setActiveTool = (
tool:
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable"
| "mermaid";
type: ToolType;
}
| { type: "custom"; customType: string },
) => {
@@ -3136,22 +3155,30 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type === "image") {
this.onImageAction();
}
if (nextActiveTool.type !== "selection") {
this.setState({
activeTool: nextActiveTool,
selectedElementIds: makeNextSelectedElementIds(
nextActiveTool.type === "mermaid"
? this.state.selectedElementIds
: {},
this.state,
),
selectedGroupIds: {},
editingGroupId: null,
this.setState((prevState) => {
const commonResets = {
snapLines: prevState.snapLines.length ? [] : prevState.snapLines,
originSnapOffset: null,
activeEmbeddable: null,
});
} else {
this.setState({ activeTool: nextActiveTool, activeEmbeddable: null });
}
} as const;
if (nextActiveTool.type !== "selection") {
return {
...prevState,
activeTool: nextActiveTool,
selectedElementIds: makeNextSelectedElementIds({}, prevState),
selectedGroupIds: makeNextSelectedElementIds({}, prevState),
editingGroupId: null,
multiElement: null,
...commonResets,
};
}
return {
...prevState,
activeTool: nextActiveTool,
...commonResets,
};
});
};
private setCursor = (cursor: string) => {
@@ -3728,10 +3755,10 @@ class App extends React.Component<AppProps, AppState> {
isTouchScreen: boolean,
) => {
const draggedDistance = distance2d(
this.lastPointerDown!.clientX,
this.lastPointerDown!.clientY,
this.lastPointerUp!.clientX,
this.lastPointerUp!.clientY,
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
this.lastPointerUpEvent!.clientX,
this.lastPointerUpEvent!.clientY,
);
if (
!this.hitLinkElement ||
@@ -3742,7 +3769,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const lastPointerDownCoords = viewportCoordsToSceneCoords(
this.lastPointerDown!,
this.lastPointerDownEvent!,
this.state,
);
const lastPointerDownHittingLinkIcon = isPointHittingLink(
@@ -3752,7 +3779,7 @@ class App extends React.Component<AppProps, AppState> {
this.device.isMobile,
);
const lastPointerUpCoords = viewportCoordsToSceneCoords(
this.lastPointerUp!,
this.lastPointerUpEvent!,
this.state,
);
const lastPointerUpHittingLinkIcon = isPointHittingLink(
@@ -3887,6 +3914,30 @@ class App extends React.Component<AppProps, AppState> {
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
const { x: scenePointerX, y: scenePointerY } = scenePointer;
if (
!this.state.draggingElement &&
isActiveToolNonLinearSnappable(this.state.activeTool.type)
) {
const { originOffset, snapLines } = getSnapLinesAtPointer(
this.scene.getNonDeletedElements(),
this.state,
{
x: scenePointerX,
y: scenePointerY,
},
event,
);
this.setState({
snapLines,
originSnapOffset: originOffset,
});
} else if (!this.state.draggingElement) {
this.setState({
snapLines: [],
});
}
if (
this.state.editingLinearElement &&
!this.state.editingLinearElement.isDragging
@@ -4080,7 +4131,6 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElement,
@@ -4358,6 +4408,10 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ contextMenu: null });
}
if (this.state.snapLines) {
this.setAppState({ snapLines: [] });
}
this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs
@@ -4430,17 +4484,21 @@ class App extends React.Component<AppProps, AppState> {
return;
}
this.lastPointerDown = event;
this.lastPointerDownEvent = event;
// we must exit before we set `cursorButton` state and `savePointer`
// else it will send pointer state & laser pointer events in collab when
// panning
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return;
}
this.setState({
lastPointerDownWith: event.pointerType,
cursorButton: "down",
});
this.savePointer(event.clientX, event.clientY, "down");
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return;
}
// only handle left mouse button or touch
if (
event.button !== POINTER_BUTTON.MAIN &&
@@ -4531,10 +4589,14 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (this.state.activeTool.type === "frame") {
this.createFrameElementOnPointerDown(pointerDownState);
} else if (this.state.activeTool.type === "laser") {
this.laserPathManager.startPath(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
);
} else if (
this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand" &&
this.state.activeTool.type !== "mermaid"
this.state.activeTool.type !== "hand"
) {
this.createGenericElementOnPointerDown(
this.state.activeTool.type,
@@ -4555,7 +4617,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUp = onPointerUp;
if (!this.state.viewModeEnabled) {
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
@@ -4571,14 +4633,14 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
) => {
this.removePointer(event);
this.lastPointerUp = event;
this.lastPointerUpEvent = event;
const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY },
this.state,
);
const clicklength =
event.timeStamp - (this.lastPointerDown?.timeStamp ?? 0);
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.device.isMobile && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
@@ -5332,7 +5394,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
sceneX,
sceneY,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
);
const embedLink = getEmbedLink(link);
@@ -5382,7 +5446,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
sceneX,
sceneY,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@@ -5559,7 +5625,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@@ -5617,7 +5685,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
);
const frame = newFrameElement({
@@ -5640,6 +5710,52 @@ class App extends React.Component<AppProps, AppState> {
});
};
private maybeCacheReferenceSnapPoints(
event: KeyboardModifiersObject,
selectedElements: ExcalidrawElement[],
recomputeAnyways: boolean = false,
) {
if (
isSnappingEnabled({
event,
appState: this.state,
selectedElements,
}) &&
(recomputeAnyways || !SnapCache.getReferenceSnapPoints())
) {
SnapCache.setReferenceSnapPoints(
getReferenceSnapPoints(
this.scene.getNonDeletedElements(),
selectedElements,
this.state,
),
);
}
}
private maybeCacheVisibleGaps(
event: KeyboardModifiersObject,
selectedElements: ExcalidrawElement[],
recomputeAnyways: boolean = false,
) {
if (
isSnappingEnabled({
event,
appState: this.state,
selectedElements,
}) &&
(recomputeAnyways || !SnapCache.getVisibleGaps())
) {
SnapCache.setVisibleGaps(
getVisibleGaps(
this.scene.getNonDeletedElements(),
selectedElements,
this.state,
),
);
}
}
private onKeyDownFromPointerDownHandler(
pointerDownState: PointerDownState,
): (event: KeyboardEvent) => void {
@@ -5697,6 +5813,10 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (this.state.activeTool.type === "laser") {
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
}
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
@@ -5869,33 +5989,62 @@ class App extends React.Component<AppProps, AppState> {
!this.state.editingElement &&
this.state.activeEmbeddable?.state !== "active"
) {
const [dragX, dragY] = getGridPoint(
pointerCoords.x - pointerDownState.drag.offset.x,
pointerCoords.y - pointerDownState.drag.offset.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const dragOffset = {
x: pointerCoords.x - pointerDownState.origin.x,
y: pointerCoords.y - pointerDownState.origin.y,
};
const [dragDistanceX, dragDistanceY] = [
Math.abs(pointerCoords.x - pointerDownState.origin.x),
Math.abs(pointerCoords.y - pointerDownState.origin.y),
const originalElements = [
...pointerDownState.originalElements.values(),
];
// We only drag in one direction if shift is pressed
const lockDirection = event.shiftKey;
if (lockDirection) {
const distanceX = Math.abs(dragOffset.x);
const distanceY = Math.abs(dragOffset.y);
const lockX = lockDirection && distanceX < distanceY;
const lockY = lockDirection && distanceX > distanceY;
if (lockX) {
dragOffset.x = 0;
}
if (lockY) {
dragOffset.y = 0;
}
}
// Snap cache *must* be synchronously popuplated before initial drag,
// otherwise the first drag even will not snap, causing a jump before
// it snaps to its position if previously snapped already.
this.maybeCacheVisibleGaps(event, selectedElements);
this.maybeCacheReferenceSnapPoints(event, selectedElements);
const { snapOffset, snapLines } = snapDraggedElements(
getSelectedElements(originalElements, this.state),
dragOffset,
this.state,
event,
);
this.setState({ snapLines });
// when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input
!this.state.editingFrame &&
dragSelectedElements(
pointerDownState,
selectedElements,
dragX,
dragY,
lockDirection,
dragDistanceX,
dragDistanceY,
dragOffset,
this.state,
this.scene,
snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
this.maybeSuggestBindingForAll(selectedElements);
// We duplicate the selected element if alt is pressed on pointer move
@@ -5936,15 +6085,21 @@ class App extends React.Component<AppProps, AppState> {
groupIdMap,
element,
);
const [originDragX, originDragY] = getGridPoint(
pointerDownState.origin.x - pointerDownState.drag.offset.x,
pointerDownState.origin.y - pointerDownState.drag.offset.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const origElement = pointerDownState.originalElements.get(
element.id,
)!;
mutateElement(duplicatedElement, {
x: duplicatedElement.x + (originDragX - dragX),
y: duplicatedElement.y + (originDragY - dragY),
x: origElement.x,
y: origElement.y,
});
// put duplicated element to pointerDownState.originalElements
// so that we can snap to the duplicated element without releasing
pointerDownState.originalElements.set(
duplicatedElement.id,
duplicatedElement,
);
nextElements.push(duplicatedElement);
elementsToAppend.push(element);
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
@@ -5970,6 +6125,8 @@ class App extends React.Component<AppProps, AppState> {
oldIdToDuplicatedId,
);
this.scene.replaceAllElements(nextSceneElements);
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
}
return;
}
@@ -6186,6 +6343,7 @@ class App extends React.Component<AppProps, AppState> {
isResizing,
isRotating,
} = this.state;
this.setState({
isResizing: false,
isRotating: false,
@@ -6200,8 +6358,14 @@ class App extends React.Component<AppProps, AppState> {
multiElement || isTextElement(this.state.editingElement)
? this.state.editingElement
: null,
snapLines: [],
originSnapOffset: null,
});
SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null);
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
this.setState({
@@ -6425,7 +6589,9 @@ class App extends React.Component<AppProps, AppState> {
) {
// remove invisible element which was added in onPointerDown
this.scene.replaceAllElements(
this.scene.getElementsIncludingDeleted().slice(0, -1),
this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== draggingElement.id),
);
this.setState({
draggingElement: null,
@@ -6647,17 +6813,17 @@ class App extends React.Component<AppProps, AppState> {
}
if (isEraserActive(this.state)) {
const draggedDistance = distance2d(
this.lastPointerDown!.clientX,
this.lastPointerDown!.clientY,
this.lastPointerUp!.clientX,
this.lastPointerUp!.clientY,
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
this.lastPointerUpEvent!.clientX,
this.lastPointerUpEvent!.clientY,
);
if (draggedDistance === 0) {
const scenePointer = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUp!.clientX,
clientY: this.lastPointerUp!.clientY,
clientX: this.lastPointerUpEvent!.clientX,
clientY: this.lastPointerUpEvent!.clientY,
},
this.state,
);
@@ -6897,6 +7063,11 @@ class App extends React.Component<AppProps, AppState> {
: unbindLinearElements)(this.scene.getSelectedElements(this.state));
}
if (activeTool.type === "laser") {
this.laserPathManager.endPath();
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw") {
resetCursor(this.interactiveCanvas);
this.setState({
@@ -6913,14 +7084,16 @@ class App extends React.Component<AppProps, AppState> {
if (
hitElement &&
this.lastPointerUp &&
this.lastPointerDown &&
this.lastPointerUp.timeStamp - this.lastPointerDown.timeStamp < 300 &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isEmbeddableElement(hitElement) &&
this.isEmbeddableCenter(
hitElement,
this.lastPointerUp,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
@@ -7440,30 +7613,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ suggestedBindings });
}
public setSelection(elements: readonly NonDeletedExcalidrawElement[]) {
const selectedElementIds: { [id: string]: true } = {};
const selectedGroupIds: { [id: string]: true } = {};
elements.forEach((ele) => {
if (ele.groupIds.length) {
selectedElementIds[ele.id] = true;
ele.groupIds.forEach((id) => {
selectedGroupIds[id] = true;
});
}
// exclude bound text elements as we don't mark them as selected when
// container is selected unless in group
else if (!isBoundToContainer(ele)) {
selectedElementIds[ele.id] = true;
}
});
this.setState({
previousSelectedElementIds: this.state.selectedElementIds,
selectedElementIds,
selectedGroupIds,
});
}
private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds({}, prevState),
@@ -7753,7 +7902,7 @@ class App extends React.Component<AppProps, AppState> {
shouldResizeFromCenter(event),
);
} else {
const [gridX, gridY] = getGridPoint(
let [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -7767,6 +7916,33 @@ class App extends React.Component<AppProps, AppState> {
? image.width / image.height
: null;
this.maybeCacheReferenceSnapPoints(event, [draggingElement]);
const { snapOffset, snapLines } = snapNewElement(
draggingElement,
this.state,
event,
{
x:
pointerDownState.originInGrid.x +
(this.state.originSnapOffset?.x ?? 0),
y:
pointerDownState.originInGrid.y +
(this.state.originSnapOffset?.y ?? 0),
},
{
x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y,
},
);
gridX += snapOffset.x;
gridY += snapOffset.y;
this.setState({
snapLines,
});
dragNewElement(
draggingElement,
this.state.activeTool.type,
@@ -7781,6 +7957,7 @@ class App extends React.Component<AppProps, AppState> {
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
aspectRatio,
this.state.originSnapOffset,
);
this.maybeSuggestBindingForAll([draggingElement]);
@@ -7822,7 +7999,7 @@ class App extends React.Component<AppProps, AppState> {
activeEmbeddable: null,
});
const pointerCoords = pointerDownState.lastCoords;
const [resizeX, resizeY] = getGridPoint(
let [resizeX, resizeY] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -7850,6 +8027,41 @@ class App extends React.Component<AppProps, AppState> {
});
});
// check needed for avoiding flickering when a key gets pressed
// during dragging
if (!this.state.selectedElementsAreBeingDragged) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const dragOffset = {
x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y,
};
const originalElements = [...pointerDownState.originalElements.values()];
this.maybeCacheReferenceSnapPoints(event, selectedElements);
const { snapOffset, snapLines } = snapResizingElements(
selectedElements,
getSelectedElements(originalElements, this.state),
this.state,
event,
dragOffset,
transformHandleType,
);
resizeX += snapOffset.x;
resizeY += snapOffset.y;
this.setState({
snapLines,
});
}
if (
transformElements(
pointerDownState,
@@ -7865,6 +8077,7 @@ class App extends React.Component<AppProps, AppState> {
resizeY,
pointerDownState.resize.center.x,
pointerDownState.resize.center.y,
this.state,
)
) {
this.maybeSuggestBindingForAll(selectedElements);
@@ -7952,6 +8165,7 @@ class App extends React.Component<AppProps, AppState> {
actionUnlockAllElements,
CONTEXT_MENU_SEPARATOR,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleZenMode,
actionToggleViewMode,
actionToggleStats,
@@ -8098,15 +8312,21 @@ class App extends React.Component<AppProps, AppState> {
if (!x || !y) {
return;
}
const pointer = viewportCoordsToSceneCoords(
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX: x, clientY: y },
this.state,
);
if (isNaN(pointer.x) || isNaN(pointer.y)) {
if (isNaN(sceneX) || isNaN(sceneY)) {
// sometimes the pointer goes off screen
}
const pointer: CollaboratorPointer = {
x: sceneX,
y: sceneY,
tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
};
this.props.onPointerUpdate?.({
pointer,
button,
+9 -9
View File
@@ -12,32 +12,32 @@
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--text-color: var(--color-surface-lowest);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-primary-darker);
--back-color: var(--color-brand-hover);
}
&:active {
--back-color: var(--color-primary-darkest);
--back-color: var(--color-brand-active);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
--border-color: var(--color-border-outline);
--back-color: transparent;
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
--text-color: var(--color-brand-hover);
--border-color: var(--color-brand-hover);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
--text-color: var(--color-brand-active);
--border-color: var(--color-brand-active);
}
}
}
+16 -1
View File
@@ -19,20 +19,35 @@
}
&__btn {
--background: var(--color-surface-mid);
display: flex;
column-gap: 0.5rem;
align-items: center;
border: 1px solid var(--default-border-color);
background-color: var(--background);
padding: 0.625rem 1rem;
border: 1px solid var(--background);
border-radius: var(--border-radius-lg);
color: var(--text-primary-color);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.4px;
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover {
--background: var(--color-surface-high);
text-decoration: none;
}
&:active {
border-color: var(--color-primary);
}
}
&__link-icon {
+5
View File
@@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
@@ -258,6 +259,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("buttons.objectsSnapMode")}
shortcuts={[getShortcutKey("Alt+S")]}
/>
<Shortcut
label={t("labels.showGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
@@ -0,0 +1,309 @@
import { LaserPointer } from "@excalidraw/laser-pointer";
import { sceneCoordsToViewportCoords } from "../../utils";
import App from "../App";
import { getClientColor } from "../../clients";
// decay time in milliseconds
const DECAY_TIME = 1000;
// length of line in points before it starts decaying
const DECAY_LENGTH = 50;
const average = (a: number, b: number) => (a + b) / 2;
function getSvgPathFromStroke(points: number[][], closed = true) {
const len = points.length;
if (len < 4) {
return ``;
}
let a = points[0];
let b = points[1];
const c = points[2];
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
2,
)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
b[1],
c[1],
).toFixed(2)} T`;
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
2,
)} `;
}
if (closed) {
result += "Z";
}
return result;
}
declare global {
interface Window {
LPM: LaserPathManager;
}
}
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3);
}
function instantiateCollabolatorState(): CollabolatorState {
return {
currentPath: undefined,
finishedPaths: [],
lastPoint: [-10000, -10000],
svg: document.createElementNS("http://www.w3.org/2000/svg", "path"),
};
}
function instantiatePath() {
LaserPointer.constants.cornerDetectionMaxAngle = 70;
return new LaserPointer({
simplify: 0,
streamline: 0.4,
sizeMapping: (c) => {
const pt = DECAY_TIME;
const pl = DECAY_LENGTH;
const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt);
const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl;
return Math.min(easeOutCubic(l), easeOutCubic(t));
},
});
}
type CollabolatorState = {
currentPath: LaserPointer | undefined;
finishedPaths: LaserPointer[];
lastPoint: [number, number];
svg: SVGPathElement;
};
export class LaserPathManager {
private ownState: CollabolatorState;
private collaboratorsState: Map<string, CollabolatorState> = new Map();
private rafId: number | undefined;
private isDrawing = false;
private container: SVGSVGElement | undefined;
constructor(private app: App) {
this.ownState = instantiateCollabolatorState();
}
destroy() {
this.stop();
this.isDrawing = false;
this.ownState = instantiateCollabolatorState();
this.collaboratorsState = new Map();
}
startPath(x: number, y: number) {
this.ownState.currentPath = instantiatePath();
this.ownState.currentPath.addPoint([x, y, performance.now()]);
this.updatePath(this.ownState);
}
addPointToPath(x: number, y: number) {
if (this.ownState.currentPath) {
this.ownState.currentPath?.addPoint([x, y, performance.now()]);
this.updatePath(this.ownState);
}
}
endPath() {
if (this.ownState.currentPath) {
this.ownState.currentPath.close();
this.ownState.finishedPaths.push(this.ownState.currentPath);
this.updatePath(this.ownState);
}
}
private updatePath(state: CollabolatorState) {
this.isDrawing = true;
if (!this.isRunning) {
this.start();
}
}
private isRunning = false;
start(svg?: SVGSVGElement) {
if (svg) {
this.container = svg;
this.container.appendChild(this.ownState.svg);
}
this.stop();
this.isRunning = true;
this.loop();
}
stop() {
this.isRunning = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
this.rafId = undefined;
}
loop() {
this.rafId = requestAnimationFrame(this.loop.bind(this));
this.updateCollabolatorsState();
if (this.isDrawing) {
this.update();
} else {
this.isRunning = false;
}
}
draw(path: LaserPointer) {
const stroke = path
.getStrokeOutline(path.options.size / this.app.state.zoom.value)
.map(([x, y]) => {
const result = sceneCoordsToViewportCoords(
{ sceneX: x, sceneY: y },
this.app.state,
);
return [result.x, result.y];
});
return getSvgPathFromStroke(stroke, true);
}
updateCollabolatorsState() {
if (!this.container || !this.app.state.collaborators.size) {
return;
}
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
if (!this.collaboratorsState.has(key)) {
const state = instantiateCollabolatorState();
this.container.appendChild(state.svg);
this.collaboratorsState.set(key, state);
this.updatePath(state);
}
const state = this.collaboratorsState.get(key)!;
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
if (collabolator.button === "down" && state.currentPath === undefined) {
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
state.currentPath = instantiatePath();
state.currentPath.addPoint([
collabolator.pointer.x,
collabolator.pointer.y,
performance.now(),
]);
this.updatePath(state);
}
if (collabolator.button === "down" && state.currentPath !== undefined) {
if (
collabolator.pointer.x !== state.lastPoint[0] ||
collabolator.pointer.y !== state.lastPoint[1]
) {
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
state.currentPath.addPoint([
collabolator.pointer.x,
collabolator.pointer.y,
performance.now(),
]);
this.updatePath(state);
}
}
if (collabolator.button === "up" && state.currentPath !== undefined) {
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
state.currentPath.addPoint([
collabolator.pointer.x,
collabolator.pointer.y,
performance.now(),
]);
state.currentPath.close();
state.finishedPaths.push(state.currentPath);
state.currentPath = undefined;
this.updatePath(state);
}
}
}
}
update() {
if (!this.container) {
return;
}
let somePathsExist = false;
for (const [key, state] of this.collaboratorsState.entries()) {
if (!this.app.state.collaborators.has(key)) {
state.svg.remove();
this.collaboratorsState.delete(key);
continue;
}
state.finishedPaths = state.finishedPaths.filter((path) => {
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
});
let paths = state.finishedPaths.map((path) => this.draw(path)).join(" ");
if (state.currentPath) {
paths += ` ${this.draw(state.currentPath)}`;
}
if (paths.trim()) {
somePathsExist = true;
}
state.svg.setAttribute("d", paths);
state.svg.setAttribute("fill", getClientColor(key));
}
this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => {
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
});
let paths = this.ownState.finishedPaths
.map((path) => this.draw(path))
.join(" ");
if (this.ownState.currentPath) {
paths += ` ${this.draw(this.ownState.currentPath)}`;
}
paths = paths.trim();
if (paths) {
somePathsExist = true;
}
this.ownState.svg.setAttribute("d", paths);
this.ownState.svg.setAttribute("fill", "red");
if (!somePathsExist) {
this.isDrawing = false;
}
}
}
@@ -0,0 +1,41 @@
import "../ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "../ToolButton";
import { laserPointerToolIcon } from "../icons";
type LaserPointerIconProps = {
title?: string;
name?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "small";
export const LaserPointerButton = (props: LaserPointerIconProps) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__LaserPointer",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
title={`${props.title}`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
data-testid="toolbar-LaserPointer"
/>
<div className="ToolIcon__icon">{laserPointerToolIcon}</div>
</label>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import { LaserPathManager } from "./LaserPathManager";
import "./LaserToolOverlay.scss";
type LaserToolOverlayProps = {
manager: LaserPathManager;
};
export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
const svgRef = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (svgRef.current) {
manager.start(svgRef.current);
}
return () => {
manager.stop();
};
}, [manager]);
return (
<div className="LaserToolOverlay">
<svg ref={svgRef} className="LaserToolOverlayCanvas" />
</div>
);
};
@@ -0,0 +1,20 @@
.excalidraw {
.LaserToolOverlay {
pointer-events: none;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 2;
.LaserToolOverlayCanvas {
image-rendering: auto;
overflow: visible;
position: absolute;
top: 0;
left: 0;
}
}
}
+22 -1
View File
@@ -55,6 +55,7 @@ import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
interface LayerUIProps {
actionManager: ActionManager;
@@ -77,6 +78,7 @@ interface LayerUIProps {
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
}
const DefaultMainMenu: React.FC<{
@@ -134,6 +136,7 @@ const LayerUI = ({
renderWelcomeScreen,
children,
app,
isCollaborating,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -279,7 +282,7 @@ const LayerUI = ({
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
setAppState={setAppState}
app={app}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
@@ -288,6 +291,24 @@ const LayerUI = ({
/>
</Stack.Row>
</Island>
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={appState.activeTool.type === "laser"}
onChange={() =>
app.setActiveTool({ type: "laser" })
}
isMobile
/>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
+2 -2
View File
@@ -99,10 +99,10 @@
font-size: 0.75rem;
&:hover {
background-color: var(--color-primary-darker);
background-color: var(--color-brand-hover);
}
&:active {
background-color: var(--color-primary-darkest);
background-color: var(--color-brand-active);
}
}
-82
View File
@@ -1,82 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.mermaid-to-excalidraw-wrapper {
display: flex;
width: 100%;
justify-content: space-between;
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: 860px) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 19rem;
resize: none;
border-radius: 8px;
padding: 1rem;
@media screen and (max-width: 860px) {
width: auto;
height: 10rem;
}
}
}
&-preview-canvas {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
width: 20rem;
height: 19rem;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: 8px;
border: 1px solid #e4e4eb;
@media screen and (max-width: 860px) {
width: auto;
}
}
&-preview-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;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
color: $oc-white;
@media screen and (max-width: 860px) {
width: 100%;
}
}
@media screen and (max-width: 860px) {
flex-direction: column;
}
}
}
-273
View File
@@ -1,273 +0,0 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[test]";
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"
style={{
color: "red",
fontWeight: 800,
fontSize: "30px",
wordBreak: "break-word",
overflow: "auto",
maxHeight: "100%",
textAlign: "center",
}}
>
Error! <p style={{ fontSize: "18px", fontWeight: "600" }}>{error}</p>
</div>
);
};
const MermaidToExcalidraw = ({
selectedElements,
}: {
selectedElements: readonly NonDeletedExcalidrawElement[];
}) => {
const mermaidToExcalidrawLib = useRef<{
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null>(null);
const [text, setText] = useState("");
const deferredText = useDeferredValue(text);
const [loading, setLoading] = useState(true);
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 = "";
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
mermaidToExcalidrawLib.current = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setLoading(false);
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
if (!loading) {
const selectedMermaidImage = selectedElements.filter(
(el) => el.type === "image" && el.customData?.mermaidText,
)[0];
const data = selectedMermaidImage
? selectedMermaidImage.customData?.mermaidText
: importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}
}, [loading, selectedElements]);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
if (
!canvasNode ||
!mermaidToExcalidrawLib.current ||
deferredText === ""
) {
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.current.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(
elements.map((el) => {
if (el.type === "image") {
el.customData = { mermaidText: text };
}
return el;
}),
{
regenerateIds: true,
},
),
files,
};
const parent = canvasNode.parentElement!;
const maxWidth = parent.offsetWidth;
const maxHeight = parent.offsetHeight;
let dimension = Math.max(maxWidth, maxHeight);
dimension = Math.min(dimension, parent.offsetWidth - 10);
dimension = Math.min(dimension, parent.offsetHeight - 10);
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: dimension,
});
// 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 = "#fff";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
console.error(e.message);
resetPreview();
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, text]);
const onClose = () => {
app.setActiveTool({ type: "selection" });
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}
title={
<>
<p style={{ marginBottom: "5px", marginTop: "2px" }}>
{t("mermaid.title")}
</p>
<span
style={{ fontSize: "15px", fontStyle: "italic", fontWeight: 500 }}
>
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
/>
<br />
</span>
</>
}
>
<div className="mermaid-to-excalidraw-wrapper">
<div
className="mermaid-to-excalidraw-wrapper-text"
style={{ display: "flex", flexDirection: "column" }}
>
<label>{t("mermaid.syntax")}</label>
<textarea
style={{
padding: "0.85rem",
borderRadius: "8px",
border: "1px solid #e4e4eb",
whiteSpace: "pre-wrap",
}}
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div
className="mermaid-to-excalidraw-wrapper-preview"
style={{ display: "flex", flexDirection: "column" }}
>
<label>{t("mermaid.preview")}</label>
<div className="mermaid-to-excalidraw-wrapper-preview-canvas">
{error && <ErrorComp error={error} />}
{loading && <Spinner size="2rem" />}
<div ref={canvasRef} />
</div>
<Button
className="mermaid-to-excalidraw-wrapper-preview-insert"
onSelect={onSelect}
>
{t("mermaid.button")}
<span style={{ paddingLeft: "8px", display: "flex" }}>
{ArrowRightIcon}
</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;
+1 -1
View File
@@ -87,7 +87,7 @@ export const MobileMenu = ({
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
setAppState={setAppState}
app={app}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
+8 -17
View File
@@ -1,27 +1,18 @@
@import "../css/variables.module";
.excalidraw {
--RadioGroup-background: #ffffff;
--RadioGroup-border: var(--color-gray-30);
--RadioGroup-background: var(--island-bg-color);
--RadioGroup-border: var(--color-surface-high);
--RadioGroup-choice-color-off: var(--color-primary);
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
--RadioGroup-choice-background-off: white;
--RadioGroup-choice-background-off-active: var(--color-gray-20);
--RadioGroup-choice-color-off-hover: var(--color-brand-hover);
--RadioGroup-choice-background-off: var(--island-bg-color);
--RadioGroup-choice-background-off-active: var(--color-surface-high);
--RadioGroup-choice-color-on: white;
--RadioGroup-choice-color-on: var(--color-surface-lowest);
--RadioGroup-choice-background-on: var(--color-primary);
--RadioGroup-choice-background-on-hover: var(--color-primary-darker);
--RadioGroup-choice-background-on-active: var(--color-primary-darkest);
&.theme--dark {
--RadioGroup-background: var(--color-gray-85);
--RadioGroup-border: var(--color-gray-70);
--RadioGroup-choice-background-off: var(--color-gray-85);
--RadioGroup-choice-background-off-active: var(--color-gray-70);
--RadioGroup-choice-color-on: var(--color-gray-85);
}
--RadioGroup-choice-background-on-hover: var(--color-brand-hover);
--RadioGroup-choice-background-on-active: var(--color-brand-active);
.RadioGroup {
box-sizing: border-box;
+1 -2
View File
@@ -3,8 +3,7 @@
.excalidraw {
.sidebar-trigger {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
@include filledButtonOnCanvas;
width: auto;
height: var(--lg-button-size);
+16 -14
View File
@@ -1,15 +1,13 @@
@import "../css/variables.module";
.excalidraw {
--Switch-disabled-color: #d6d6d6;
--Switch-track-background: white;
--Switch-thumb-background: #3d3d3d;
&.theme--dark {
--Switch-disabled-color: #5c5c5c;
--Switch-track-background: #242424;
--Switch-thumb-background: #b8b8b8;
}
--Switch-disabled-color: var(--color-border-outline);
--Switch-disabled-toggled-background: var(--color-border-outline-variant);
--Switch-disabled-border: var(--color-border-outline-variant);
--Switch-track-background: var(--island-bg-color);
--Switch-thumb-background: var(--color-on-surface);
--Switch-hover-background: var(--color-brand-hover);
--Switch-active-background: var(--color-brand-active);
.Switch {
position: relative;
@@ -28,7 +26,11 @@
&:hover {
background: var(--Switch-track-background);
border: 1px solid #999999;
border: 1px solid var(--Switch-hover-background);
}
&:active {
border: 1px solid var(--Switch-active-background);
}
&.toggled {
@@ -43,11 +45,11 @@
&.disabled {
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-border);
&.toggled {
background: var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-color);
background: var(--Switch-disabled-toggled-background);
border: 1px solid var(--Switch-disabled-toggled-background);
}
}
@@ -92,7 +94,7 @@
}
&.disabled.toggled:before {
background: var(--color-gray-50);
background: var(--Switch-disabled-color);
}
& input {
+12 -21
View File
@@ -1,25 +1,16 @@
@import "../css/variables.module";
.excalidraw {
--ExcTextField--color: var(--color-gray-80);
--ExcTextField--label-color: var(--color-gray-80);
--ExcTextField--background: white;
--ExcTextField--readonly--background: var(--color-gray-10);
--ExcTextField--readonly--color: var(--color-gray-80);
--ExcTextField--border: var(--color-gray-40);
--ExcTextField--border-hover: var(--color-gray-50);
--ExcTextField--placeholder: var(--color-gray-40);
&.theme--dark {
--ExcTextField--color: var(--color-gray-10);
--ExcTextField--label-color: var(--color-gray-20);
--ExcTextField--background: var(--color-gray-85);
--ExcTextField--readonly--background: var(--color-gray-80);
--ExcTextField--readonly--color: var(--color-gray-40);
--ExcTextField--border: var(--color-gray-70);
--ExcTextField--border-hover: var(--color-gray-60);
--ExcTextField--placeholder: var(--color-gray-80);
}
--ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: transparent;
--ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-border-outline);
--ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField {
&--fullWidth {
@@ -61,7 +52,7 @@
&:active,
&:focus-within {
border-color: var(--color-primary);
border-color: var(--ExcTextField--border-active);
}
}
@@ -107,7 +98,7 @@
&--readonly {
background: var(--ExcTextField--readonly--background);
border-color: transparent;
border-color: var(--ExcTextField--readonly--border);
& input {
color: var(--ExcTextField--readonly--color);
+5 -14
View File
@@ -97,10 +97,6 @@
}
}
// &:hover {
// background-color: var(--button-gray-2);
// }
&:active {
background-color: var(--button-gray-3);
}
@@ -110,7 +106,6 @@
}
&--hide {
// visibility: hidden;
display: none !important;
}
}
@@ -165,19 +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);
}
}
.ToolIcon__LaserPointer .ToolIcon__icon {
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}
+7 -5
View File
@@ -16,28 +16,30 @@
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.25rem;
@include isMobile {
margin: 0;
}
}
}
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
background-color: transparent;
&:active {
background-color: var(--button-hover-bg);
box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset;
}
&--selected,
&--selected:hover {
background: var(--color-primary-light);
color: var(--color-primary);
}
}
.App-toolbar__extra-tools-dropdown {
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
z-index: 1;
}
}
@@ -193,6 +193,8 @@ const getRelevantAppStateProps = (
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
});
const areEqual = (
+7 -5
View File
@@ -114,11 +114,13 @@ const areEqual = (
return false;
}
return isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
return (
isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
);
};
+37 -14
View File
@@ -7,6 +7,8 @@
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
row-gap: 0.75rem;
@@ -14,7 +16,7 @@
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
// background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
@@ -27,7 +29,7 @@
}
.dropdown-menu-container {
background-color: #fff !important;
background-color: var(--island-bg-color);
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
@@ -38,7 +40,7 @@
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
color: var(--color-on-surface);
width: 100%;
box-sizing: border-box;
font-weight: normal;
@@ -47,7 +49,7 @@
.dropdown-menu-item {
background-color: transparent;
border: 0;
border: 1px solid transparent;
align-items: center;
height: 2rem;
cursor: pointer;
@@ -57,6 +59,11 @@
height: 2.25rem;
}
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
@@ -73,6 +80,11 @@
text-decoration: none;
}
&:active {
background-color: var(--button-hover-bg);
border-color: var(--color-brand-active);
}
svg {
width: 1rem;
height: 1rem;
@@ -91,22 +103,33 @@
font-weight: 500;
}
}
&.theme--dark {
.dropdown-menu-item {
color: var(--color-gray-40);
}
.dropdown-menu-container {
background-color: var(--color-gray-90) !important;
}
}
.dropdown-menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
--background: var(--color-surface-mid);
background-color: var(--background);
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover {
--background: var(--color-surface-high);
background-color: var(--background);
text-decoration: none;
}
&:active {
border-color: var(--color-primary);
}
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
@@ -11,12 +11,14 @@ const DropdownMenuItem = ({
children,
shortcut,
className,
selected,
...rest
}: {
icon?: JSX.Element;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
selected?: boolean;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
@@ -26,7 +28,7 @@ const DropdownMenuItem = ({
{...rest}
onClick={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
@@ -3,15 +3,19 @@ import React from "react";
const DropdownMenuItemCustom = ({
children,
className = "",
selected,
...rest
}: {
children: React.ReactNode;
className?: string;
selected?: boolean;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
{...rest}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className} ${
selected ? `dropdown-menu-item--selected` : ``
}`.trim()}
>
{children}
</div>
@@ -12,6 +12,7 @@ const DropdownMenuItemLink = ({
children,
onSelect,
className = "",
selected,
...rest
}: {
href: string;
@@ -19,6 +20,7 @@ const DropdownMenuItemLink = ({
children: React.ReactNode;
shortcut?: string;
className?: string;
selected?: boolean;
onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
@@ -29,7 +31,7 @@ const DropdownMenuItemLink = ({
href={href}
target="_blank"
rel="noreferrer"
className={getDropdownMenuItemClassName(className)}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
>
+7 -2
View File
@@ -6,8 +6,13 @@ export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
}>({});
export const getDropdownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
export const getDropdownMenuItemClassName = (
className = "",
selected = false,
) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
selected ? "dropdown-menu-item--selected" : ""
}`.trim();
};
export const useHandleDropdownMenuItemClick = (
+17 -32
View File
@@ -13,7 +13,7 @@ import clsx from "clsx";
import { Theme } from "../element/types";
import { THEME } from "../constants";
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
@@ -1654,36 +1654,21 @@ export const frameToolIcon = createIcon(
tablerIconProps,
);
export const mermaidLogoIcon = createIcon(
<path
fill="black"
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 laserPointerToolIcon = createIcon(
<g
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
transform="rotate(90 10 10)"
>
<path
clipRule="evenodd"
d="m9.644 13.69 7.774-7.773a2.357 2.357 0 0 0-3.334-3.334l-7.773 7.774L8 12l1.643 1.69Z"
/>
<path d="m13.25 3.417 3.333 3.333M10 10l2-2M5 15l3-3M2.156 17.894l1-1M5.453 19.029l-.144-1.407M2.377 11.887l.866 1.118M8.354 17.273l-1.194-.758M.953 14.652l1.408.13" />
</g>,
export const ArrowRightIcon = createIcon(
<>
<path
d="M4.16602 10H15.8327"
stroke="white"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5 13.3333L15.8333 10"
stroke="white"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5 6.66666L15.8333 9.99999"
stroke="white"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>,
{ width: 20, height: 20 },
20,
);
@@ -14,6 +14,8 @@
--button-active-bg: var(--color-primary-darker);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
flex-shrink: 0;
// double .active to force specificity
+1
View File
@@ -43,6 +43,7 @@ const MainMenu = Object.assign(
});
}}
data-testid="main-menu-trigger"
className="main-menu-trigger"
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
@@ -174,7 +174,7 @@
justify-content: space-between;
background: none;
border: none;
border: 1px solid transparent;
padding: 0.75rem;
@@ -204,7 +204,7 @@
.welcome-screen-menu-item:hover {
text-decoration: none;
background: var(--color-gray-10);
background: var(--button-hover-bg);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
@@ -216,7 +216,8 @@
}
.welcome-screen-menu-item:active {
background: var(--color-gray-20);
background: var(--button-hover-bg);
border-color: var(--color-brand-active);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
@@ -247,8 +248,7 @@
}
.welcome-screen-menu-item:hover {
background: var(--color-gray-85);
background-color: var(--color-surface-low);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
}
@@ -259,7 +259,6 @@
}
.welcome-screen-menu-item:active {
background-color: var(--color-gray-90);
.welcome-screen-menu-item__text {
color: var(--color-gray-10);
}
+19 -11
View File
@@ -280,11 +280,6 @@
align-items: center;
justify-content: space-between;
padding: 8px;
.dropdown-menu--mobile {
bottom: 55px;
top: auto;
}
}
.App-mobile-menu {
@@ -449,13 +444,14 @@
}
&:active {
border: 1px solid var(--color-primary-darkest);
border: 1px solid var(--button-active-border);
}
}
.help-icon {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
@include filledButtonOnCanvas;
width: var(--lg-button-size);
height: var(--lg-button-size);
@@ -596,8 +592,6 @@
background-color: var(--island-bg-color);
.ToolIcon__icon {
width: 2rem;
height: 2rem;
border-radius: 0;
}
@@ -607,8 +601,8 @@
}
.App-toolbar--mobile {
overflow: visible;
max-width: 98vw;
overflow-x: auto;
max-width: 90vw;
.ToolIcon__keybinding {
display: none;
@@ -628,6 +622,20 @@
padding: 0;
}
}
.main-menu-trigger {
@include filledButtonOnCanvas;
}
.App-menu__left {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
@at-root .excalidraw.theme--dark#{&} {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}
}
}
.ErrorSplash.excalidraw {
+45 -23
View File
@@ -12,27 +12,30 @@
--dialog-border-color: var(--color-gray-20);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: var(--color-gray-80);
--icon-fill-color: var(--color-on-surface);
--icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white};
--input-border-color: #{$oc-gray-4};
--input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7};
--island-bg-color: rgba(255, 255, 255, 0.96);
--island-bg-color: #ffffff;
--keybinding-color: var(--color-gray-40);
--link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
--popup-bg-color: #{$oc-white};
--popup-bg-color: var(--island-bg-color);
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
--button-hover-bg: var(--color-gray-10);
--default-border-color: var(--color-gray-30);
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high);
--button-active-border: var(--color-brand-active);
--default-border-color: var(--color-surface-high);
--default-button-size: 2rem;
--default-icon-size: 1rem;
@@ -63,14 +66,14 @@
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--sidebar-border-color: var(--color-gray-20);
--sidebar-bg-color: #fff;
--sidebar-border-color: var(--color-surface-high);
--sidebar-bg-color: var(--island-bg-color);
--library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01),
0px 8px 5px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.09),
0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1);
--space-factor: 0.25rem;
--text-primary-color: var(--color-gray-80);
--text-primary-color: var(--color-on-surface);
--color-selection: #6965db;
@@ -132,6 +135,19 @@
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%);
--color-surface-mid: hsl(240 25% 96%);
--color-surface-low: hsl(240 25% 94%);
--color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0;
--color-on-primary-container: #030064;
--color-surface-primary-container: #e0dfff;
--color-brand-active: #4440bf;
--color-border-outline: #767680;
--color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff;
&.theme--dark {
&.theme--dark-background-none {
background: none;
@@ -150,29 +166,24 @@
--dialog-border-color: var(--color-gray-80);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-6};
--icon-fill-color: var(--color-gray-40);
--icon-green-fill-color: #{$oc-green-4};
--default-bg-color: #121212;
--input-bg-color: #121212;
--input-border-color: #2e2e2e;
--input-hover-bg-color: #181818;
--input-label-color: #{$oc-gray-2};
--island-bg-color: #262627;
--island-bg-color: #232329;
--keybinding-color: var(--color-gray-60);
--link-color: #{$oc-blue-4};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c;
--popup-secondary-bg-color: #222;
--popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4};
--text-primary-color: var(--color-gray-40);
--button-hover-bg: var(--color-gray-80);
--default-border-color: var(--color-gray-80);
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),
0px 1.13px 4.13211px rgba(0, 0, 0, 0.035),
0px 0.769896px 1.4945px rgba(0, 0, 0, 0.0243888);
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
@@ -180,8 +191,6 @@
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--avatar-border-color: var(--color-gray-85);
--sidebar-border-color: var(--color-gray-85);
--sidebar-bg-color: #191919;
--scrollbar-thumb: #{$oc-gray-8};
--scrollbar-thumb-hover: #{$oc-gray-7};
@@ -224,5 +233,18 @@
--color-promo: #d297ff;
--color-logo-text: #e2dfff;
--color-surface-high: hsl(245, 10%, 21%);
--color-surface-low: hsl(240, 8%, 15%);
--color-surface-mid: hsl(240 6% 10%);
--color-surface-lowest: hsl(0, 0%, 7%);
--color-on-surface: #e3e3e8;
--color-brand-hover: #bbb8ff;
--color-on-primary-container: #e0dfff;
--color-surface-primary-container: #403e6a;
--color-brand-active: #d0ccff;
--color-border-outline: #8e8d9c;
--color-border-outline-variant: #46464f;
--color-surface-primary-container: #403e6a;
}
}
+30 -10
View File
@@ -11,7 +11,7 @@
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon {
--icon-fill-color: var(--color-primary-darker);
--icon-fill-color: var(--color-on-primary-container);
svg {
fill: var(--icon-fill-color);
@@ -23,11 +23,11 @@
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon {
background: var(--color-primary-light);
--keybinding-color: var(--color-gray-60);
background: var(--color-surface-primary-container);
--keybinding-color: var(--color-on-primary-container);
svg {
color: var(--color-primary-darker);
color: var(--color-on-primary-container);
}
}
}
@@ -44,7 +44,11 @@
&:active {
background: var(--button-hover-bg);
border: 1px solid var(--color-primary-darkest);
border: 1px solid var(--button-active-border);
svg {
color: var(--color-on-primary-container);
}
}
}
}
@@ -63,7 +67,7 @@
border-radius: var(--border-radius-lg);
cursor: pointer;
background-color: var(--button-bg, var(--island-bg-color));
color: var(--button-color, var(--text-primary-color));
color: var(--button-color, var(--color-on-surface));
svg {
width: var(--button-width, var(--lg-icon-size));
@@ -88,22 +92,38 @@
}
&.active {
background-color: var(--button-selected-bg, var(--color-primary-light));
border-color: var(--button-selected-border, var(--color-primary-light));
background-color: var(
--button-selected-bg,
var(--color-surface-primary-container)
);
border-color: var(
--button-selected-border,
var(--color-surface-primary-container)
);
&:hover {
background-color: var(
--button-selected-hover-bg,
var(--color-primary-light)
var(--color-surface-primary-container)
);
}
svg {
color: var(--button-color, var(--color-primary-darker));
color: var(--button-color, var(--color-on-primary-container));
}
}
}
@mixin filledButtonOnCanvas {
border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low);
&:active {
box-shadow: 0 0 0 1px var(--color-brand-active);
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)";
$right-sidebar-width: "302px";
+103
View File
@@ -0,0 +1,103 @@
import { CURSOR_TYPE, MIME_TYPES, THEME } from "./constants";
import OpenColor from "open-color";
import { AppState, DataURL } from "./types";
import { isHandToolActive, isEraserActive } from "./appState";
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
const laserPointerCursorIconSVG = `<path stroke="#1b1b1f" fill="#fff" d="m7.868 11.113 7.773 7.774a2.359 2.359 0 0 0 1.667.691 2.368 2.368 0 0 0 2.357-2.358c0-.625-.248-1.225-.69-1.667L11.201 7.78 9.558 9.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M3.664 3.625l1 1M2.529 6.922l1.407-.144m5.735-2.932-1.118.866M4.285 9.823l.758-1.194m1.863-6.207-.13 1.408"/>`;
const laserPointerCursorDataURL_lightMode = `data:${
MIME_TYPES.svg
},${encodeURIComponent(
`${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}</svg>`,
)}`;
const laserPointerCursorDataURL_darkMode = `data:${
MIME_TYPES.svg
},${encodeURIComponent(
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
)}`;
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
if (interactiveCanvas) {
interactiveCanvas.style.cursor = "";
}
};
export const setCursor = (
interactiveCanvas: HTMLCanvasElement | null,
cursor: string,
) => {
if (interactiveCanvas) {
interactiveCanvas.style.cursor = cursor;
}
};
let eraserCanvasCache: any;
let previewDataURL: string;
export const setEraserCursor = (
interactiveCanvas: HTMLCanvasElement | null,
theme: AppState["theme"],
) => {
const cursorImageSizePx = 20;
const drawCanvas = () => {
const isDarkTheme = theme === THEME.DARK;
eraserCanvasCache = document.createElement("canvas");
eraserCanvasCache.theme = theme;
eraserCanvasCache.height = cursorImageSizePx;
eraserCanvasCache.width = cursorImageSizePx;
const context = eraserCanvasCache.getContext("2d")!;
context.lineWidth = 1;
context.beginPath();
context.arc(
eraserCanvasCache.width / 2,
eraserCanvasCache.height / 2,
5,
0,
2 * Math.PI,
);
context.fillStyle = isDarkTheme ? OpenColor.black : OpenColor.white;
context.fill();
context.strokeStyle = isDarkTheme ? OpenColor.white : OpenColor.black;
context.stroke();
previewDataURL = eraserCanvasCache.toDataURL(MIME_TYPES.svg) as DataURL;
};
if (!eraserCanvasCache || eraserCanvasCache.theme !== theme) {
drawCanvas();
}
setCursor(
interactiveCanvas,
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
cursorImageSizePx / 2
}, auto`,
);
};
export const setCursorForShape = (
interactiveCanvas: HTMLCanvasElement | null,
appState: Pick<AppState, "activeTool" | "theme">,
) => {
if (!interactiveCanvas) {
return;
}
if (appState.activeTool.type === "selection") {
resetCursor(interactiveCanvas);
} else if (isHandToolActive(appState)) {
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
} else if (isEraserActive(appState)) {
setEraserCursor(interactiveCanvas, appState.theme);
// do nothing if image tool is selected which suggests there's
// a image-preview set as the cursor
// Ignore custom type as well and let host decide
} else if (appState.activeTool.type === "laser") {
const url =
appState.theme === THEME.LIGHT
? laserPointerCursorDataURL_lightMode
: laserPointerCursorDataURL_darkMode;
interactiveCanvas.style.cursor = `url(${url}), auto`;
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
}
};
+47 -47
View File
@@ -97,12 +97,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0.5,
0.5,
0,
0,
],
[
394.5,
34.5,
395,
35,
],
],
"roughness": 1,
@@ -150,11 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
399.5,
400,
0,
],
],
@@ -317,7 +317,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"focus": 0,
"gap": 205,
"gap": 5,
},
"fillStyle": "hachure",
"frameId": null,
@@ -331,11 +331,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -355,7 +355,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 255,
"y": 239,
}
@@ -395,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"x": 340,
"y": 226.5,
}
`;
@@ -428,11 +428,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -452,7 +452,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 255,
"y": 239,
}
@@ -492,7 +492,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"x": 340,
"y": 226.5,
}
`;
@@ -562,7 +562,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 355,
"x": 555,
"y": 189,
}
`;
@@ -595,11 +595,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 255,
"y": 239,
}
@@ -659,7 +659,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"x": 340,
"y": 226.5,
}
`;
@@ -747,7 +747,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 355,
"x": 555,
"y": 226.5,
}
`;
@@ -801,11 +801,11 @@ exports[`Test Transform > should transform linear elements 1`] = `
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -821,7 +821,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 20,
}
@@ -846,11 +846,11 @@ exports[`Test Transform > should transform linear elements 2`] = `
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 450,
"y": 20,
}
@@ -895,7 +895,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0,
],
[
100,
300,
0,
],
],
@@ -911,7 +911,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 60,
}
@@ -940,7 +940,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0,
],
[
100,
300,
0,
],
],
@@ -956,7 +956,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 450,
"y": 60,
}
@@ -1244,11 +1244,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -1264,7 +1264,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 100,
}
@@ -1294,11 +1294,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -1314,7 +1314,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 200,
}
@@ -1344,11 +1344,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -1364,7 +1364,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1,
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 300,
}
@@ -1394,11 +1394,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -1414,7 +1414,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1,
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 400,
}
@@ -1454,7 +1454,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 85,
"x": 185,
"y": 87.5,
}
`;
@@ -1493,7 +1493,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 200,
"x": 50,
"x": 150,
"y": 187.5,
}
`;
@@ -1533,7 +1533,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 75,
"x": 175,
"y": 275,
}
`;
@@ -1573,7 +1573,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 75,
"x": 175,
"y": 375,
}
`;
+19 -10
View File
@@ -34,13 +34,13 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
measureTextElement,
} from "../element/textElement";
import { normalizeLink } from "./url";
@@ -67,7 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
frame: true,
embeddable: true,
hand: true,
mermaid: true,
laser: false,
};
export type RestoredDataState = {
@@ -93,7 +93,8 @@ const repairBinding = (binding: PointBinding | null) => {
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -159,6 +160,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false,
};
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element) {
base.customData = element.customData;
}
@@ -189,7 +193,7 @@ const restoreElement = (
fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily);
}
const text = element.text ?? "";
const text = (typeof element.text === "string" && element.text) || "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
@@ -204,11 +208,7 @@ const restoreElement = (
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
const baseline = measureTextElement(element, { text }).baseline;
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@@ -222,9 +222,17 @@ const restoreElement = (
baseline,
});
// if empty text, mark as deleted. We keep in array
// for data integrity purposes (collab etc.)
if (!text && !element.isDeleted) {
element = { ...element, originalText: text, isDeleted: true };
element = bumpVersion(element);
}
if (refreshDimensions) {
element = { ...element, ...refreshTextDimensions(element) };
}
return element;
case "freedraw": {
return restoreElementWithProperties(element, {
@@ -299,6 +307,7 @@ const restoreElement = (
// We also don't want to throw, but instead return void so we filter
// out these unsupported elements from the restored array.
}
return null;
};
/**
+9 -8
View File
@@ -321,7 +321,7 @@ describe("Test Transform", () => {
});
expect(text).toMatchObject({
x: 240,
x: 340,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
@@ -341,7 +341,7 @@ describe("Test Transform", () => {
});
expect(ellipse).toMatchObject({
x: 355,
x: 555,
y: 189,
type: "ellipse",
boundElements: [
@@ -386,6 +386,7 @@ describe("Test Transform", () => {
);
expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({
@@ -405,7 +406,7 @@ describe("Test Transform", () => {
});
expect(text1).toMatchObject({
x: 240,
x: 340,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
@@ -425,7 +426,7 @@ describe("Test Transform", () => {
});
expect(text3).toMatchObject({
x: 355,
x: 555,
y: 226.5,
type: "text",
boundElements: [
@@ -602,14 +603,14 @@ describe("Test Transform", () => {
);
expect(excaldrawElements.length).toBe(4);
const [, , arrow, text] = excaldrawElements;
const [, , arrow] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [
{
id: text.id,
id: "id46",
type: "text",
},
],
@@ -655,11 +656,11 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: 0,
gap: 205,
gap: 5,
});
expect(rect.boundElements).toStrictEqual([
{
id: arrow.id,
id: "id47",
type: "arrow",
},
]);
+4 -85
View File
@@ -39,8 +39,6 @@ import {
} from "../element/types";
import { MarkOptional } from "../utility-types";
import { assertNever, getFontString } from "../utils";
import { getSizeFromPoints } from "../points";
import { nanoid } from "nanoid";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -161,7 +159,7 @@ export type ExcalidrawElementSkeleton =
} & Partial<ExcalidrawImageElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100,
width: 300,
height: 0,
};
@@ -359,48 +357,6 @@ 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));
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(linearElement, { points: newPoints });
return {
linearElement,
startBoundElement,
@@ -411,7 +367,7 @@ const bindLinearElementToElement = (
class ElementStore {
excalidrawElements = new Map<string, ExcalidrawElement>();
add = (ele?: ExcalidrawElement, originalId?: string) => {
add = (ele?: ExcalidrawElement) => {
if (!ele) {
return;
}
@@ -429,7 +385,6 @@ class ElementStore {
export const convertToExcalidrawElements = (
elements: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
) => {
if (!elements) {
return [];
@@ -437,16 +392,10 @@ export const convertToExcalidrawElements = (
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();
// Create individual elements
for (const element of elements) {
let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds) {
Object.assign(element, { id: nanoid() });
}
switch (element.type) {
case "rectangle":
case "ellipse":
@@ -495,11 +444,6 @@ export const convertToExcalidrawElements = (
],
...element,
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break;
}
case "text": {
@@ -555,9 +499,6 @@ export const convertToExcalidrawElements = (
} else {
elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element);
if (originalId) {
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
}
}
}
@@ -583,18 +524,6 @@ export const convertToExcalidrawElements = (
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
element.type === "arrow" ? element?.end : undefined;
if (originalStart && originalStart.id) {
const newStartId = oldToNewElementIdMap.get(originalStart.id);
if (newStartId) {
Object.assign(originalStart, { id: newStartId });
}
}
if (originalEnd && originalEnd.id) {
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
if (newEndId) {
Object.assign(originalEnd, { id: newEndId });
}
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container as ExcalidrawArrowElement,
@@ -610,23 +539,13 @@ export const convertToExcalidrawElements = (
} else {
switch (element.type) {
case "arrow": {
const { start, end } = element;
if (start && start.id) {
const newStartId = oldToNewElementIdMap.get(start.id);
Object.assign(start, { id: newStartId });
}
if (end && end.id) {
const newEndId = oldToNewElementIdMap.get(end.id);
Object.assign(end, { id: newEndId });
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement,
start,
end,
element.start,
element.end,
elementStore,
);
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
+14 -1
View File
@@ -158,7 +158,7 @@ export const getElementAbsoluteCoords = (
];
};
/**
/*
* for a given element, `getElementLineSegments` returns line segments
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
@@ -674,6 +674,19 @@ export const getCommonBounds = (
return [minX, minY, maxX, maxY];
};
export const getDraggedElementsBounds = (
elements: ExcalidrawElement[],
dragOffset: { x: number; y: number },
) => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return [
minX + dragOffset.x,
minY + dragOffset.y,
maxX + dragOffset.x,
maxY + dragOffset.y,
];
};
export const getResizedElementAbsoluteCoords = (
element: ExcalidrawElement,
nextWidth: number,
+54 -45
View File
@@ -6,27 +6,22 @@ import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups";
import { getGridPoint } from "../math";
import Scene from "../scene/Scene";
import {
isArrowElement,
isBoundToContainer,
isFrameElement,
} from "./typeChecks";
import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
selectedElements: NonDeletedExcalidrawElement[],
pointerX: number,
pointerY: number,
lockDirection: boolean = false,
distanceX: number = 0,
distanceY: number = 0,
offset: { x: number; y: number },
appState: AppState,
scene: Scene,
snapOffset: {
x: number;
y: number;
},
gridSize: AppState["gridSize"],
) => {
const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 };
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
@@ -40,7 +35,6 @@ export const dragSelectedElements = (
if (frames.length > 0) {
const elementsInFrames = scene
.getNonDeletedElements()
.filter((e) => !isBoundToContainer(e))
.filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!));
@@ -49,33 +43,35 @@ export const dragSelectedElements = (
elementsToUpdate.forEach((element) => {
updateElementCoords(
lockDirection,
distanceX,
distanceY,
pointerDownState,
element,
offset,
snapOffset,
gridSize,
);
// update coords of bound text only if we're dragging the container directly
// (we don't drag the group that it's part of)
if (
// Don't update coords of arrow label since we calculate its position during render
!isArrowElement(element) &&
// container isn't part of any group
// (perf optim so we don't check `isSelectedViaGroup()` in every case)
(!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element)))
!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
) {
const textElement = getBoundTextElement(element);
if (textElement) {
if (
textElement &&
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords(
lockDirection,
distanceX,
distanceY,
pointerDownState,
textElement,
offset,
snapOffset,
gridSize,
);
}
}
@@ -86,31 +82,40 @@ export const dragSelectedElements = (
};
const updateElementCoords = (
lockDirection: boolean,
distanceX: number,
distanceY: number,
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
offset: { x: number; y: number },
dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"],
) => {
let x: number;
let y: number;
if (lockDirection) {
const lockX = lockDirection && distanceX < distanceY;
const lockY = lockDirection && distanceX > distanceY;
const original = pointerDownState.originalElements.get(element.id);
x = lockX && original ? original.x : element.x + offset.x;
y = lockY && original ? original.y : element.y + offset.y;
} else {
x = element.x + offset.x;
y = element.y + offset.y;
const originalElement =
pointerDownState.originalElements.get(element.id) ?? element;
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
if (snapOffset.x === 0 || snapOffset.y === 0) {
const [nextGridX, nextGridY] = getGridPoint(
originalElement.x + dragOffset.x,
originalElement.y + dragOffset.y,
gridSize,
);
if (snapOffset.x === 0) {
nextX = nextGridX;
}
if (snapOffset.y === 0) {
nextY = nextGridY;
}
}
mutateElement(element, {
x,
y,
x: nextX,
y: nextY,
});
};
export const getDragOffsetXY = (
selectedElements: NonDeletedExcalidrawElement[],
x: number,
@@ -134,6 +139,10 @@ export const dragNewElement = (
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null,
originOffset: {
x: number;
y: number;
} | null = null,
) => {
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
if (widthAspectRatio) {
@@ -174,8 +183,8 @@ export const dragNewElement = (
if (width !== 0 && height !== 0) {
mutateElement(draggingElement, {
x: newX,
y: newY,
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
width,
height,
});
+2 -1
View File
@@ -2,7 +2,8 @@ import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import { t } from "../i18n";
import { ExcalidrawProps } from "../types";
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks";
+26 -5
View File
@@ -6,12 +6,21 @@ import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { getSubtypeMethods } from "./subtypes";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce"
>;
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): ElementUpdate<TElement> => {
const map = getSubtypeMethods(element.subtype);
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
};
// This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you
@@ -22,6 +31,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
informMutation = true,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
@@ -70,6 +81,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
}
}
if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue;
}
}
@@ -77,6 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value;
didChange = true;
key in oldUpdates && (increment = true);
}
}
if (!didChange) {
@@ -92,9 +105,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element);
}
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (increment) {
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
if (informMutation) {
Scene.getScene(element)?.informMutation();
@@ -108,6 +123,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
updates: ElementUpdate<TElement>,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
@@ -119,6 +136,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue;
}
didChange = true;
key in oldUpdates && (increment = true);
}
}
@@ -126,6 +144,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element;
}
if (!increment) {
return { ...element, ...updates };
}
return {
...element,
...updates,
@@ -140,8 +161,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
*
* NOTE: does not trigger re-render.
*/
export const bumpVersion = (
element: Mutable<ExcalidrawElement>,
export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
element: T,
version?: ExcalidrawElement["version"],
) => {
element.version = (version ?? element.version) + 1;
+39 -24
View File
@@ -15,12 +15,7 @@ import {
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "../element/types";
import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
@@ -30,9 +25,9 @@ import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getContainerElement,
measureText,
measureTextElement,
normalizeText,
wrapText,
wrapTextElement,
getBoundTextMaxWidth,
getDefaultLineHeight,
} from "./textElement";
@@ -45,6 +40,21 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import { MarkOptional, Merge, Mutable } from "../utility-types";
import { getSubtypeMethods } from "./subtypes";
export const maybeGetSubtypeProps = (obj: {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
}) => {
const data: typeof obj = {};
if ("subtype" in obj && obj.subtype !== undefined) {
data.subtype = obj.subtype;
}
if ("customData" in obj && obj.customData !== undefined) {
data.customData = obj.customData;
}
return data as typeof obj;
};
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -58,6 +68,8 @@ export type ElementConstructorOpts = MarkOptional<
| "version"
| "versionNonce"
| "link"
| "subtype"
| "customData"
| "strokeStyle"
| "fillStyle"
| "strokeColor"
@@ -93,8 +105,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }),
id: rest.id || randomId(),
type,
x,
@@ -128,8 +142,11 @@ export const newElement = (
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
): NonDeleted<ExcalidrawGenericElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
export const newEmbeddableElement = (
opts: {
@@ -196,10 +213,12 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text);
const metrics = measureText(
text,
getFontString({ fontFamily, fontSize }),
lineHeight,
const metrics = measureTextElement(
{ ...opts, fontSize, fontFamily, lineHeight },
{
text,
customData: opts.customData,
},
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -244,7 +263,9 @@ const getAdjustedDimensions = (
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), element.lineHeight);
} = measureTextElement(element, {
text: nextText,
});
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -253,11 +274,7 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId
) {
const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const prevMetrics = measureTextElement(element);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
@@ -313,11 +330,9 @@ export const refreshTextDimensions = (
}
const container = getContainerElement(textElement);
if (container) {
text = wrapText(
text = wrapTextElement(textElement, getBoundTextMaxWidth(container), {
text,
getFontString(textElement),
getBoundTextMaxWidth(container),
);
});
}
const dimensions = getAdjustedDimensions(textElement, text);
return { text, ...dimensions };
+29 -19
View File
@@ -41,7 +41,7 @@ import {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
import { Point, PointerDownState } from "../types";
import { AppState, Point, PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
@@ -51,7 +51,7 @@ import {
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
measureTextElement,
getBoundTextMaxHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
@@ -79,6 +79,7 @@ export const transformElements = (
pointerY: number,
centerX: number,
centerY: number,
appState: AppState,
) => {
if (selectedElements.length === 1) {
const [element] = selectedElements;
@@ -223,11 +224,7 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
const metrics = measureTextElement(element, { fontSize: nextFontSize });
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
@@ -466,8 +463,8 @@ export const resizeSingleElement = (
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
eleNewWidth = Math.max(eleNewWidth, minWidth);
eleNewHeight = Math.max(eleNewHeight, minHeight);
}
}
@@ -508,8 +505,11 @@ export const resizeSingleElement = (
}
}
const flipX = eleNewWidth < 0;
const flipY = eleNewHeight < 0;
// Flip horizontally
if (eleNewWidth < 0) {
if (flipX) {
if (transformHandleDirection.includes("e")) {
newTopLeft[0] -= Math.abs(newBoundsWidth);
}
@@ -517,8 +517,9 @@ export const resizeSingleElement = (
newTopLeft[0] += Math.abs(newBoundsWidth);
}
}
// Flip vertically
if (eleNewHeight < 0) {
if (flipY) {
if (transformHandleDirection.includes("s")) {
newTopLeft[1] -= Math.abs(newBoundsHeight);
}
@@ -542,10 +543,20 @@ export const resizeSingleElement = (
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
const newOrigin = [...newTopLeft];
const linearElementXOffset = stateAtResizeStart.x - newBoundsX1;
const linearElementYOffset = stateAtResizeStart.y - newBoundsY1;
newOrigin[0] += linearElementXOffset;
newOrigin[1] += linearElementYOffset;
const nextX = newOrigin[0];
const nextY = newOrigin[1];
// Readjust points for linear elements
let rescaledElementPointsY;
let rescaledPoints;
if (isLinearElement(element) || isFreeDrawElement(element)) {
rescaledElementPointsY = rescalePoints(
1,
@@ -562,16 +573,11 @@ export const resizeSingleElement = (
);
}
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
const resizedElement = {
width: Math.abs(eleNewWidth),
height: Math.abs(eleNewHeight),
x: newOrigin[0],
y: newOrigin[1],
x: nextX,
y: nextY,
points: rescaledPoints,
};
@@ -680,6 +686,10 @@ export const resizeMultipleElements = (
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
targetElements.map(({ orig }) => orig).concat(boundTextElements),
);
// const originalHeight = maxY - minY;
// const originalWidth = maxX - minX;
const direction = transformHandleType;
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
+2 -1
View File
@@ -12,6 +12,7 @@ export const showSelectedShapeActions = (
(appState.editingElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand"))) ||
appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) ||
getSelectedElements(elements, appState).length),
);
+222
View File
@@ -0,0 +1,222 @@
import { ExcalidrawElement, ExcalidrawTextElement, NonDeleted } from "../types";
import { getNonDeletedElements } from "../";
import { isTextElement } from "../typeChecks";
import { getContainerElement, redrawTextBoundingBox } from "../textElement";
import { ShapeCache } from "../../scene/ShapeCache";
import Scene from "../../scene/Scene";
// Use "let" instead of "const" so we can dynamically add subtypes
let subtypeNames: readonly Subtype[] = [];
let parentTypeMap: readonly {
subtype: Subtype;
parentType: ExcalidrawElement["type"];
}[] = [];
export type SubtypeRecord = Readonly<{
subtype: Subtype;
parents: readonly ExcalidrawElement["type"][];
}>;
// Subtype Names
export type Subtype = Required<ExcalidrawElement>["subtype"];
export const getSubtypeNames = (): readonly Subtype[] => {
return subtypeNames;
};
// Subtype Methods
export type SubtypeMethods = {
clean: (
updates: Omit<
Partial<ExcalidrawElement>,
"id" | "version" | "versionNonce"
>,
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
ensureLoaded: (callback?: () => void) => Promise<void>;
getEditorStyle: (element: ExcalidrawTextElement) => Record<string, any>;
measureText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "text"
| "lineHeight"
>,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => { width: number; height: number; baseline: number };
render: (
element: NonDeleted<ExcalidrawElement>,
context: CanvasRenderingContext2D,
) => void;
renderSvg: (
svgRoot: SVGElement,
root: SVGElement,
element: NonDeleted<ExcalidrawElement>,
opt?: { offsetX?: number; offsetY?: number },
) => void;
wrapText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "originalText"
| "lineHeight"
>,
containerWidth: number,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => string;
};
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
const methodMaps = [] as Array<MethodMap>;
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
export const getSubtypeMethods = (
subtype: Subtype | undefined,
): Partial<SubtypeMethods> | undefined => {
const map = methodMaps.find((method) => method.subtype === subtype);
return map?.methods;
};
export const addSubtypeMethods = (
subtype: Subtype,
methods: Partial<SubtypeMethods>,
) => {
if (!subtypeNames.includes(subtype)) {
return;
}
if (!methodMaps.find((method) => method.subtype === subtype)) {
methodMaps.push({ subtype, methods });
}
};
// Callback to re-render subtyped `ExcalidrawElement`s after completing
// async loading of the subtype.
export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void;
export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean;
// Functions to prepare subtypes for use
export type SubtypePrepFn = (onSubtypeLoaded?: SubtypeLoadedCb) => {
methods: Partial<SubtypeMethods>;
};
// This is the main method to set up the subtype. The optional
// `onSubtypeLoaded` callback may be used to re-render subtyped
// `ExcalidrawElement`s after the subtype has finished async loading.
export const prepareSubtype = (
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
onSubtypeLoaded?: SubtypeLoadedCb,
): { methods: Partial<SubtypeMethods> } => {
const map = getSubtypeMethods(record.subtype);
if (map) {
return { methods: map };
}
// Check for undefined/null subtypes and parentTypes
if (
record.subtype === undefined ||
record.subtype === "" ||
record.parents === undefined ||
record.parents.length === 0
) {
return { methods: {} };
}
// Register the types
const subtype = record.subtype;
subtypeNames = [...subtypeNames, subtype];
record.parents.forEach((parentType) => {
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
});
// Prepare the subtype
const { methods } = subtypePrepFn(onSubtypeLoaded);
// Register the subtype's methods
addSubtypeMethods(record.subtype, methods);
return { methods };
};
// Ensure all subtypes are loaded before continuing, eg to
// redraw text element bounding boxes correctly.
export const ensureSubtypesLoadedForElements = async (
elements: readonly ExcalidrawElement[],
callback?: () => void,
) => {
// Only ensure the loading of subtypes which are actually needed.
// We don't want to be held up by eg downloading the MathJax SVG fonts
// if we don't actually need them yet.
const subtypesUsed = [] as Subtype[];
elements.forEach((el) => {
if (
"subtype" in el &&
el.subtype !== undefined &&
!subtypesUsed.includes(el.subtype)
) {
subtypesUsed.push(el.subtype);
}
});
await ensureSubtypesLoaded(subtypesUsed, callback);
};
export const ensureSubtypesLoaded = async (
subtypes: Subtype[],
callback?: () => void,
) => {
// Use a for loop so we can do `await map.ensureLoaded()`
for (let i = 0; i < subtypes.length; i++) {
const subtype = subtypes[i];
// Should be defined if prepareSubtype() has run
const map = getSubtypeMethods(subtype);
if (map?.ensureLoaded) {
await map.ensureLoaded();
}
}
if (callback) {
callback();
}
};
// Call this method after finishing any async loading for
// subtypes of ExcalidrawElement if the newly loaded code
// would change the rendering.
export const checkRefreshOnSubtypeLoad = (
hasSubtype: SubtypeCheckFn,
elements: readonly ExcalidrawElement[],
) => {
let refreshNeeded = false;
const scenes: Scene[] = [];
getNonDeletedElements(elements).forEach((element) => {
// If the element is of the subtype that was just
// registered, update the element's dimensions, mark the
// element for a re-render, and indicate the scene needs a refresh.
if (hasSubtype(element)) {
ShapeCache.delete(element);
if (isTextElement(element)) {
redrawTextBoundingBox(element, getContainerElement(element));
}
refreshNeeded = true;
const scene = Scene.getScene(element);
if (scene && !scenes.includes(scene)) {
// Store in case we have multiple scenes
scenes.push(scene);
}
}
});
// Only inform each scene once
scenes.forEach((scene) => scene.informMutation());
return refreshNeeded;
};
+39 -20
View File
@@ -1,3 +1,4 @@
import { getSubtypeMethods, SubtypeMethods } from "./subtypes";
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
@@ -36,6 +37,30 @@ import {
} from "./textWysiwyg";
import { ExtractSetType } from "../utility-types";
export const measureTextElement = function (element, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.measureText) {
return map.measureText(element, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text;
return measureText(text, font, element.lineHeight);
} as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.wrapText) {
return map.wrapText(element, containerWidth, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.originalText;
return wrapText(text, font, containerWidth);
} as SubtypeMethods["wrapText"];
export const normalizeText = (text: string) => {
return (
text
@@ -68,22 +93,24 @@ export const redrawTextBoundingBox = (
if (container) {
maxWidth = getBoundTextMaxWidth(container, textElement);
boundTextUpdates.text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
}
const metrics = measureText(
boundTextUpdates.text,
getFontString(textElement),
textElement.lineHeight,
);
const metrics = measureTextElement(textElement, {
text: boundTextUpdates.text,
});
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
// Maintain coordX for non left-aligned text in case the width has changed
if (!container) {
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
boundTextUpdates.x += textElement.width - metrics.width;
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
}
}
if (container) {
const maxContainerHeight = getBoundTextMaxHeight(
container,
@@ -196,17 +223,9 @@ export const handleBindTextResize = (
(transformHandleType !== "n" && transformHandleType !== "s")
) {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
text = wrapTextElement(textElement, maxWidth);
}
const metrics = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
const metrics = measureTextElement(textElement, { text });
nextHeight = metrics.height;
nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
+65 -72
View File
@@ -1,12 +1,6 @@
import ReactDOM from "react-dom";
import { Excalidraw } from "../packages/excalidraw/index";
import {
GlobalTestState,
getTextEditor,
render,
screen,
updateTextEditor,
} from "../tests/test-utils";
import { GlobalTestState, render, screen } from "../tests/test-utils";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { CODES, KEYS } from "../keys";
import {
@@ -23,7 +17,6 @@ import {
} from "./types";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root
@@ -32,7 +25,16 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const tab = " ";
const mouse = new Pointer("mouse");
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const getTextEditor = () => {
return document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
};
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
fireEvent.change(editor, { target: { value } });
editor.dispatchEvent(new Event("input"));
};
describe("textWysiwyg", () => {
describe("start text editing", () => {
@@ -198,7 +200,7 @@ describe("textWysiwyg", () => {
mouse.clickAt(text.x + 50, text.y + 50);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
@@ -220,7 +222,7 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
@@ -247,7 +249,7 @@ describe("textWysiwyg", () => {
textElement = UI.createElement("text");
mouse.clickOn(textElement);
textarea = getTextEditor(textEditorSelector);
textarea = getTextEditor();
});
afterAll(() => {
@@ -457,7 +459,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
textarea = getTextEditor(textEditorSelector);
textarea = getTextEditor();
updateTextEditor(
textarea,
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
@@ -509,7 +511,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -537,7 +539,7 @@ describe("textWysiwyg", () => {
]);
expect(text.angle).toBe(rectangle.angle);
mouse.down();
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -564,7 +566,7 @@ describe("textWysiwyg", () => {
API.setSelectedElements([diamond]);
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n");
@@ -599,7 +601,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
let editor = getTextEditor(textEditorSelector);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@@ -614,7 +616,7 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(rectangle.id);
mouse.down();
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0));
@@ -636,7 +638,7 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
@@ -671,7 +673,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0));
@@ -696,7 +698,7 @@ describe("textWysiwyg", () => {
freedraw.y + freedraw.height / 2,
);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
@@ -730,7 +732,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -745,7 +747,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(
editor,
@@ -790,7 +792,7 @@ describe("textWysiwyg", () => {
mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = getTextEditor(textEditorSelector);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
@@ -803,7 +805,7 @@ describe("textWysiwyg", () => {
rectangle.y + rectangle.height / 2,
);
mouse.down();
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
editor.select();
fireEvent.click(screen.getByTitle(/code/i));
@@ -836,7 +838,7 @@ describe("textWysiwyg", () => {
Keyboard.keyDown(KEYS.ENTER);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = getTextEditor(textEditorSelector);
let editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -857,7 +859,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
updateTextEditor(editor, "Hello");
await new Promise((r) => setTimeout(r, 0));
@@ -886,7 +888,7 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
@@ -923,7 +925,7 @@ describe("textWysiwyg", () => {
// Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
editor.blur();
@@ -944,24 +946,24 @@ describe("textWysiwyg", () => {
it("should respect text alignment when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
let editor = getTextEditor(textEditorSelector);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
// should center align horizontally and vertically by default
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
85,
4.5,
4.999999999999986,
]
`);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
editor.select();
@@ -974,7 +976,7 @@ describe("textWysiwyg", () => {
editor.blur();
// should left align horizontally and bottom vertically after resize
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
15,
@@ -984,7 +986,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
editor.select();
@@ -996,11 +998,11 @@ describe("textWysiwyg", () => {
editor.blur();
// should right align horizontally and top vertically after resize
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
375,
-539,
374.99999999999994,
-535.0000000000001,
]
`);
});
@@ -1022,7 +1024,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -1037,7 +1039,7 @@ describe("textWysiwyg", () => {
it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -1046,7 +1048,7 @@ describe("textWysiwyg", () => {
expect(rectangle.height).toBe(75);
expect(textElement.fontSize).toBe(20);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
shift: true,
});
expect(rectangle.width).toBe(200);
@@ -1057,7 +1059,7 @@ describe("textWysiwyg", () => {
it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -1089,7 +1091,7 @@ describe("textWysiwyg", () => {
it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -1126,7 +1128,7 @@ describe("textWysiwyg", () => {
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, " ");
@@ -1181,32 +1183,35 @@ describe("textWysiwyg", () => {
it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = getTextEditor(textEditorSelector);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(156);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBeCloseTo(155, 8);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBe(156);
expect(rectangle.height).toBeCloseTo(155, 8);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
155,
8,
);
});
it("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
editor.blur();
@@ -1231,7 +1236,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
editor.blur();
expect(
@@ -1263,12 +1268,12 @@ describe("textWysiwyg", () => {
beforeEach(async () => {
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
updateTextEditor(editor, "Hello");
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
editor.select();
});
@@ -1379,7 +1384,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 = getTextEditor(textEditorSelector);
const editor = getTextEditor();
updateTextEditor(
editor,
@@ -1467,7 +1472,7 @@ describe("textWysiwyg", () => {
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = getTextEditor(textEditorSelector);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
expect(
@@ -1492,7 +1497,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
editor = getTextEditor(textEditorSelector);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw");
editor.blur();
@@ -1507,28 +1512,16 @@ describe("textWysiwyg", () => {
});
});
it("should bump the version of labelled arrow when label updated", async () => {
it("should bump the version of a labeled arrow when the label is updated", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
let editor = getTextEditor(textEditorSelector);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
await UI.editText(arrow, "Hello");
const { version } = arrow;
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor(textEditorSelector);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello\nworld!");
editor.blur();
await UI.editText(arrow, "Hello\nworld!");
expect(arrow.version).toEqual(version + 1);
});
+59 -8
View File
@@ -26,6 +26,7 @@ import {
getContainerElement,
getTextElementAngle,
getTextWidth,
measureText,
normalizeText,
redrawTextBoundingBox,
wrapText,
@@ -43,8 +44,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
import { SubtypeMethods, getSubtypeMethods } from "./subtypes";
const getTransform = (
offsetX: number,
width: number,
height: number,
angle: number,
@@ -62,7 +65,8 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2;
}
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
const offset = offsetX !== 0 ? ` translate(${offsetX}px, 0px)` : "";
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)${offset}`;
};
const originalContainerCache: {
@@ -97,6 +101,14 @@ export const getOriginalContainerHeightFromCache = (
return originalContainerCache[id]?.height ?? null;
};
const getEditorStyle = function (element) {
const map = getSubtypeMethods(element.subtype);
if (map?.getEditorStyle) {
return map.getEditorStyle(element);
}
return {};
} as SubtypeMethods["getEditorStyle"];
export const textWysiwyg = ({
id,
onChange,
@@ -156,11 +168,24 @@ export const textWysiwyg = ({
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width;
// Editing metrics
const eMetrics = measureText(
container && updatedTextElement.containerId
? wrapText(
updatedTextElement.originalText,
getFontString(updatedTextElement),
getBoundTextMaxWidth(container),
)
: updatedTextElement.originalText,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
let maxHeight = eMetrics.height;
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
// Set to element height by default since that's
// what is going to be used for unbounded text
const textElementHeight = updatedTextElement.height;
const textElementHeight = Math.max(updatedTextElement.height, maxHeight);
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
@@ -246,13 +271,35 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff;
}
let transformWidth = updatedTextElement.width;
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
transformWidth += 0.5;
}
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
const offWidth = container
? Math.min(
0,
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
)
: Math.min(maxWidth, updatedTextElement.width) -
Math.min(maxWidth, eMetrics.width);
const offsetX =
textAlign === "right"
? offWidth
: textAlign === "center"
? offWidth / 2
: 0;
const { width: w, height: h } = updatedTextElement;
const transformOrigin =
updatedTextElement.width !== eMetrics.width ||
updatedTextElement.height !== eMetrics.height
? { transformOrigin: `${w / 2}px ${h / 2}px` }
: {};
let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
@@ -270,13 +317,15 @@ export const textWysiwyg = ({
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight,
width: `${textElementWidth}px`,
width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
...transformOrigin,
transform: getTransform(
textElementWidth,
textElementHeight,
offsetX,
transformWidth,
updatedTextElement.height,
getTextElementAngle(updatedTextElement),
appState,
maxWidth,
@@ -334,6 +383,7 @@ export const textWysiwyg = ({
whiteSpace,
overflowWrap: "break-word",
boxSizing: "content-box",
...getEditorStyle(element),
});
editable.value = element.originalText;
updateWysiwygStyle();
@@ -584,7 +634,7 @@ export const textWysiwyg = ({
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate();
editable.remove();
@@ -701,6 +751,7 @@ export const textWysiwyg = ({
passive: false,
capture: true,
});
window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
+1
View File
@@ -65,6 +65,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number;
link: string | null;
locked: boolean;
subtype?: string;
customData?: Record<string, any>;
}>;
+116
View File
@@ -436,5 +436,121 @@ describe("adding elements to frames", () => {
expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]);
});
it("random order 01", () => {
const frame1 = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const frame2 = API.createElement({
type: "frame",
x: 200,
y: 0,
width: 100,
height: 100,
});
const frame3 = API.createElement({
type: "frame",
x: 300,
y: 0,
width: 100,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 25,
y: 25,
width: 50,
height: 50,
frameId: frame1.id,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 225,
y: 25,
width: 50,
height: 50,
frameId: frame2.id,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 325,
y: 25,
width: 50,
height: 50,
frameId: frame3.id,
});
const rectangle4 = API.createElement({
type: "rectangle",
x: 350,
y: 25,
width: 50,
height: 50,
frameId: frame3.id,
});
h.elements = [
frame1,
rectangle4,
rectangle1,
rectangle3,
frame3,
rectangle2,
frame2,
];
API.setSelectedElements([rectangle2]);
const origSize = h.elements.length;
expect(h.elements.length).toBe(origSize);
dragElementIntoFrame(frame3, rectangle2);
expect(h.elements.length).toBe(origSize);
});
it("random order 02", () => {
const frame1 = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const frame2 = API.createElement({
type: "frame",
x: 200,
y: 0,
width: 100,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 25,
y: 25,
width: 50,
height: 50,
frameId: frame1.id,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 225,
y: 25,
width: 50,
height: 50,
frameId: frame2.id,
});
h.elements = [rectangle1, rectangle2, frame1, frame2];
API.setSelectedElements([rectangle2]);
expect(h.elements.length).toBe(4);
dragElementIntoFrame(frame2, rectangle1);
expect(h.elements.length).toBe(4);
});
});
});
+112 -66
View File
@@ -14,7 +14,7 @@ import {
getBoundTextElement,
getContainerElement,
} from "./element/textElement";
import { arrayToMap, findIndex } from "./utils";
import { arrayToMap } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
@@ -452,90 +452,125 @@ export const getContainingFrame = (
};
// --------------------------- Frame Operations -------------------------------
/**
* Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame:
* [el, el, child, child, frame, el]
*/
export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement,
) => {
const _elementsToAdd: ExcalidrawElement[] = [];
const { allElementsIndexMap, currTargetFrameChildrenMap } =
allElements.reduce(
(acc, element, index) => {
acc.allElementsIndexMap.set(element.id, index);
if (element.frameId === frame.id) {
acc.currTargetFrameChildrenMap.set(element.id, true);
}
return acc;
},
{
allElementsIndexMap: new Map<ExcalidrawElement["id"], number>(),
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
for (const element of elementsToAdd) {
_elementsToAdd.push(element);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
_elementsToAdd.push(boundTextElement);
}
}
const allElementsIndex = allElements.reduce(
(acc: Record<string, number>, element, index) => {
acc[element.id] = index;
return acc;
},
{},
);
const frameIndex = allElementsIndex[frame.id];
// need to be calculated before the mutation below occurs
const leftFrameBoundaryIndex = findIndex(
allElements,
(e) => e.frameId === frame.id,
);
const existingFrameChildren = allElements.filter(
(element) => element.frameId === frame.id,
);
const addedFrameChildren_left: ExcalidrawElement[] = [];
const addedFrameChildren_right: ExcalidrawElement[] = [];
const finalElementsToAdd: ExcalidrawElement[] = [];
// - add bound text elements if not already in the array
// - filter out elements that are already in the frame
for (const element of omitGroupsContainingFrames(
allElements,
_elementsToAdd,
elementsToAdd,
)) {
if (element.frameId !== frame.id && !isFrameElement(element)) {
if (allElementsIndex[element.id] > frameIndex) {
addedFrameChildren_right.push(element);
} else {
addedFrameChildren_left.push(element);
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
const boundTextElement = getBoundTextElement(element);
if (
boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
}
}
const frameElement = allElements[frameIndex];
const nextFrameChildren = addedFrameChildren_left
.concat(existingFrameChildren)
.concat(addedFrameChildren_right);
const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
const nextFrameChildrenMap = nextFrameChildren.reduce(
(acc: Record<string, boolean>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
const nextElements: ExcalidrawElement[] = [];
const nextOtherElements_left = allElements
.slice(0, leftFrameBoundaryIndex >= 0 ? leftFrameBoundaryIndex : frameIndex)
.filter((element) => !nextFrameChildrenMap[element.id]);
const processedElements = new Set<ExcalidrawElement["id"]>();
const nextOtherElement_right = allElements
.slice(frameIndex + 1)
.filter((element) => !nextFrameChildrenMap[element.id]);
for (const element of allElements) {
if (processedElements.has(element.id)) {
continue;
}
const nextElements = nextOtherElements_left
.concat(nextFrameChildren)
.concat([frameElement])
.concat(nextOtherElement_right);
processedElements.add(element.id);
if (
finalElementsToAddSet.has(element.id) ||
(element.frameId && element.frameId === frame.id)
) {
// will be added in bulk once we process target frame
continue;
}
// target frame
if (element.id === frame.id) {
const currFrameChildren = getFrameElements(allElements, frame.id);
currFrameChildren.forEach((child) => {
processedElements.add(child.id);
});
// if not found, add all children on top by assigning the lowest index
const targetFrameIndex = allElementsIndexMap.get(frame.id) ?? -1;
const { newChildren_left, newChildren_right } = finalElementsToAdd.reduce(
(acc, element) => {
// if index not found, add on top of current frame children
const elementIndex = allElementsIndexMap.get(element.id) ?? Infinity;
if (elementIndex < targetFrameIndex) {
acc.newChildren_left.push(element);
} else {
acc.newChildren_right.push(element);
}
return acc;
},
{
newChildren_left: [] as ExcalidrawElement[],
newChildren_right: [] as ExcalidrawElement[],
},
);
nextElements.push(
...newChildren_left,
...currFrameChildren,
...newChildren_right,
element,
);
continue;
}
nextElements.push(element);
}
for (const element of finalElementsToAdd) {
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
}
return nextElements;
};
@@ -705,6 +740,17 @@ export const isElementInFrame = (
: element;
if (frame) {
// Perf improvement:
// For an element that's already in a frame, if it's not being dragged
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
// It has to be in its containing frame.
if (
!appState.selectedElementIds[element.id] ||
!appState.selectedElementsAreBeingDragged
) {
return true;
}
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame);
}
+1
View File
@@ -21,6 +21,7 @@ export const CODES = {
V: "KeyV",
Z: "KeyZ",
R: "KeyR",
S: "KeyS",
} as const;
export const KEYS = {
+3 -9
View File
@@ -164,6 +164,7 @@
"darkMode": "Dark mode",
"lightMode": "Light mode",
"zenMode": "Zen mode",
"objectsSnapMode": "Snap to objects",
"exitZenMode": "Exit zen mode",
"cancel": "Cancel",
"clear": "Clear",
@@ -235,9 +236,9 @@
"eraser": "Eraser",
"frame": "Frame tool",
"embeddable": "Web Embed",
"laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw"
"extraTools": "More tools"
},
"headings": {
"canvasActions": "Canvas actions",
@@ -496,12 +497,5 @@
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
}
}
},
"mermaid": {
"title": "Mermaid to Excalidraw",
"button": "Insert",
"description": "Currently only <flowchartLink>flowcharts</flowchartLink> are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax",
"preview": "Preview"
}
}
+41 -1
View File
@@ -1,4 +1,4 @@
import { rotate } from "./math";
import { rangeIntersection, rangesOverlap, rotate } from "./math";
describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
@@ -13,3 +13,43 @@ describe("rotate", () => {
expect(res2).toEqual([x1, x2]);
});
});
describe("range overlap", () => {
it("should overlap when range a contains range b", () => {
expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
});
it("should overlap when range b contains range a", () => {
expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
});
it("should overlap when range a and b intersect", () => {
expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
});
});
describe("range intersection", () => {
it("should intersect completely with itself", () => {
expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
});
it("should intersect irrespective of order", () => {
expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
});
it("should intersect at the edge", () => {
expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
});
it("should not intersect", () => {
expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
});
});
+33
View File
@@ -472,3 +472,36 @@ export const isRightAngle = (angle: number) => {
// angle, which we can check with modulo after rounding.
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
};
// Given two ranges, return if the two ranges overlap with each other
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
export const rangesOverlap = (
[a0, a1]: [number, number],
[b0, b1]: [number, number],
) => {
if (a0 <= b0) {
return a1 >= b0;
}
if (a0 >= b0) {
return b1 >= a0;
}
return false;
};
// Given two ranges,return ther intersection of the two ranges if any
// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
export const rangeIntersection = (
rangeA: [number, number],
rangeB: [number, number],
): [number, number] | null => {
const rangeStart = Math.max(rangeA[0], rangeB[0]);
const rangeEnd = Math.min(rangeA[1], rangeB[1]);
if (rangeStart <= rangeEnd) {
return [rangeStart, rangeEnd];
}
return null;
};
+22
View File
@@ -11,6 +11,28 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section.
-->
## Unreleased
### Features
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [7078](https://github.com/excalidraw/excalidraw/pull/7078)
## 0.16.1 (2023-09-21)
## Excalidraw Library
**_This section lists the updates made to the excalidraw library and will not affect the integration._**
### Fixes
- More eye-droper fixes [#7019](https://github.com/excalidraw/excalidraw/pull/7019)
### Refactor
- Move excalidraw-app outside src [#6987](https://github.com/excalidraw/excalidraw/pull/6987)
---
## 0.16.0 (2023-09-19)
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@excalidraw/excalidraw",
"version": "0.16.0",
"version": "0.16.1",
"main": "main.js",
"types": "types/packages/excalidraw/index.d.ts",
"files": [
@@ -41,14 +41,6 @@ module.exports = {
"sass-loader",
],
},
// So that type module works with webpack
// https://github.com/webpack/webpack/issues/11467#issuecomment-691873586
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
{
test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude:
@@ -44,14 +44,6 @@ module.exports = {
"sass-loader",
],
},
// So that type module works with webpack
// https://github.com/webpack/webpack/issues/11467#issuecomment-691873586
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
{
test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude:
+7
View File
@@ -22,5 +22,12 @@ const polyfill = () => {
configurable: true,
});
}
if (!Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function (...nodes) {
this.innerHTML = "";
this.append(...nodes);
};
}
};
export default polyfill;
+12
View File
@@ -31,6 +31,7 @@ import {
InteractiveCanvasAppState,
} from "../types";
import { getDefaultAppState } from "../appState";
import { getSubtypeMethods } from "../element/subtypes";
import {
BOUND_TEXT_PADDING,
FRAME_STYLE,
@@ -264,6 +265,12 @@ const drawElementOnCanvas = (
) => {
context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
const map = getSubtypeMethods(element.subtype);
if (map?.render) {
map.render(element, context);
context.globalAlpha = 1;
return;
}
switch (element.type) {
case "rectangle":
case "embeddable":
@@ -897,6 +904,11 @@ export const renderElementToSvg = (
root = anchorTag;
}
const map = getSubtypeMethods(element.subtype);
if (map?.renderSvg) {
map.renderSvg(svgRoot, root, element, { offsetX, offsetY });
return;
}
const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
+3
View File
@@ -67,6 +67,7 @@ import {
EXTERNAL_LINK_IMG,
getLinkHandleFromCoords,
} from "../element/Hyperlink";
import { renderSnaps } from "./renderSnaps";
import {
isEmbeddableElement,
isFrameElement,
@@ -720,6 +721,8 @@ const _renderInteractiveScene = ({
context.restore();
}
renderSnaps(context, appState);
// Reset zoom
context.restore();
+189
View File
@@ -0,0 +1,189 @@
import { PointSnapLine, PointerSnapLine } from "../snapping";
import { InteractiveCanvasAppState, Point } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b";
const SNAP_COLOR_DARK = "#ff0000";
const SNAP_WIDTH = 1;
const SNAP_CROSS_SIZE = 2;
export const renderSnaps = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
if (!appState.snapLines.length) {
return;
}
// in dark mode, we need to adjust the color to account for color inversion.
// Don't change if zen mode, because we draw only crosses, we want the
// colors to be more visible
const snapColor =
appState.theme === "light" || appState.zenModeEnabled
? SNAP_COLOR_LIGHT
: SNAP_COLOR_DARK;
// in zen mode make the cross more visible since we don't draw the lines
const snapWidth =
(appState.zenModeEnabled ? SNAP_WIDTH * 1.5 : SNAP_WIDTH) /
appState.zoom.value;
context.save();
context.translate(appState.scrollX, appState.scrollY);
for (const snapLine of appState.snapLines) {
if (snapLine.type === "pointer") {
context.lineWidth = snapWidth;
context.strokeStyle = snapColor;
drawPointerSnapLine(snapLine, context, appState);
} else if (snapLine.type === "gap") {
context.lineWidth = snapWidth;
context.strokeStyle = snapColor;
drawGapLine(
snapLine.points[0],
snapLine.points[1],
snapLine.direction,
appState,
context,
);
} else if (snapLine.type === "points") {
context.lineWidth = snapWidth;
context.strokeStyle = snapColor;
drawPointsSnapLine(snapLine, context, appState);
}
}
context.restore();
};
const drawPointsSnapLine = (
pointSnapLine: PointSnapLine,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
if (!appState.zenModeEnabled) {
const firstPoint = pointSnapLine.points[0];
const lastPoint = pointSnapLine.points[pointSnapLine.points.length - 1];
drawLine(firstPoint, lastPoint, context);
}
for (const point of pointSnapLine.points) {
drawCross(point, appState, context);
}
};
const drawPointerSnapLine = (
pointerSnapLine: PointerSnapLine,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
drawCross(pointerSnapLine.points[0], appState, context);
if (!appState.zenModeEnabled) {
drawLine(pointerSnapLine.points[0], pointerSnapLine.points[1], context);
}
};
const drawCross = (
[x, y]: Point,
appState: InteractiveCanvasAppState,
context: CanvasRenderingContext2D,
) => {
context.save();
const size =
(appState.zenModeEnabled ? SNAP_CROSS_SIZE * 1.5 : SNAP_CROSS_SIZE) /
appState.zoom.value;
context.beginPath();
context.moveTo(x - size, y - size);
context.lineTo(x + size, y + size);
context.moveTo(x + size, y - size);
context.lineTo(x - size, y + size);
context.stroke();
context.restore();
};
const drawLine = (
from: Point,
to: Point,
context: CanvasRenderingContext2D,
) => {
context.beginPath();
context.lineTo(...from);
context.lineTo(...to);
context.stroke();
};
const drawGapLine = (
from: Point,
to: Point,
direction: "horizontal" | "vertical",
appState: InteractiveCanvasAppState,
context: CanvasRenderingContext2D,
) => {
// a horizontal gap snap line
// |–––––––||–––––––|
// ^ ^ ^ ^
// \ \ \ \
// (1) (2) (3) (4)
const FULL = 8 / appState.zoom.value;
const HALF = FULL / 2;
const QUARTER = FULL / 4;
if (direction === "horizontal") {
const halfPoint = [(from[0] + to[0]) / 2, from[1]];
// (1)
if (!appState.zenModeEnabled) {
drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
}
// (3)
drawLine(
[halfPoint[0] - QUARTER, halfPoint[1] - HALF],
[halfPoint[0] - QUARTER, halfPoint[1] + HALF],
context,
);
drawLine(
[halfPoint[0] + QUARTER, halfPoint[1] - HALF],
[halfPoint[0] + QUARTER, halfPoint[1] + HALF],
context,
);
if (!appState.zenModeEnabled) {
// (4)
drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
// (2)
drawLine(from, to, context);
}
} else {
const halfPoint = [from[0], (from[1] + to[1]) / 2];
// (1)
if (!appState.zenModeEnabled) {
drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
}
// (3)
drawLine(
[halfPoint[0] - HALF, halfPoint[1] - QUARTER],
[halfPoint[0] + HALF, halfPoint[1] - QUARTER],
context,
);
drawLine(
[halfPoint[0] - HALF, halfPoint[1] + QUARTER],
[halfPoint[0] + HALF, halfPoint[1] + QUARTER],
context,
);
if (!appState.zenModeEnabled) {
// (4)
drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
// (2)
drawLine(from, to, context);
}
}
};
+21
View File
@@ -11,6 +11,7 @@ import {
getFrameElements,
} from "../frame";
import { isShallowEqual } from "../utils";
import { isElementInViewport } from "../element/sizeHelpers";
/**
* Frames and their containing elements are not to be selected at the same time.
@@ -89,6 +90,26 @@ export const getElementsWithinSelection = (
return elementsInSelection;
};
export const getVisibleAndNonSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const selectedElementsSet = new Set(
selectedElements.map((element) => element.id),
);
return elements.filter((element) => {
const isVisible = isElementInViewport(
element,
appState.width,
appState.height,
appState,
);
return !selectedElementsSet.has(element.id) && isVisible;
});
};
// FIXME move this into the editor instance to keep utility methods stateless
export const isSomeElementSelected = (function () {
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
+1361
View File
File diff suppressed because it is too large Load Diff
-178
View File
@@ -1,178 +0,0 @@
import {
act,
fireEvent,
getTextEditor,
render,
updateTextEditor,
} 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";
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: {
activeTool: {
type: "mermaid",
lastActiveTool: null,
locked: false,
customType: null,
},
},
}}
/>,
);
});
it("should open mermaid popup when active tool is mermaid", async () => {
const dialog = document.querySelector(".dialog-mermaid")!;
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 = ".mermaid-to-excalidraw-wrapper-text textarea";
const editor = getTextEditor(selector);
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[test]"
`);
await act(async () => {
updateTextEditor(editor, "flowchart TD1");
await new Promise((cb) => setTimeout(cb, 0));
});
expect(getTextEditor(selector).textContent).toBe("flowchart TD1");
expect(dialog.querySelector('[data-testid="mermaid-error"]'))
.toMatchInlineSnapshot(`
<div
data-testid="mermaid-error"
style="color: red; font-weight: 800; font-size: 30px; word-break: break-word; overflow: auto; max-height: 100%; text-align: center;"
>
Error!
<p
style="font-size: 18px; font-weight: 600;"
>
ERROR
</p>
</div>
`);
});
});
@@ -1,10 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class=\\"Modal Dialog dialog-mermaid\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 800px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><h2 id=\\"test-id-dialog-title\\" class=\\"Dialog__title\\"><span class=\\"Dialog__titleContent\\"><p style=\\"margin-bottom: 5px; margin-top: 2px;\\">Mermaid to Excalidraw</p><span style=\\"font-size: 15px; font-style: italic; font-weight: 500;\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">flowcharts</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=\\"mermaid-to-excalidraw-wrapper\\"><div class=\\"mermaid-to-excalidraw-wrapper-text\\" style=\\"display: flex; flex-direction: column;\\"><label>Mermaid Syntax</label><textarea style=\\"padding: 0.85rem; border-radius: 8px; border: 1px solid #e4e4eb; white-space: pre-wrap;\\">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[test]</textarea></div><div class=\\"mermaid-to-excalidraw-wrapper-preview\\" style=\\"display: flex; flex-direction: column;\\"><label>Preview</label><div class=\\"mermaid-to-excalidraw-wrapper-preview-canvas\\"><div></div></div><button type=\\"button\\" class=\\"excalidraw-button mermaid-to-excalidraw-wrapper-preview-insert\\">Insert<span style=\\"padding-left: 8px; display: flex;\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\"><path d=\\"M4.16602 10H15.8327\\" stroke=\\"white\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\" stroke=\\"white\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\" stroke=\\"white\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"></path></svg></span></button></div></div></div></div></div></div>"
`;
@@ -331,12 +331,17 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
@@ -363,6 +368,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -524,12 +530,14 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -553,6 +561,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -666,7 +675,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of elements 1`] = `1`;
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `6`;
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = `
{
@@ -723,12 +732,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -752,6 +763,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1039,7 +1051,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = `
{
@@ -1096,12 +1108,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -1125,6 +1139,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1412,7 +1427,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = `
{
@@ -1469,12 +1484,14 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -1498,6 +1515,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1611,7 +1629,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of elements 1`] = `1`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `6`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = `
{
@@ -1668,12 +1686,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -1695,6 +1715,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1847,7 +1868,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of elements 1`] = `1`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `8`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = `
{
@@ -1904,12 +1925,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -1933,6 +1956,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -2148,7 +2172,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `8`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = `
{
@@ -2205,12 +2229,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -2239,6 +2265,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -2537,7 +2564,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = `
{
@@ -2594,12 +2621,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -2623,6 +2652,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -3416,7 +3446,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `20`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `19`;
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = `
{
@@ -3473,12 +3503,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -3502,6 +3534,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -3789,7 +3822,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `11`;
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = `
{
@@ -3846,12 +3879,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -3875,6 +3910,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -4162,7 +4198,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `11`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = `
{
@@ -4219,12 +4255,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -4251,6 +4289,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -4618,7 +4657,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `14`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = `
{
@@ -4951,12 +4990,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -4983,6 +5024,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -5198,7 +5240,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = `
{
@@ -5531,12 +5573,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -5565,6 +5609,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -5863,7 +5908,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `14`;
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = `
{
@@ -5950,6 +5995,19 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"viewMode": true,
},
{
"checked": [Function],
"contextItemLabel": "buttons.objectsSnapMode",
"keyTest": [Function],
"name": "objectsSnapMode",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "canvas",
"predicate": [Function],
},
"viewMode": true,
},
{
"checked": [Function],
"contextItemLabel": "buttons.zenMode",
@@ -6035,12 +6093,17 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
@@ -6062,6 +6125,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -6431,12 +6495,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -6460,6 +6526,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -6805,12 +6872,17 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
@@ -6834,6 +6906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -7031,6 +7104,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] nu
exports[`contextMenu element > shows context menu for element > [end of test] number of elements 2`] = `2`;
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `6`;
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 2`] = `6`;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -87,6 +87,7 @@ describe("contextMenu element", () => {
"gridMode",
"zenMode",
"viewMode",
"objectsSnapMode",
"stats",
];
@@ -340,12 +340,12 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"groupIds": [],
"height": 100,
"id": "id-text01",
"isDeleted": false,
"isDeleted": true,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
"originalText": "test",
"originalText": "",
"roughness": 1,
"roundness": {
"type": 3,
@@ -358,8 +358,8 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"textAlign": "left",
"type": "text",
"updated": 1,
"version": 1,
"versionNonce": 0,
"version": 2,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 0,
+3
View File
@@ -86,12 +86,15 @@ describe("restoreElements", () => {
textElement.text = null;
textElement.font = "10 unknown";
expect(textElement.isDeleted).toBe(false);
const restoredText = restore.restoreElements(
[textElement],
null,
)[0] as ExcalidrawTextElement;
expect(restoredText.isDeleted).toBe(true);
expect(restoredText).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
});
});
+10 -10
View File
@@ -47,7 +47,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -79,7 +79,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
@@ -112,7 +112,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -144,7 +144,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -180,7 +180,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -221,7 +221,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -241,7 +241,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -261,7 +261,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -288,7 +288,7 @@ describe("Test dragCreate", () => {
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -315,7 +315,7 @@ describe("Test dragCreate", () => {
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
+27 -1
View File
@@ -16,6 +16,14 @@ import util from "util";
import path from "path";
import { getMimeType } from "../../data/blob";
import {
SubtypeLoadedCb,
SubtypePrepFn,
SubtypeRecord,
checkRefreshOnSubtypeLoad,
prepareSubtype,
} from "../../element/subtypes";
import {
maybeGetSubtypeProps,
newEmbeddableElement,
newFrameElement,
newFreeDrawElement,
@@ -32,6 +40,16 @@ const readFile = util.promisify(fs.readFile);
const { h } = window;
export class API {
static addSubtype = (record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) => {
const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => {
if (checkRefreshOnSubtypeLoad(hasSubtype, h.elements)) {
h.app.refresh();
}
};
const prep = prepareSubtype(record, subtypePrepFn, subtypeLoadedCb);
return prep;
};
static setSelectedElements = (elements: ExcalidrawElement[]) => {
h.setState({
selectedElementIds: elements.reduce((acc, element) => {
@@ -94,6 +112,7 @@ export class API {
angle?: number;
id?: string;
isDeleted?: boolean;
frameId?: ExcalidrawElement["id"];
groupIds?: string[];
// generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"];
@@ -112,6 +131,8 @@ export class API {
verticalAlign?: T extends "text"
? ExcalidrawTextElement["verticalAlign"]
: never;
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
boundElements?: ExcalidrawGenericElement["boundElements"];
containerId?: T extends "text"
? ExcalidrawTextElement["containerId"]
@@ -140,6 +161,10 @@ export class API {
const appState = h?.state || getDefaultAppState();
const custom = maybeGetSubtypeProps({
subtype: rest.subtype,
customData: rest.customData,
});
const base: Omit<
ExcalidrawGenericElement,
| "id"
@@ -151,12 +176,13 @@ export class API {
| "versionNonce"
| "isDeleted"
| "groupIds"
| "frameId"
| "link"
| "updated"
> = {
...custom,
x,
y,
frameId: rest.frameId ?? null,
angle: rest.angle ?? 0,
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
backgroundColor:

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