Compare commits

..

2 Commits

Author SHA1 Message Date
David Luzar 3ae8af5a13 v0.16.2 2024-04-12 20:15:53 +02:00
David Luzar 053ca9058a fix: Gist embed allowing unsafe html (#7883) 2024-04-12 15:14:18 +02:00
104 changed files with 1042 additions and 5861 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/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/src/excalidraw-app) is part of this repository as well, and the app features:
- 📡 PWA support (works offline).
- 🤼 Real-time collaboration.
@@ -38,7 +38,6 @@ 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 |
@@ -71,7 +70,6 @@ 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 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.
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.
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/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/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.
### 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**
![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
![Aggresive 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 workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
The following worfklow 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
@@ -1,22 +0,0 @@
# 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, run:
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
>
> ```bash
> git remote add upstream https://github.com/excalidraw/excalidraw.git
+1 -5
View File
@@ -23,11 +23,7 @@ const sidebars = {
},
items: ["introduction/development", "introduction/contributing"],
},
{
type: "category",
label: "Codebase",
items: ["codebase/json-schema", "codebase/frames"],
},
{ type: "category", label: "Codebase", items: ["codebase/json-schema"] },
{
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 but don't know where to
Want to build your own app powered by Excalidraw by 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; tool: "pointer" | "laser" };
pointer: { x: number; y: number };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;
-1
View File
@@ -20,7 +20,6 @@
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@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",
+1 -1
View File
@@ -11,7 +11,7 @@
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "180x180"
"sizes": "256x256"
}
],
"start_url": "/",
+6 -6
View File
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
measureTextElement,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
@@ -31,6 +31,7 @@ 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({
@@ -47,11 +48,10 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureTextElement(
boundTextElement,
{
text: boundTextElement.originalText,
},
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
+1 -2
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, updateActiveTool } from "../utils";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
@@ -21,7 +21,6 @@ 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",
+2 -5
View File
@@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { updateActiveTool } from "../utils";
import { updateActiveTool, resetCursor } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@@ -15,7 +15,6 @@ import {
} from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
export const actionFinalize = register({
name: "finalize",
@@ -91,9 +90,7 @@ export const actionFinalize = register({
}
}
if (isInvisiblySmallElement(multiPointElement)) {
newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
newElements = newElements.slice(0, -1);
}
// If the multi point line closes the loop,
+1 -2
View File
@@ -4,8 +4,7 @@ import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
-1
View File
@@ -15,7 +15,6 @@ export const actionToggleGridMode = register({
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
};
@@ -1,28 +0,0 @@
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,7 +80,6 @@ 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,7 +28,6 @@ export type ShortcutName =
| "ungroup"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "addToLibrary"
| "viewMode"
@@ -75,7 +74,6 @@ 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,7 +51,6 @@ export type ActionName =
| "pasteStyles"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
-9
View File
@@ -99,12 +99,6 @@ export const getDefaultAppState = (): Omit<
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
snapLines: [],
originSnapOffset: {
x: 0,
y: 0,
},
objectsSnapModeEnabled: false,
};
};
@@ -212,9 +206,6 @@ 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: var(--color-surface-low) !important;
background-color: transparent !important;
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
+70 -47
View File
@@ -14,8 +14,13 @@ import {
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
import { AppClassProperties, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import { UIAppState, Zoom } from "../types";
import {
capitalizeString,
isTransparent,
updateActiveTool,
setCursorForShape,
} from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
@@ -31,12 +36,7 @@ import {
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
laserPointerToolIcon,
} from "./icons";
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
@@ -215,23 +215,18 @@ 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) => {
@@ -256,14 +251,29 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
app.setActiveTool({ type: value });
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
activeEmbeddable: null,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
@@ -290,14 +300,24 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
app.setActiveTool({ type: "frame" });
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
selected={activeTool.type === "frame"}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
@@ -310,28 +330,30 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
app.setActiveTool({ type: "embeddable" });
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
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),
})}
className="App-toolbar__extra-tools-trigger"
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
@@ -344,36 +366,37 @@ export const ShapesSwitcher = ({
>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "frame" });
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "embeddable" });
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
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>
)}
+86 -350
View File
@@ -35,7 +35,6 @@ import {
actionLink,
actionToggleElementLock,
actionToggleLinearEditor,
actionToggleObjectsSnapMode,
} from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
@@ -211,7 +210,7 @@ import {
import Scene from "../scene/Scene";
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes";
import { findShapeByKey, SHAPES } from "../shapes";
import {
AppClassProperties,
AppProps,
@@ -229,9 +228,6 @@ import {
FrameNameBoundsCache,
SidebarName,
SidebarTabName,
KeyboardModifiersObject,
CollaboratorPointer,
ToolType,
} from "../types";
import {
debounce,
@@ -241,14 +237,18 @@ import {
isInputLike,
isToolIcon,
isWritableElement,
resetCursor,
resolvablePromise,
sceneCoordsToViewportCoords,
setCursor,
setCursorForShape,
tupleToCoors,
viewportCoordsToSceneCoords,
withBatchedUpdates,
wrapEvent,
withBatchedUpdatesThrottled,
updateObject,
setEraserCursor,
updateActiveTool,
getShortcutKey,
isTransparent,
@@ -342,17 +342,6 @@ 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";
@@ -365,14 +354,6 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache";
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!);
@@ -497,13 +478,10 @@ class App extends React.Component<AppProps, AppState> {
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null;
lastPointerDown: React.PointerEvent<HTMLElement> | null = null;
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
lastViewportPosition = { x: 0, y: 0 };
laserPathManager: LaserPathManager = new LaserPathManager(this);
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
@@ -512,7 +490,6 @@ class App extends React.Component<AppProps, AppState> {
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
objectsSnapModeEnabled = false,
theme = defaultAppState.theme,
name = defaultAppState.name,
} = props;
@@ -523,7 +500,6 @@ class App extends React.Component<AppProps, AppState> {
...this.getCanvasOffsets(),
viewModeEnabled,
zenModeEnabled,
objectsSnapModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
name,
width: window.innerWidth,
@@ -918,7 +894,11 @@ class App extends React.Component<AppProps, AppState> {
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads"
sandbox={`${
embedLink?.sandbox?.allowSameOrigin
? "allow-same-origin"
: ""
} allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
/>
)}
</div>
@@ -1110,7 +1090,7 @@ class App extends React.Component<AppProps, AppState> {
cursor: CURSOR_TYPE.MOVE,
pointerEvents: this.state.viewModeEnabled
? POINTER_EVENTS.disabled
: POINTER_EVENTS.enabled,
: POINTER_EVENTS.inheritFromUI,
}}
onPointerDown={(event) => this.handleCanvasPointerDown(event)}
onWheel={(event) => this.handleWheel(event)}
@@ -1157,9 +1137,6 @@ 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
@@ -1215,14 +1192,12 @@ class App extends React.Component<AppProps, AppState> {
!this.scene.getElementsIncludingDeleted().length
}
app={this}
isCollaborating={this.props.isCollaborating}
>
{this.props.children}
</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 && (
@@ -1750,9 +1725,7 @@ 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();
@@ -2567,11 +2540,10 @@ class App extends React.Component<AppProps, AppState> {
});
};
togglePenMode = (force?: boolean) => {
togglePenMode = () => {
this.setState((prevState) => {
return {
penMode: force ?? !prevState.penMode,
penDetected: true,
penMode: !prevState.penMode,
};
});
};
@@ -3065,15 +3037,6 @@ 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)
@@ -3133,10 +3096,15 @@ class App extends React.Component<AppProps, AppState> {
}
});
setActiveTool = (
private setActiveTool = (
tool:
| {
type: ToolType;
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
}
| { type: "custom"; customType: string },
) => {
@@ -3155,30 +3123,17 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type === "image") {
this.onImageAction();
}
this.setState((prevState) => {
const commonResets = {
snapLines: prevState.snapLines.length ? [] : prevState.snapLines,
originSnapOffset: null,
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,
if (nextActiveTool.type !== "selection") {
this.setState({
activeTool: nextActiveTool,
...commonResets,
};
});
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
editingGroupId: null,
activeEmbeddable: null,
});
} else {
this.setState({ activeTool: nextActiveTool, activeEmbeddable: null });
}
};
private setCursor = (cursor: string) => {
@@ -3755,10 +3710,10 @@ class App extends React.Component<AppProps, AppState> {
isTouchScreen: boolean,
) => {
const draggedDistance = distance2d(
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
this.lastPointerUpEvent!.clientX,
this.lastPointerUpEvent!.clientY,
this.lastPointerDown!.clientX,
this.lastPointerDown!.clientY,
this.lastPointerUp!.clientX,
this.lastPointerUp!.clientY,
);
if (
!this.hitLinkElement ||
@@ -3769,7 +3724,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const lastPointerDownCoords = viewportCoordsToSceneCoords(
this.lastPointerDownEvent!,
this.lastPointerDown!,
this.state,
);
const lastPointerDownHittingLinkIcon = isPointHittingLink(
@@ -3779,7 +3734,7 @@ class App extends React.Component<AppProps, AppState> {
this.device.isMobile,
);
const lastPointerUpCoords = viewportCoordsToSceneCoords(
this.lastPointerUpEvent!,
this.lastPointerUp!,
this.state,
);
const lastPointerUpHittingLinkIcon = isPointHittingLink(
@@ -3914,30 +3869,6 @@ 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
@@ -4408,10 +4339,6 @@ 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
@@ -4484,21 +4411,17 @@ class App extends React.Component<AppProps, AppState> {
return;
}
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.lastPointerDown = event;
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 &&
@@ -4589,11 +4512,6 @@ 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"
@@ -4617,7 +4535,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUp = onPointerUp;
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
if (!this.state.viewModeEnabled) {
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
@@ -4633,14 +4551,14 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
) => {
this.removePointer(event);
this.lastPointerUpEvent = event;
this.lastPointerUp = event;
const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY },
this.state,
);
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
event.timeStamp - (this.lastPointerDown?.timeStamp ?? 0);
if (this.device.isMobile && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
@@ -5394,9 +5312,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
sceneX,
sceneY,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const embedLink = getEmbedLink(link);
@@ -5446,9 +5362,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
sceneX,
sceneY,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@@ -5625,9 +5539,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@@ -5685,9 +5597,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const frame = newFrameElement({
@@ -5710,52 +5620,6 @@ 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 {
@@ -5813,10 +5677,6 @@ 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,
@@ -5989,62 +5849,33 @@ class App extends React.Component<AppProps, AppState> {
!this.state.editingElement &&
this.state.activeEmbeddable?.state !== "active"
) {
const dragOffset = {
x: pointerCoords.x - pointerDownState.origin.x,
y: pointerCoords.y - pointerDownState.origin.y,
};
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 originalElements = [
...pointerDownState.originalElements.values(),
const [dragDistanceX, dragDistanceY] = [
Math.abs(pointerCoords.x - pointerDownState.origin.x),
Math.abs(pointerCoords.y - pointerDownState.origin.y),
];
// 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,
dragOffset,
dragX,
dragY,
lockDirection,
dragDistanceX,
dragDistanceY,
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
@@ -6085,21 +5916,15 @@ class App extends React.Component<AppProps, AppState> {
groupIdMap,
element,
);
const origElement = pointerDownState.originalElements.get(
element.id,
)!;
mutateElement(duplicatedElement, {
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,
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,
);
mutateElement(duplicatedElement, {
x: duplicatedElement.x + (originDragX - dragX),
y: duplicatedElement.y + (originDragY - dragY),
});
nextElements.push(duplicatedElement);
elementsToAppend.push(element);
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
@@ -6125,8 +5950,6 @@ class App extends React.Component<AppProps, AppState> {
oldIdToDuplicatedId,
);
this.scene.replaceAllElements(nextSceneElements);
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
}
return;
}
@@ -6343,7 +6166,6 @@ class App extends React.Component<AppProps, AppState> {
isResizing,
isRotating,
} = this.state;
this.setState({
isResizing: false,
isRotating: false,
@@ -6358,14 +6180,8 @@ 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({
@@ -6589,9 +6405,7 @@ class App extends React.Component<AppProps, AppState> {
) {
// remove invisible element which was added in onPointerDown
this.scene.replaceAllElements(
this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== draggingElement.id),
this.scene.getElementsIncludingDeleted().slice(0, -1),
);
this.setState({
draggingElement: null,
@@ -6813,17 +6627,17 @@ class App extends React.Component<AppProps, AppState> {
}
if (isEraserActive(this.state)) {
const draggedDistance = distance2d(
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
this.lastPointerUpEvent!.clientX,
this.lastPointerUpEvent!.clientY,
this.lastPointerDown!.clientX,
this.lastPointerDown!.clientY,
this.lastPointerUp!.clientX,
this.lastPointerUp!.clientY,
);
if (draggedDistance === 0) {
const scenePointer = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent!.clientX,
clientY: this.lastPointerUpEvent!.clientY,
clientX: this.lastPointerUp!.clientX,
clientY: this.lastPointerUp!.clientY,
},
this.state,
);
@@ -7063,11 +6877,6 @@ 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({
@@ -7084,16 +6893,14 @@ class App extends React.Component<AppProps, AppState> {
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
this.lastPointerUp &&
this.lastPointerDown &&
this.lastPointerUp.timeStamp - this.lastPointerDown.timeStamp < 300 &&
gesture.pointers.size <= 1 &&
isEmbeddableElement(hitElement) &&
this.isEmbeddableCenter(
hitElement,
this.lastPointerUpEvent,
this.lastPointerUp,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
@@ -7902,7 +7709,7 @@ class App extends React.Component<AppProps, AppState> {
shouldResizeFromCenter(event),
);
} else {
let [gridX, gridY] = getGridPoint(
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -7916,33 +7723,6 @@ 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,
@@ -7957,7 +7737,6 @@ class App extends React.Component<AppProps, AppState> {
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
aspectRatio,
this.state.originSnapOffset,
);
this.maybeSuggestBindingForAll([draggingElement]);
@@ -7999,7 +7778,7 @@ class App extends React.Component<AppProps, AppState> {
activeEmbeddable: null,
});
const pointerCoords = pointerDownState.lastCoords;
let [resizeX, resizeY] = getGridPoint(
const [resizeX, resizeY] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@@ -8027,41 +7806,6 @@ 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,
@@ -8077,7 +7821,6 @@ class App extends React.Component<AppProps, AppState> {
resizeY,
pointerDownState.resize.center.x,
pointerDownState.resize.center.y,
this.state,
)
) {
this.maybeSuggestBindingForAll(selectedElements);
@@ -8165,7 +7908,6 @@ class App extends React.Component<AppProps, AppState> {
actionUnlockAllElements,
CONTEXT_MENU_SEPARATOR,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleZenMode,
actionToggleViewMode,
actionToggleStats,
@@ -8312,21 +8054,15 @@ class App extends React.Component<AppProps, AppState> {
if (!x || !y) {
return;
}
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
const pointer = viewportCoordsToSceneCoords(
{ clientX: x, clientY: y },
this.state,
);
if (isNaN(sceneX) || isNaN(sceneY)) {
if (isNaN(pointer.x) || isNaN(pointer.y)) {
// 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(--color-surface-lowest);
--text-color: var(--input-bg-color);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-brand-hover);
--back-color: var(--color-primary-darker);
}
&:active {
--back-color: var(--color-brand-active);
--back-color: var(--color-primary-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-border-outline);
--back-color: transparent;
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-brand-hover);
--border-color: var(--color-brand-hover);
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
}
&:active {
--text-color: var(--color-brand-active);
--border-color: var(--color-brand-active);
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
}
}
}
+1 -16
View File
@@ -19,35 +19,20 @@
}
&__btn {
--background: var(--color-surface-mid);
display: flex;
column-gap: 0.5rem;
align-items: center;
background-color: var(--background);
border: 1px solid var(--default-border-color);
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,7 +165,6 @@ 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"]}
@@ -259,10 +258,6 @@ 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+'")]}
@@ -1,309 +0,0 @@
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;
}
}
}
@@ -1,41 +0,0 @@
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
@@ -1,27 +0,0 @@
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>
);
};
@@ -1,20 +0,0 @@
.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;
}
}
}
+1 -22
View File
@@ -55,7 +55,6 @@ 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;
@@ -78,7 +77,6 @@ interface LayerUIProps {
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
}
const DefaultMainMenu: React.FC<{
@@ -136,7 +134,6 @@ const LayerUI = ({
renderWelcomeScreen,
children,
app,
isCollaborating,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -282,7 +279,7 @@ const LayerUI = ({
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
app={app}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
@@ -291,24 +288,6 @@ 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-brand-hover);
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-brand-active);
background-color: var(--color-primary-darkest);
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ export const MobileMenu = ({
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
app={app}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
+17 -8
View File
@@ -1,18 +1,27 @@
@import "../css/variables.module";
.excalidraw {
--RadioGroup-background: var(--island-bg-color);
--RadioGroup-border: var(--color-surface-high);
--RadioGroup-background: #ffffff;
--RadioGroup-border: var(--color-gray-30);
--RadioGroup-choice-color-off: var(--color-primary);
--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-off-hover: var(--color-primary-darkest);
--RadioGroup-choice-background-off: white;
--RadioGroup-choice-background-off-active: var(--color-gray-20);
--RadioGroup-choice-color-on: var(--color-surface-lowest);
--RadioGroup-choice-color-on: white;
--RadioGroup-choice-background-on: var(--color-primary);
--RadioGroup-choice-background-on-hover: var(--color-brand-hover);
--RadioGroup-choice-background-on-active: var(--color-brand-active);
--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 {
box-sizing: border-box;
+2 -1
View File
@@ -3,7 +3,8 @@
.excalidraw {
.sidebar-trigger {
@include outlineButtonStyles;
@include filledButtonOnCanvas;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
+14 -16
View File
@@ -1,13 +1,15 @@
@import "../css/variables.module";
.excalidraw {
--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-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 {
position: relative;
@@ -26,11 +28,7 @@
&:hover {
background: var(--Switch-track-background);
border: 1px solid var(--Switch-hover-background);
}
&:active {
border: 1px solid var(--Switch-active-background);
border: 1px solid #999999;
}
&.toggled {
@@ -45,11 +43,11 @@
&.disabled {
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-border);
border: 1px solid var(--Switch-disabled-color);
&.toggled {
background: var(--Switch-disabled-toggled-background);
border: 1px solid var(--Switch-disabled-toggled-background);
background: var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-color);
}
}
@@ -94,7 +92,7 @@
}
&.disabled.toggled:before {
background: var(--Switch-disabled-color);
background: var(--color-gray-50);
}
& input {
+21 -12
View File
@@ -1,16 +1,25 @@
@import "../css/variables.module";
.excalidraw {
--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--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 {
&--fullWidth {
@@ -52,7 +61,7 @@
&:active,
&:focus-within {
border-color: var(--ExcTextField--border-active);
border-color: var(--color-primary);
}
}
@@ -98,7 +107,7 @@
&--readonly {
background: var(--ExcTextField--readonly--background);
border-color: var(--ExcTextField--readonly--border);
border-color: transparent;
& input {
color: var(--ExcTextField--readonly--color);
+5 -5
View File
@@ -97,6 +97,10 @@
}
}
// &:hover {
// background-color: var(--button-gray-2);
// }
&:active {
background-color: var(--button-gray-3);
}
@@ -106,6 +110,7 @@
}
&--hide {
// visibility: hidden;
display: none !important;
}
}
@@ -165,10 +170,5 @@
height: var(--lg-icon-size);
}
}
.ToolIcon__LaserPointer .ToolIcon__icon {
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}
-7
View File
@@ -22,19 +22,12 @@
.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 {
@@ -193,8 +193,6 @@ const getRelevantAppStateProps = (
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
});
const areEqual = (
+5 -7
View File
@@ -114,13 +114,11 @@ 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),
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
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),
);
};
+14 -35
View File
@@ -16,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;
@@ -29,7 +29,7 @@
}
.dropdown-menu-container {
background-color: var(--island-bg-color);
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
@@ -40,7 +40,7 @@
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-on-surface);
color: var(--color-gray-100);
width: 100%;
box-sizing: border-box;
font-weight: normal;
@@ -49,7 +49,7 @@
.dropdown-menu-item {
background-color: transparent;
border: 1px solid transparent;
border: 0;
align-items: center;
height: 2rem;
cursor: pointer;
@@ -59,11 +59,6 @@
height: 2.25rem;
}
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
@@ -80,11 +75,6 @@
text-decoration: none;
}
&:active {
background-color: var(--button-hover-bg);
border-color: var(--color-brand-active);
}
svg {
width: 1rem;
height: 1rem;
@@ -103,33 +93,22 @@
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,14 +11,12 @@ 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);
@@ -28,7 +26,7 @@ const DropdownMenuItem = ({
{...rest}
onClick={handleClick}
type="button"
className={getDropdownMenuItemClassName(className, selected)}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
@@ -3,19 +3,15 @@ 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} ${
selected ? `dropdown-menu-item--selected` : ``
}`.trim()}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
>
{children}
</div>
@@ -12,7 +12,6 @@ const DropdownMenuItemLink = ({
children,
onSelect,
className = "",
selected,
...rest
}: {
href: string;
@@ -20,7 +19,6 @@ const DropdownMenuItemLink = ({
children: React.ReactNode;
shortcut?: string;
className?: string;
selected?: boolean;
onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
@@ -31,7 +29,7 @@ const DropdownMenuItemLink = ({
href={href}
target="_blank"
rel="noreferrer"
className={getDropdownMenuItemClassName(className, selected)}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
>
+2 -7
View File
@@ -6,13 +6,8 @@ export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
}>({});
export const getDropdownMenuItemClassName = (
className = "",
selected = false,
) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
selected ? "dropdown-menu-item--selected" : ""
}`.trim();
export const getDropdownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
export const useHandleDropdownMenuItemClick = (
+1 -20
View File
@@ -13,7 +13,7 @@ import clsx from "clsx";
import { Theme } from "../element/types";
import { THEME } from "../constants";
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
@@ -1653,22 +1653,3 @@ export const frameToolIcon = createIcon(
</g>,
tablerIconProps,
);
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>,
20,
);
@@ -14,8 +14,6 @@
--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,7 +43,6 @@ 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: 1px solid transparent;
border: none;
padding: 0.75rem;
@@ -204,7 +204,7 @@
.welcome-screen-menu-item:hover {
text-decoration: none;
background: var(--button-hover-bg);
background: var(--color-gray-10);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
@@ -216,8 +216,7 @@
}
.welcome-screen-menu-item:active {
background: var(--button-hover-bg);
border-color: var(--color-brand-active);
background: var(--color-gray-20);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
@@ -248,7 +247,8 @@
}
.welcome-screen-menu-item:hover {
background-color: var(--color-surface-low);
background: var(--color-gray-85);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
}
@@ -259,6 +259,7 @@
}
.welcome-screen-menu-item:active {
background-color: var(--color-gray-90);
.welcome-screen-menu-item__text {
color: var(--color-gray-10);
}
+2 -17
View File
@@ -444,14 +444,13 @@
}
&:active {
border: 1px solid var(--button-active-border);
border: 1px solid var(--color-primary-darkest);
}
}
.help-icon {
@include outlineButtonStyles;
@include filledButtonOnCanvas;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
@@ -622,20 +621,6 @@
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 {
+23 -45
View File
@@ -12,30 +12,27 @@
--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-on-surface);
--icon-fill-color: var(--color-gray-80);
--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: #ffffff;
--island-bg-color: rgba(255, 255, 255, 0.96);
--keybinding-color: var(--color-gray-40);
--link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
--popup-bg-color: var(--island-bg-color);
--popup-bg-color: #{$oc-white};
--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 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);
--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);
--default-button-size: 2rem;
--default-icon-size: 1rem;
@@ -66,14 +63,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-surface-high);
--sidebar-bg-color: var(--island-bg-color);
--sidebar-border-color: var(--color-gray-20);
--sidebar-bg-color: #fff;
--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-on-surface);
--text-primary-color: var(--color-gray-80);
--color-selection: #6965db;
@@ -135,19 +132,6 @@
--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;
@@ -166,24 +150,29 @@
--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: #232329;
--island-bg-color: #262627;
--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};
--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);
--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);
--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),
@@ -191,6 +180,8 @@
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};
@@ -233,18 +224,5 @@
--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;
}
}
+10 -30
View File
@@ -11,7 +11,7 @@
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon {
--icon-fill-color: var(--color-on-primary-container);
--icon-fill-color: var(--color-primary-darker);
svg {
fill: var(--icon-fill-color);
@@ -23,11 +23,11 @@
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon {
background: var(--color-surface-primary-container);
--keybinding-color: var(--color-on-primary-container);
background: var(--color-primary-light);
--keybinding-color: var(--color-gray-60);
svg {
color: var(--color-on-primary-container);
color: var(--color-primary-darker);
}
}
}
@@ -44,11 +44,7 @@
&:active {
background: var(--button-hover-bg);
border: 1px solid var(--button-active-border);
svg {
color: var(--color-on-primary-container);
}
border: 1px solid var(--color-primary-darkest);
}
}
}
@@ -67,7 +63,7 @@
border-radius: var(--border-radius-lg);
cursor: pointer;
background-color: var(--button-bg, var(--island-bg-color));
color: var(--button-color, var(--color-on-surface));
color: var(--button-color, var(--text-primary-color));
svg {
width: var(--button-width, var(--lg-icon-size));
@@ -92,38 +88,22 @@
}
&.active {
background-color: var(
--button-selected-bg,
var(--color-surface-primary-container)
);
border-color: var(
--button-selected-border,
var(--color-surface-primary-container)
);
background-color: var(--button-selected-bg, var(--color-primary-light));
border-color: var(--button-selected-border, var(--color-primary-light));
&:hover {
background-color: var(
--button-selected-hover-bg,
var(--color-surface-primary-container)
var(--color-primary-light)
);
}
svg {
color: var(--button-color, var(--color-on-primary-container));
color: var(--button-color, var(--color-primary-darker));
}
}
}
@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
@@ -1,103 +0,0 @@
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;
}
};
+9 -19
View File
@@ -34,13 +34,13 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureTextElement,
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
@@ -67,7 +67,6 @@ export const AllowedExcalidrawActiveTools: Record<
frame: true,
embeddable: true,
hand: true,
laser: false,
};
export type RestoredDataState = {
@@ -93,8 +92,7 @@ const repairBinding = (binding: PointBinding | null) => {
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
T extends Required<Omit<ExcalidrawElement, "customData">> & {
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -160,9 +158,6 @@ const restoreElementWithProperties = <
locked: element.locked ?? false,
};
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element) {
base.customData = element.customData;
}
@@ -193,7 +188,7 @@ const restoreElement = (
fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily);
}
const text = (typeof element.text === "string" && element.text) || "";
const text = element.text ?? "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
@@ -208,7 +203,11 @@ const restoreElement = (
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureTextElement(element, { text }).baseline;
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@@ -222,17 +221,9 @@ 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, {
@@ -307,7 +298,6 @@ 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;
};
/**
+1 -14
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,19 +674,6 @@ 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,
+33 -43
View File
@@ -6,22 +6,23 @@ 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 { isFrameElement } from "./typeChecks";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
selectedElements: NonDeletedExcalidrawElement[],
offset: { x: number; y: number },
pointerX: number,
pointerY: number,
lockDirection: boolean = false,
distanceX: number = 0,
distanceY: number = 0,
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
@@ -43,11 +44,12 @@ 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)
@@ -67,11 +69,12 @@ export const dragSelectedElements = (
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords(
lockDirection,
distanceX,
distanceY,
pointerDownState,
textElement,
offset,
snapOffset,
gridSize,
);
}
}
@@ -82,40 +85,31 @@ export const dragSelectedElements = (
};
const updateElementCoords = (
lockDirection: boolean,
distanceX: number,
distanceY: number,
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"],
offset: { x: number; y: number },
) => {
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;
}
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;
}
mutateElement(element, {
x: nextX,
y: nextY,
x,
y,
});
};
export const getDragOffsetXY = (
selectedElements: NonDeletedExcalidrawElement[],
x: number,
@@ -139,10 +133,6 @@ 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) {
@@ -183,8 +173,8 @@ export const dragNewElement = (
if (width !== 0 && height !== 0) {
mutateElement(draggingElement, {
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
x: newX,
y: newY,
width,
height,
});
+27 -47
View File
@@ -2,8 +2,7 @@ import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import { t } from "../i18n";
import { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks";
@@ -18,6 +17,7 @@ type EmbeddedLink =
| ({
aspectRatio: { w: number; h: number };
warning?: string;
sandbox?: { allowSameOrigin?: boolean };
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
@@ -29,20 +29,20 @@ const embeddedLinkCache = new Map<string, EmbeddedLink>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
const RE_GH_GIST_EMBED =
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
/https?:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)\.js["']/i;
// not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com/;
const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
const RE_VALTOWN =
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
@@ -137,46 +137,27 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
}
if (RE_TWITTER.test(link)) {
let ret: EmbeddedLink;
// assume embed code
if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 480, h: 480 },
};
// assume regular tweet url
} else {
ret = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
};
}
// the embed srcdoc still supports twitter.com domain only
link = link.replace(/\bx.com\b/, "twitter.com");
const ret: EmbeddedLink = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
sandbox: { allowSameOrigin: true },
};
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) {
let ret: EmbeddedLink;
// assume embed code
if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 550, h: 720 },
};
// assume regular url
} else {
ret = {
type: "document",
srcdoc: () =>
createSrcDoc(`
const ret: EmbeddedLink = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${link}.js"></script>
<style type="text/css">
* { margin: 0px; }
@@ -184,9 +165,8 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
`),
aspectRatio: { w: 550, h: 720 },
};
}
aspectRatio: { w: 550, h: 720 },
};
embeddedLinkCache.set(link, ret);
return ret;
}
@@ -304,8 +284,8 @@ export const extractSrc = (htmlString: string): string => {
}
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 2) {
return gistMatch[1];
if (gistMatch && gistMatch.length === 3) {
return `https://gist.github.com/${gistMatch[1]}/${gistMatch[2]}`;
}
const match = htmlString.match(RE_GENERIC_EMBED);
+5 -26
View File
@@ -6,21 +6,12 @@ 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
@@ -31,8 +22,6 @@ 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)
@@ -81,7 +70,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
}
}
if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue;
}
}
@@ -89,7 +77,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value;
didChange = true;
key in oldUpdates && (increment = true);
}
}
if (!didChange) {
@@ -105,11 +92,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element);
}
if (increment) {
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (informMutation) {
Scene.getScene(element)?.informMutation();
@@ -123,8 +108,6 @@ 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") {
@@ -136,7 +119,6 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue;
}
didChange = true;
key in oldUpdates && (increment = true);
}
}
@@ -144,9 +126,6 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element;
}
if (!increment) {
return { ...element, ...updates };
}
return {
...element,
...updates,
@@ -161,8 +140,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
*
* NOTE: does not trigger re-render.
*/
export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
element: T,
export const bumpVersion = (
element: Mutable<ExcalidrawElement>,
version?: ExcalidrawElement["version"],
) => {
element.version = (version ?? element.version) + 1;
+24 -39
View File
@@ -15,7 +15,12 @@ import {
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "../element/types";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
@@ -25,9 +30,9 @@ import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getContainerElement,
measureTextElement,
measureText,
normalizeText,
wrapTextElement,
wrapText,
getBoundTextMaxWidth,
getDefaultLineHeight,
} from "./textElement";
@@ -40,21 +45,6 @@ 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">,
@@ -68,8 +58,6 @@ export type ElementConstructorOpts = MarkOptional<
| "version"
| "versionNonce"
| "link"
| "subtype"
| "customData"
| "strokeStyle"
| "fillStyle"
| "strokeColor"
@@ -105,10 +93,8 @@ 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,
@@ -142,11 +128,8 @@ export const newElement = (
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
export const newEmbeddableElement = (
opts: {
@@ -213,12 +196,10 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text);
const metrics = measureTextElement(
{ ...opts, fontSize, fontFamily, lineHeight },
{
text,
customData: opts.customData,
},
const metrics = measureText(
text,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -263,9 +244,7 @@ const getAdjustedDimensions = (
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureTextElement(element, {
text: nextText,
});
} = measureText(nextText, getFontString(element), element.lineHeight);
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -274,7 +253,11 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId
) {
const prevMetrics = measureTextElement(element);
const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
@@ -330,9 +313,11 @@ export const refreshTextDimensions = (
}
const container = getContainerElement(textElement);
if (container) {
text = wrapTextElement(textElement, getBoundTextMaxWidth(container), {
text = wrapText(
text,
});
getFontString(textElement),
getBoundTextMaxWidth(container),
);
}
const dimensions = getAdjustedDimensions(textElement, text);
return { text, ...dimensions };
+19 -29
View File
@@ -41,7 +41,7 @@ import {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
import { AppState, Point, PointerDownState } from "../types";
import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
@@ -51,7 +51,7 @@ import {
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureTextElement,
measureText,
getBoundTextMaxHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
@@ -79,7 +79,6 @@ export const transformElements = (
pointerY: number,
centerX: number,
centerY: number,
appState: AppState,
) => {
if (selectedElements.length === 1) {
const [element] = selectedElements;
@@ -224,7 +223,11 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureTextElement(element, { fontSize: nextFontSize });
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
@@ -463,8 +466,8 @@ export const resizeSingleElement = (
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
eleNewWidth = Math.max(eleNewWidth, minWidth);
eleNewHeight = Math.max(eleNewHeight, minHeight);
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
}
}
@@ -505,11 +508,8 @@ export const resizeSingleElement = (
}
}
const flipX = eleNewWidth < 0;
const flipY = eleNewHeight < 0;
// Flip horizontally
if (flipX) {
if (eleNewWidth < 0) {
if (transformHandleDirection.includes("e")) {
newTopLeft[0] -= Math.abs(newBoundsWidth);
}
@@ -517,9 +517,8 @@ export const resizeSingleElement = (
newTopLeft[0] += Math.abs(newBoundsWidth);
}
}
// Flip vertically
if (flipY) {
if (eleNewHeight < 0) {
if (transformHandleDirection.includes("s")) {
newTopLeft[1] -= Math.abs(newBoundsHeight);
}
@@ -543,20 +542,10 @@ 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,
@@ -573,11 +562,16 @@ 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: nextX,
y: nextY,
x: newOrigin[0],
y: newOrigin[1],
points: rescaledPoints,
};
@@ -686,10 +680,6 @@ 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> = {
+1 -2
View File
@@ -12,7 +12,6 @@ export const showSelectedShapeActions = (
(appState.editingElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) ||
appState.activeTool.type !== "hand"))) ||
getSelectedElements(elements, appState).length),
);
-222
View File
@@ -1,222 +0,0 @@
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;
};
+20 -39
View File
@@ -1,4 +1,3 @@
import { getSubtypeMethods, SubtypeMethods } from "./subtypes";
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
@@ -37,30 +36,6 @@ 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
@@ -93,24 +68,22 @@ export const redrawTextBoundingBox = (
if (container) {
maxWidth = getBoundTextMaxWidth(container, textElement);
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
boundTextUpdates.text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
}
const metrics = measureTextElement(textElement, {
text: boundTextUpdates.text,
});
const metrics = measureText(
boundTextUpdates.text,
getFontString(textElement),
textElement.lineHeight,
);
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,
@@ -223,9 +196,17 @@ export const handleBindTextResize = (
(transformHandleType !== "n" && transformHandleType !== "s")
) {
if (text) {
text = wrapTextElement(textElement, maxWidth);
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
}
const metrics = measureTextElement(textElement, { text });
const metrics = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
nextHeight = metrics.height;
nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
+27 -17
View File
@@ -17,6 +17,7 @@ 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
@@ -952,11 +953,11 @@ describe("textWysiwyg", () => {
editor.blur();
// should center align horizontally and vertically by default
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
85,
4.999999999999986,
4.5,
]
`);
@@ -976,7 +977,7 @@ describe("textWysiwyg", () => {
editor.blur();
// should left align horizontally and bottom vertically after resize
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
15,
@@ -998,11 +999,11 @@ describe("textWysiwyg", () => {
editor.blur();
// should right align horizontally and top vertically after resize
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
374.99999999999994,
-535.0000000000001,
375,
-539,
]
`);
});
@@ -1048,7 +1049,7 @@ describe("textWysiwyg", () => {
expect(rectangle.height).toBe(75);
expect(textElement.fontSize).toBe(20);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
shift: true,
});
expect(rectangle.width).toBe(200);
@@ -1188,8 +1189,8 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello");
editor.blur();
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBeCloseTo(155, 8);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(156);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle);
@@ -1199,12 +1200,9 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBeCloseTo(155, 8);
expect(rectangle.height).toBe(156);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
155,
8,
);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
});
it("should reset the container height cache when font properties updated", async () => {
@@ -1512,16 +1510,28 @@ describe("textWysiwyg", () => {
});
});
it("should bump the version of a labeled arrow when the label is updated", async () => {
it("should bump the version of labelled arrow when label updated", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
await UI.editText(arrow, "Hello");
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
const { version } = arrow;
await UI.editText(arrow, "Hello\nworld!");
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello\nworld!");
editor.blur();
expect(arrow.version).toEqual(version + 1);
});
+8 -59
View File
@@ -26,7 +26,6 @@ import {
getContainerElement,
getTextElementAngle,
getTextWidth,
measureText,
normalizeText,
redrawTextBoundingBox,
wrapText,
@@ -44,10 +43,8 @@ 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,
@@ -65,8 +62,7 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2;
}
const offset = offsetX !== 0 ? ` translate(${offsetX}px, 0px)` : "";
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)${offset}`;
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
};
const originalContainerCache: {
@@ -101,14 +97,6 @@ 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,
@@ -168,24 +156,11 @@ export const textWysiwyg = ({
const container = getContainerElement(updatedTextElement);
let maxWidth = 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);
let maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width;
// Set to element height by default since that's
// what is going to be used for unbounded text
const textElementHeight = Math.max(updatedTextElement.height, maxHeight);
const textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
@@ -271,35 +246,13 @@ 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
@@ -317,15 +270,13 @@ export const textWysiwyg = ({
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight,
width: `${Math.min(textElementWidth, maxWidth)}px`,
width: `${textElementWidth}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
...transformOrigin,
transform: getTransform(
offsetX,
transformWidth,
updatedTextElement.height,
textElementWidth,
textElementHeight,
getTextElementAngle(updatedTextElement),
appState,
maxWidth,
@@ -383,7 +334,6 @@ export const textWysiwyg = ({
whiteSpace,
overflowWrap: "break-word",
boxSizing: "content-box",
...getEditorStyle(element),
});
editable.value = element.originalText;
updateWysiwygStyle();
@@ -634,7 +584,7 @@ export const textWysiwyg = ({
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate();
editable.remove();
@@ -751,7 +701,6 @@ export const textWysiwyg = ({
passive: false,
capture: true,
});
window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
-1
View File
@@ -65,7 +65,6 @@ type _ExcalidrawElementBase = Readonly<{
updated: number;
link: string | null;
locked: boolean;
subtype?: string;
customData?: Record<string, any>;
}>;
-116
View File
@@ -436,121 +436,5 @@ 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);
});
});
});
+64 -110
View File
@@ -14,7 +14,7 @@ import {
getBoundTextElement,
getContainerElement,
} from "./element/textElement";
import { arrayToMap } from "./utils";
import { arrayToMap, findIndex } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
@@ -452,125 +452,90 @@ 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 { 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>(),
},
);
const _elementsToAdd: ExcalidrawElement[] = [];
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
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,
)) {
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
for (const element of elementsToAdd) {
_elementsToAdd.push(element);
const boundTextElement = getBoundTextElement(element);
if (
boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
if (boundTextElement) {
_elementsToAdd.push(boundTextElement);
}
}
const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
const allElementsIndex = allElements.reduce(
(acc: Record<string, number>, element, index) => {
acc[element.id] = index;
return acc;
},
{},
);
const nextElements: ExcalidrawElement[] = [];
const frameIndex = allElementsIndex[frame.id];
// need to be calculated before the mutation below occurs
const leftFrameBoundaryIndex = findIndex(
allElements,
(e) => e.frameId === frame.id,
);
const processedElements = new Set<ExcalidrawElement["id"]>();
const existingFrameChildren = allElements.filter(
(element) => element.frameId === frame.id,
);
for (const element of allElements) {
if (processedElements.has(element.id)) {
continue;
}
const addedFrameChildren_left: ExcalidrawElement[] = [];
const addedFrameChildren_right: ExcalidrawElement[] = [];
processedElements.add(element.id);
for (const element of omitGroupsContainingFrames(
allElements,
_elementsToAdd,
)) {
if (element.frameId !== frame.id && !isFrameElement(element)) {
if (allElementsIndex[element.id] > frameIndex) {
addedFrameChildren_right.push(element);
} else {
addedFrameChildren_left.push(element);
}
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,
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
continue;
}
nextElements.push(element);
}
for (const element of finalElementsToAdd) {
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
}
const frameElement = allElements[frameIndex];
const nextFrameChildren = addedFrameChildren_left
.concat(existingFrameChildren)
.concat(addedFrameChildren_right);
const nextFrameChildrenMap = nextFrameChildren.reduce(
(acc: Record<string, boolean>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
const nextOtherElements_left = allElements
.slice(0, leftFrameBoundaryIndex >= 0 ? leftFrameBoundaryIndex : frameIndex)
.filter((element) => !nextFrameChildrenMap[element.id]);
const nextOtherElement_right = allElements
.slice(frameIndex + 1)
.filter((element) => !nextFrameChildrenMap[element.id]);
const nextElements = nextOtherElements_left
.concat(nextFrameChildren)
.concat([frameElement])
.concat(nextOtherElement_right);
return nextElements;
};
@@ -740,17 +705,6 @@ 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,7 +21,6 @@ export const CODES = {
V: "KeyV",
Z: "KeyZ",
R: "KeyR",
S: "KeyS",
} as const;
export const KEYS = {
-2
View File
@@ -164,7 +164,6 @@
"darkMode": "Dark mode",
"lightMode": "Light mode",
"zenMode": "Zen mode",
"objectsSnapMode": "Snap to objects",
"exitZenMode": "Exit zen mode",
"cancel": "Cancel",
"clear": "Clear",
@@ -236,7 +235,6 @@
"eraser": "Eraser",
"frame": "Frame tool",
"embeddable": "Web Embed",
"laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools"
},
+1 -41
View File
@@ -1,4 +1,4 @@
import { rangeIntersection, rangesOverlap, rotate } from "./math";
import { rotate } from "./math";
describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
@@ -13,43 +13,3 @@ 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,36 +472,3 @@ 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,28 +11,6 @@ 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.1",
"version": "0.16.2",
"main": "main.js",
"types": "types/packages/excalidraw/index.d.ts",
"files": [
-7
View File
@@ -22,12 +22,5 @@ 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,7 +31,6 @@ import {
InteractiveCanvasAppState,
} from "../types";
import { getDefaultAppState } from "../appState";
import { getSubtypeMethods } from "../element/subtypes";
import {
BOUND_TEXT_PADDING,
FRAME_STYLE,
@@ -265,12 +264,6 @@ 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":
@@ -904,11 +897,6 @@ 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,7 +67,6 @@ import {
EXTERNAL_LINK_IMG,
getLinkHandleFromCoords,
} from "../element/Hyperlink";
import { renderSnaps } from "./renderSnaps";
import {
isEmbeddableElement,
isFrameElement,
@@ -721,8 +720,6 @@ const _renderInteractiveScene = ({
context.restore();
}
renderSnaps(context, appState);
// Reset zoom
context.restore();
-189
View File
@@ -1,189 +0,0 @@
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,7 +11,6 @@ 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.
@@ -90,26 +89,6 @@ 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
@@ -331,17 +331,12 @@ 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,
@@ -368,7 +363,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -530,14 +524,12 @@ 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,
@@ -561,7 +553,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -675,7 +666,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`] = `6`;
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 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = `
{
@@ -732,14 +723,12 @@ 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,
@@ -763,7 +752,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1051,7 +1039,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`] = `12`;
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 to front' in context menu brings element to front > [end of test] appState 1`] = `
{
@@ -1108,14 +1096,12 @@ 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,
@@ -1139,7 +1125,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1427,7 +1412,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`] = `12`;
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 'Copy styles' in context menu copies styles > [end of test] appState 1`] = `
{
@@ -1484,14 +1469,12 @@ 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,
@@ -1515,7 +1498,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1629,7 +1611,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`] = `6`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = `
{
@@ -1686,14 +1668,12 @@ 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,
@@ -1715,7 +1695,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -1868,7 +1847,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`] = `7`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `8`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = `
{
@@ -1925,14 +1904,12 @@ 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,
@@ -1956,7 +1933,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -2172,7 +2148,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`] = `7`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `8`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = `
{
@@ -2229,14 +2205,12 @@ 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,
@@ -2265,7 +2239,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -2564,7 +2537,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`] = `12`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = `
{
@@ -2621,14 +2594,12 @@ 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,
@@ -2652,7 +2623,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -3446,7 +3416,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`] = `19`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `20`;
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = `
{
@@ -3503,14 +3473,12 @@ 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,
@@ -3534,7 +3502,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -3822,7 +3789,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`] = `11`;
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 to back' in context menu sends element to back > [end of test] appState 1`] = `
{
@@ -3879,14 +3846,12 @@ 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,
@@ -3910,7 +3875,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -4198,7 +4162,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`] = `11`;
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 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = `
{
@@ -4255,14 +4219,12 @@ 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,
@@ -4289,7 +4251,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -4657,7 +4618,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`] = `13`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `14`;
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = `
{
@@ -4990,14 +4951,12 @@ 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,
@@ -5024,7 +4983,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -5240,7 +5198,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`] = `12`;
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 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = `
{
@@ -5573,14 +5531,12 @@ 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,
@@ -5609,7 +5565,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -5908,7 +5863,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`] = `13`;
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 context menu for canvas > [end of test] appState 1`] = `
{
@@ -5995,19 +5950,6 @@ 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",
@@ -6093,17 +6035,12 @@ 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,
@@ -6125,7 +6062,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -6495,14 +6431,12 @@ 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,
@@ -6526,7 +6460,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -6872,17 +6805,12 @@ 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,
@@ -6906,7 +6834,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
@@ -7104,6 +7031,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`] = `6`;
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 2`] = `6`;
File diff suppressed because it is too large Load Diff
-1
View File
@@ -87,7 +87,6 @@ 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": true,
"isDeleted": false,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
"originalText": "",
"originalText": "test",
"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": 2,
"versionNonce": Any<Number>,
"version": 1,
"versionNonce": 0,
"verticalAlign": "top",
"width": 100,
"x": 0,
-3
View File
@@ -86,15 +86,12 @@ 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(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
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(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
@@ -112,7 +112,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
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(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
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(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
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(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
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(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
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(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
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(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
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(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
+1 -27
View File
@@ -16,14 +16,6 @@ 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,
@@ -40,16 +32,6 @@ 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) => {
@@ -112,7 +94,6 @@ export class API {
angle?: number;
id?: string;
isDeleted?: boolean;
frameId?: ExcalidrawElement["id"];
groupIds?: string[];
// generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"];
@@ -131,8 +112,6 @@ 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"]
@@ -161,10 +140,6 @@ export class API {
const appState = h?.state || getDefaultAppState();
const custom = maybeGetSubtypeProps({
subtype: rest.subtype,
customData: rest.customData,
});
const base: Omit<
ExcalidrawGenericElement,
| "id"
@@ -176,13 +151,12 @@ 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:
+42 -237
View File
@@ -1,37 +1,13 @@
import type { Point } from "../../types";
import type {
import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
ExcalidrawArrowElement,
ExcalidrawRectangleElement,
ExcalidrawEllipseElement,
ExcalidrawDiamondElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
} from "../../element/types";
import {
getTransformHandles,
getTransformHandlesFromCoords,
OMIT_SIDES_FOR_FRAME,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
TransformHandleType,
type TransformHandle,
type TransformHandleDirection,
} from "../../element/transformHandles";
import { KEYS } from "../../keys";
import { type ToolName } from "../queries/toolQueries";
import { fireEvent, GlobalTestState, screen } from "../test-utils";
import { ToolName } from "../queries/toolQueries";
import { fireEvent, GlobalTestState } from "../test-utils";
import { mutateElement } from "../../element/mutateElement";
import { API } from "./api";
import {
isFrameElement,
isLinearElement,
isFreeDrawElement,
isTextElement,
} from "../../element/typeChecks";
import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
import { rotatePoint } from "../../math";
const { h } = window;
@@ -110,29 +86,6 @@ export class Keyboard {
};
}
const getElementPointForSelection = (element: ExcalidrawElement): Point => {
const { x, y, width, height, angle } = element;
const target: Point = [
x +
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
y,
];
let center: Point;
if (isLinearElement(element)) {
const bounds = getElementPointsCoords(element, element.points);
center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2];
} else {
center = [x + width / 2, y + height / 2];
}
if (isTextElement(element)) {
return center;
}
return rotatePoint(target, center, angle);
};
export class Pointer {
public clientX = 0;
public clientY = 0;
@@ -246,120 +199,31 @@ export class Pointer {
elements: ExcalidrawElement | ExcalidrawElement[],
) {
API.clearSelection();
Keyboard.withModifierKeys({ shift: true }, () => {
elements = Array.isArray(elements) ? elements : [elements];
elements.forEach((element) => {
this.reset();
this.click(...getElementPointForSelection(element));
this.click(element.x, element.y);
});
});
this.reset();
}
clickOn(element: ExcalidrawElement) {
this.reset();
this.click(...getElementPointForSelection(element));
this.click(element.x, element.y);
this.reset();
}
doubleClickOn(element: ExcalidrawElement) {
this.reset();
this.doubleClick(...getElementPointForSelection(element));
this.doubleClick(element.x, element.y);
this.reset();
}
}
const mouse = new Pointer("mouse");
const transform = (
element: ExcalidrawElement | ExcalidrawElement[],
handle: TransformHandleType,
mouseMove: [deltaX: number, deltaY: number],
keyboardModifiers: KeyboardModifiers = {},
) => {
const elements = Array.isArray(element) ? element : [element];
mouse.select(elements);
let handleCoords: TransformHandle | undefined;
if (elements.length === 1) {
handleCoords = getTransformHandles(elements[0], h.state.zoom, "mouse")[
handle
];
} else {
const [x1, y1, x2, y2] = getCommonBounds(elements);
const isFrameSelected = elements.some(isFrameElement);
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
h.state.zoom,
"mouse",
isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
);
handleCoords = transformHandles[handle];
}
if (!handleCoords) {
throw new Error(`There is no "${handle}" handle for this selection`);
}
const clientX = handleCoords[0] + handleCoords[2] / 2;
const clientY = handleCoords[1] + handleCoords[3] / 2;
Keyboard.withModifierKeys(keyboardModifiers, () => {
mouse.reset();
mouse.down(clientX, clientY);
mouse.move(mouseMove[0], mouseMove[1]);
mouse.up();
});
};
const proxy = <T extends ExcalidrawElement>(
element: T,
): typeof element & {
/** Returns the actual, current element from the elements array, instead of
the proxy */
get(): typeof element;
} => {
return new Proxy(
{},
{
get(target, prop) {
const currentElement = h.elements.find(
({ id }) => id === element.id,
) as any;
if (prop === "get") {
if (currentElement.hasOwnProperty("get")) {
throw new Error(
"trying to get `get` test property, but ExcalidrawElement seems to define its own",
);
}
return () => currentElement;
}
return currentElement[prop];
},
},
) as any;
};
/** Tools that can be used to draw shapes */
type DrawingToolName = Exclude<ToolName, "lock" | "selection" | "eraser">;
type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
? ExcalidrawLinearElement
: T extends "arrow"
? ExcalidrawArrowElement
: T extends "text"
? ExcalidrawTextElement
: T extends "rectangle"
? ExcalidrawRectangleElement
: T extends "ellipse"
? ExcalidrawEllipseElement
: T extends "diamond"
? ExcalidrawDiamondElement
: ExcalidrawElement;
export class UI {
static clickTool = (toolName: ToolName) => {
fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
@@ -382,10 +246,6 @@ export class UI {
fireEvent.click(element);
};
static clickByTitle = (title: string) => {
fireEvent.click(screen.getByTitle(title));
};
/**
* Creates an Excalidraw element, and returns a proxy that wraps it so that
* accessing props will return the latest ones from the object existing in
@@ -395,17 +255,16 @@ export class UI {
* If you need to get the actual element, not the proxy, call `get()` method
* on the proxy object.
*/
static createElement<T extends DrawingToolName>(
static createElement<T extends ToolName>(
type: T,
{
position = 0,
x = position,
y = position,
size = 10,
width: initialWidth = size,
height: initialHeight = initialWidth,
width = size,
height = width,
angle = 0,
points: initialPoints,
}: {
position?: number;
x?: number;
@@ -414,46 +273,25 @@ export class UI {
width?: number;
height?: number;
angle?: number;
points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never;
} = {},
): Element<T> & {
): (T extends "arrow" | "line" | "freedraw"
? ExcalidrawLinearElement
: T extends "text"
? ExcalidrawTextElement
: ExcalidrawElement) & {
/** Returns the actual, current element from the elements array, instead
of the proxy */
get(): Element<T>;
get(): T extends "arrow" | "line" | "freedraw"
? ExcalidrawLinearElement
: T extends "text"
? ExcalidrawTextElement
: ExcalidrawElement;
} {
const width = initialWidth ?? initialHeight ?? size;
const height = initialHeight ?? size;
const points: Point[] = initialPoints ?? [
[0, 0],
[width, height],
];
UI.clickTool(type);
if (type === "text") {
mouse.reset();
mouse.click(x, y);
} else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => {
mouse.reset();
mouse.click(x + point[0], y + point[1]);
});
Keyboard.keyPress(KEYS.ESCAPE);
} else if (type === "freedraw" && points.length > 2) {
const firstPoint = points[0];
mouse.reset();
mouse.down(x + firstPoint[0], y + firstPoint[1]);
points
.slice(1)
.forEach((point) => mouse.moveTo(x + point[0], y + point[1]));
mouse.upAt();
Keyboard.keyPress(KEYS.ESCAPE);
} else {
mouse.reset();
mouse.down(x, y);
mouse.reset();
mouse.up(x + width, y + height);
}
mouse.reset();
mouse.down(x, y);
mouse.reset();
mouse.up(x + (width ?? height ?? size), y + (height ?? size));
const origElement = h.elements[h.elements.length - 1] as any;
@@ -461,58 +299,25 @@ export class UI {
mutateElement(origElement, { angle });
}
return proxy(origElement);
}
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const openedEditor = document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor =
openedEditor ??
document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
);
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}
fireEvent.input(editor, { target: { value: text } });
await new Promise((resolve) => setTimeout(resolve, 0));
editor.blur();
return isTextElement(element)
? element
: proxy(
h.elements[
h.elements.length - 1
] as ExcalidrawTextElementWithContainer,
);
}
static resize(
element: ExcalidrawElement | ExcalidrawElement[],
handle: TransformHandleDirection,
mouseMove: [deltaX: number, deltaY: number],
keyboardModifiers: KeyboardModifiers = {},
) {
return transform(element, handle, mouseMove, keyboardModifiers);
}
static rotate(
element: ExcalidrawElement | ExcalidrawElement[],
mouseMove: [deltaX: number, deltaY: number],
keyboardModifiers: KeyboardModifiers = {},
) {
return transform(element, "rotation", mouseMove, keyboardModifiers);
return new Proxy(
{},
{
get(target, prop) {
const currentElement = h.elements.find(
(element) => element.id === origElement.id,
) as any;
if (prop === "get") {
if (currentElement.hasOwnProperty("get")) {
throw new Error(
"trying to get `get` test property, but ExcalidrawElement seems to define its own",
);
}
return () => currentElement;
}
return currentElement[prop];
},
},
) as any;
}
static group(elements: ExcalidrawElement[]) {
+71 -9
View File
@@ -16,6 +16,7 @@ import { Point } from "../types";
import { KEYS } from "../keys";
import { LinearElementEditor } from "../element/linearElementEditor";
import { queryByTestId, queryByText } from "@testing-library/react";
import { resize, rotate } from "./utils";
import {
getBoundTextElementPosition,
wrapText,
@@ -938,10 +939,71 @@ describe("Test Linear Elements", () => {
expect(line.boundElements).toBeNull();
});
// TODO fix #7029 and rewrite this test
it.todo(
"should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated",
);
it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => {
createThreePointerLinearElement("arrow", {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
});
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
expect(container.angle).toBe(0);
expect(textElement.angle).toBe(0);
expect(getBoundTextElementPosition(arrow, textElement))
.toMatchInlineSnapshot(`
{
"x": 75,
"y": 60,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
[
20,
20,
105,
80,
55.45893770831013,
45,
]
`);
rotate(container, -35, 55);
expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
expect(textElement.angle).toBe(0);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
{
"x": 21.73926141863671,
"y": 73.31003398390868,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
[
20,
20,
102.41961302274555,
86.49012635273976,
55.45893770831013,
45,
]
`);
});
it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
createThreePointerLinearElement("arrow", {
@@ -980,20 +1042,20 @@ describe("Test Linear Elements", () => {
]
`);
UI.resize(container, "ne", [300, 200]);
resize(container, "ne", [300, 200]);
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
{
"height": 130,
"width": 366.11716195150507,
"width": 367,
}
`);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
{
"x": 271.11716195150507,
"x": 272,
"y": 45,
}
`);
@@ -1007,9 +1069,9 @@ describe("Test Linear Elements", () => {
[
20,
35,
501.11716195150507,
502,
95,
205.4589377083102,
205.9061448421403,
52.5,
]
`);
+4 -4
View File
@@ -43,7 +43,7 @@ describe("move element", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -84,8 +84,8 @@ describe("move element", () => {
// select the second rectangles
new Pointer("mouse").clickOn(rectB);
expect(renderInteractiveScene).toHaveBeenCalledTimes(24);
expect(renderStaticScene).toHaveBeenCalledTimes(19);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(20);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@@ -131,7 +131,7 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
+8 -7
View File
@@ -48,7 +48,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.elements.length).toEqual(0);
});
@@ -63,7 +63,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.elements.length).toEqual(0);
});
@@ -78,7 +78,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.elements.length).toEqual(0);
});
});
@@ -110,8 +110,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@@ -153,8 +153,9 @@ describe("multi point mode in linear elements", () => {
fireEvent.keyDown(document, {
key: KEYS.ENTER,
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@@ -55,15 +55,10 @@ exports[`exportToSvg > with default arguments 1`] = `
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "name",
"objectsSnapModeEnabled": false,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
@@ -85,7 +80,6 @@ exports[`exportToSvg > with default arguments 1`] = `
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
+118 -968
View File
File diff suppressed because it is too large Load Diff
-81
View File
@@ -1,81 +0,0 @@
import ReactDOM from "react-dom";
import { render } from "./test-utils";
import { reseed } from "../random";
import { UI } from "./helpers/ui";
import { Excalidraw } from "../packages/excalidraw/index";
import { expect } from "vitest";
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
beforeEach(() => {
localStorage.clear();
reseed(7);
});
test("unselected bound arrow updates when rotating its target element", async () => {
await render(<Excalidraw />);
const rectangle = UI.createElement("rectangle", {
width: 200,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -80,
y: 50,
width: 70,
height: 0,
});
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
UI.rotate(rectangle, [60, 36], { shift: true });
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.height).toBeCloseTo(0);
});
test("unselected bound arrows update when rotating their target elements", async () => {
await render(<Excalidraw />);
const ellipse = UI.createElement("ellipse", {
x: 0,
y: 80,
width: 300,
height: 120,
});
const ellipseArrow = UI.createElement("arrow", {
position: 0,
width: 40,
height: 80,
});
const text = UI.createElement("text", {
position: 220,
});
await UI.editText(text, "test");
const textArrow = UI.createElement("arrow", {
x: 360,
y: 300,
width: -100,
height: -40,
});
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
expect(textArrow.endBinding?.elementId).toEqual(text.id);
UI.rotate([ellipse, text], [-82, 23], { shift: true });
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
expect(ellipseArrow.x).toEqual(0);
expect(ellipseArrow.y).toEqual(0);
expect(ellipseArrow.points[0]).toEqual([0, 0]);
expect(ellipseArrow.points[1][0]).toBeCloseTo(48.5, 1);
expect(ellipseArrow.points[1][1]).toBeCloseTo(126.5, 1);
expect(textArrow.endBinding?.elementId).toEqual(text.id);
expect(textArrow.x).toEqual(360);
expect(textArrow.y).toEqual(300);
expect(textArrow.points[0]).toEqual([0, 0]);
expect(textArrow.points[1][0]).toBeCloseTo(-94, 1);
expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 1);
});
+6 -6
View File
@@ -310,7 +310,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -342,7 +342,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -374,7 +374,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -419,7 +419,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -463,7 +463,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -480,7 +480,7 @@ describe("tool locking & selection", () => {
expect(h.state.activeTool.locked).toBe(true);
for (const { value } of Object.values(SHAPES)) {
if (value !== "image" && value !== "selection" && value !== "eraser") {
if (value !== "image" && value !== "selection") {
const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true);
}
-395
View File
@@ -1,395 +0,0 @@
import { vi } from "vitest";
import {
SubtypeLoadedCb,
SubtypeRecord,
SubtypeMethods,
SubtypePrepFn,
addSubtypeMethods,
ensureSubtypesLoadedForElements,
getSubtypeMethods,
getSubtypeNames,
} from "../element/subtypes";
import { render } from "./test-utils";
import { API } from "./helpers/api";
import { Excalidraw, FONT_FAMILY } from "../packages/excalidraw/index";
import {
ExcalidrawElement,
ExcalidrawTextElement,
FontString,
} from "../element/types";
import { getFontString } from "../utils";
import * as textElementUtils from "../element/textElement";
import { isTextElement } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
const MW = 200;
const TWIDTH = 200;
const THEIGHT = 20;
const TBASELINE = 0;
const FONTSIZE = 20;
const DBFONTSIZE = 40;
const TRFONTSIZE = 60;
const test2: SubtypeRecord = {
subtype: "test2",
parents: ["text"],
};
const test3: SubtypeRecord = {
subtype: "test3",
parents: ["text", "line"],
};
const prepareNullSubtype = function () {
const methods = {} as SubtypeMethods;
methods.clean = cleanTest2ElementUpdate;
methods.measureText = measureTest2;
methods.wrapText = wrapTest2;
return { methods };
} as SubtypePrepFn;
const cleanTest2ElementUpdate = function (updates) {
const oldUpdates = {};
for (const key in updates) {
if (key !== "fontFamily") {
(oldUpdates as any)[key] = (updates as any)[key];
}
}
(updates as any).fontFamily = FONT_FAMILY.Cascadia;
return oldUpdates;
} as SubtypeMethods["clean"];
let test2Loaded = false;
const ensureLoadedTest2: SubtypeMethods["ensureLoaded"] = async (callback) => {
test2Loaded = true;
if (onTest2Loaded) {
onTest2Loaded((el) => isTextElement(el) && el.subtype === test2.subtype);
}
if (callback) {
callback();
}
};
const measureTest2: SubtypeMethods["measureText"] = function (element, next) {
const text = next?.text ?? element.text;
const customData = next?.customData ?? {};
const fontSize = customData.triple
? TRFONTSIZE
: next?.fontSize ?? element.fontSize;
const fontFamily = element.fontFamily;
const fontString = getFontString({ fontSize, fontFamily });
const lineHeight = element.lineHeight;
const metrics = textElementUtils.measureText(text, fontString, lineHeight);
const width = test2Loaded
? metrics.width * 2
: Math.max(metrics.width - 10, 0);
const height = test2Loaded
? metrics.height * 2
: Math.max(metrics.height - 5, 0);
return { width, height, baseline: 1 };
};
const wrapTest2: SubtypeMethods["wrapText"] = function (
element,
maxWidth,
next,
) {
const text = next?.text ?? element.originalText;
if (next?.customData && next?.customData.triple === true) {
return `${text.split(" ").join("\n")}\nHELLO WORLD.`;
}
if (next?.fontSize === DBFONTSIZE) {
return `${text.split(" ").join("\n")}\nHELLO World.`;
}
return `${text.split(" ").join("\n")}\nHello world.`;
};
let onTest2Loaded: SubtypeLoadedCb | undefined;
const prepareTest2Subtype = function (onSubtypeLoaded) {
const methods = {
clean: cleanTest2ElementUpdate,
ensureLoaded: ensureLoadedTest2,
measureText: measureTest2,
wrapText: wrapTest2,
} as SubtypeMethods;
onTest2Loaded = onSubtypeLoaded;
return { methods };
} as SubtypePrepFn;
const prepareTest3Subtype = function () {
const methods = {} as SubtypeMethods;
return { methods };
} as SubtypePrepFn;
const { h } = window;
describe("subtype registration", () => {
it("should check for invalid subtype or parents", async () => {
await render(<Excalidraw />, {});
// Define invalid subtype records
const null1 = {} as SubtypeRecord;
const null2 = { subtype: "" } as SubtypeRecord;
const null3 = { subtype: "null" } as SubtypeRecord;
const null4 = { subtype: "null", parents: [] } as SubtypeRecord;
// Try registering the invalid subtypes
const prepN1 = API.addSubtype(null1, prepareNullSubtype);
const prepN2 = API.addSubtype(null2, prepareNullSubtype);
const prepN3 = API.addSubtype(null3, prepareNullSubtype);
const prepN4 = API.addSubtype(null4, prepareNullSubtype);
// Verify the guards in `prepareSubtype` worked
expect(prepN1).toStrictEqual({ methods: {} });
expect(prepN2).toStrictEqual({ methods: {} });
expect(prepN3).toStrictEqual({ methods: {} });
expect(prepN4).toStrictEqual({ methods: {} });
});
it("should return subtype methods correctly", async () => {
// Check initial registration works
let prep2 = API.addSubtype(test2, prepareTest2Subtype);
expect(prep2.methods).toStrictEqual({
clean: cleanTest2ElementUpdate,
ensureLoaded: ensureLoadedTest2,
measureText: measureTest2,
wrapText: wrapTest2,
});
// Check repeat registration fails
prep2 = API.addSubtype(test2, prepareNullSubtype);
expect(prep2.methods).toStrictEqual({
clean: cleanTest2ElementUpdate,
ensureLoaded: ensureLoadedTest2,
measureText: measureTest2,
wrapText: wrapTest2,
});
// Check initial registration works
let prep3 = API.addSubtype(test3, prepareTest3Subtype);
expect(prep3.methods).toStrictEqual({});
// Check repeat registration fails
prep3 = API.addSubtype(test3, prepareNullSubtype);
expect(prep3.methods).toStrictEqual({});
});
});
describe("subtypes", () => {
it("should correctly register", async () => {
const subtypes = getSubtypeNames();
expect(subtypes).toContain(test2.subtype);
expect(subtypes).toContain(test3.subtype);
});
it("should return subtype methods", async () => {
expect(getSubtypeMethods(undefined)).toBeUndefined();
const test2Methods = getSubtypeMethods(test2.subtype);
expect(test2Methods?.clean).toStrictEqual(cleanTest2ElementUpdate);
expect(test2Methods?.ensureLoaded).toStrictEqual(ensureLoadedTest2);
expect(test2Methods?.measureText).toStrictEqual(measureTest2);
expect(test2Methods?.render).toBeUndefined();
expect(test2Methods?.renderSvg).toBeUndefined();
expect(test2Methods?.wrapText).toStrictEqual(wrapTest2);
});
it("should not overwrite subtype methods", async () => {
addSubtypeMethods(test2.subtype, {});
addSubtypeMethods(test3.subtype, { clean: cleanTest2ElementUpdate });
const test2Methods = getSubtypeMethods(test2.subtype);
expect(test2Methods?.measureText).toStrictEqual(measureTest2);
expect(test2Methods?.wrapText).toStrictEqual(wrapTest2);
const test3Methods = getSubtypeMethods(test3.subtype);
expect(test3Methods?.clean).toBeUndefined();
});
it("should apply to ExcalidrawElements", async () => {
const elements = [
API.createElement({ type: "text", id: "A", subtype: test3.subtype }),
API.createElement({ type: "line", id: "B", subtype: test3.subtype }),
];
await render(<Excalidraw />, { localStorageData: { elements } });
elements.forEach((el) => expect(el.subtype).toBe(test3.subtype));
});
it("should enforce prop value restrictions", async () => {
const elements = [
API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
fontFamily: FONT_FAMILY.Virgil,
}),
API.createElement({
type: "text",
id: "B",
fontFamily: FONT_FAMILY.Virgil,
}),
];
await render(<Excalidraw />, { localStorageData: { elements } });
elements.forEach((el) => {
if (el.subtype === test2.subtype) {
expect(el.fontFamily).toBe(FONT_FAMILY.Cascadia);
} else {
expect(el.fontFamily).toBe(FONT_FAMILY.Virgil);
}
});
});
it("should consider enforced prop values in version increments", async () => {
const rectA = API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
fontFamily: FONT_FAMILY.Virgil,
fontSize: 10,
});
const rectB = API.createElement({
type: "text",
id: "B",
subtype: test2.subtype,
fontFamily: FONT_FAMILY.Virgil,
fontSize: 10,
});
// Initial element creation checks
expect(rectA.fontFamily).toBe(FONT_FAMILY.Cascadia);
expect(rectB.fontFamily).toBe(FONT_FAMILY.Cascadia);
expect(rectA.version).toBe(1);
expect(rectB.version).toBe(1);
// Check that attempting to set prop values not permitted by the subtype
// doesn't increment element versions
mutateElement(rectA, { fontFamily: FONT_FAMILY.Helvetica });
mutateElement(rectB, { fontFamily: FONT_FAMILY.Helvetica, fontSize: 20 });
expect(rectA.version).toBe(1);
expect(rectB.version).toBe(2);
// Check that element versions don't increment when creating new elements
// while attempting to use prop values not permitted by the subtype
// First check based on `rectA` (unsuccessfully mutated)
const rectC = newElementWith(rectA, { fontFamily: FONT_FAMILY.Virgil });
const rectD = newElementWith(rectA, {
fontFamily: FONT_FAMILY.Virgil,
fontSize: 15,
});
expect(rectC.version).toBe(1);
expect(rectD.version).toBe(2);
// Then check based on `rectB` (successfully mutated)
const rectE = newElementWith(rectB, { fontFamily: FONT_FAMILY.Virgil });
const rectF = newElementWith(rectB, {
fontFamily: FONT_FAMILY.Virgil,
fontSize: 15,
});
expect(rectE.version).toBe(2);
expect(rectF.version).toBe(3);
});
it("should call custom text methods", async () => {
const testString = "A quick brown fox jumps over the lazy dog.";
const elements = [
API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
text: testString,
fontSize: FONTSIZE,
}),
];
await render(<Excalidraw />, { localStorageData: { elements } });
const mockMeasureText = (text: string, font: FontString) => {
if (text === testString) {
let multiplier = 1;
if (font.includes(`${DBFONTSIZE}`)) {
multiplier = 2;
}
if (font.includes(`${TRFONTSIZE}`)) {
multiplier = 3;
}
const width = multiplier * TWIDTH;
const height = multiplier * THEIGHT;
const baseline = multiplier * TBASELINE;
return { width, height, baseline };
}
return { width: 1, height: 0, baseline: 0 };
};
vi.spyOn(textElementUtils, "measureText").mockImplementation(
mockMeasureText,
);
elements.forEach((el) => {
if (isTextElement(el)) {
// First test with `ExcalidrawTextElement.text`
const metrics = textElementUtils.measureTextElement(el);
expect(metrics).toStrictEqual({
width: TWIDTH - 10,
height: THEIGHT - 5,
baseline: TBASELINE + 1,
});
const wrappedText = textElementUtils.wrapTextElement(el, MW);
expect(wrappedText).toEqual(
`${testString.split(" ").join("\n")}\nHello world.`,
);
// Now test with modified text in `next`
let next: {
text?: string;
fontSize?: number;
customData?: Record<string, any>;
} = {
text: "Hello world.",
};
const nextMetrics = textElementUtils.measureTextElement(el, next);
expect(nextMetrics).toStrictEqual({ width: 0, height: 0, baseline: 1 });
const nextWrappedText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextWrappedText).toEqual("Hello\nworld.\nHello world.");
// Now test modified fontSizes in `next`
next = { fontSize: DBFONTSIZE };
const nextFM = textElementUtils.measureTextElement(el, next);
expect(nextFM).toStrictEqual({
width: 2 * TWIDTH - 10,
height: 2 * THEIGHT - 5,
baseline: 2 * TBASELINE + 1,
});
const nextFWrText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextFWrText).toEqual(
`${testString.split(" ").join("\n")}\nHELLO World.`,
);
// Now test customData in `next`
next = { customData: { triple: true } };
const nextCD = textElementUtils.measureTextElement(el, next);
expect(nextCD).toStrictEqual({
width: 3 * TWIDTH - 10,
height: 3 * THEIGHT - 5,
baseline: 3 * TBASELINE + 1,
});
const nextCDWrText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextCDWrText).toEqual(
`${testString.split(" ").join("\n")}\nHELLO WORLD.`,
);
}
});
});
});
describe("subtype loading", () => {
let elements: ExcalidrawElement[];
beforeEach(async () => {
const testString = "A quick brown fox jumps over the lazy dog.";
elements = [
API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
text: testString,
}),
];
await render(<Excalidraw />, { localStorageData: { elements } });
h.elements = elements;
});
it("should redraw text bounding boxes", async () => {
h.setState({ selectedElementIds: { A: true } });
const el = h.elements[0] as ExcalidrawTextElement;
expect(el.width).toEqual(100);
expect(el.height).toEqual(100);
ensureSubtypesLoadedForElements(elements);
expect(el.width).toEqual(TWIDTH * 2);
expect(el.height).toEqual(THEIGHT * 2);
expect(el.baseline).toEqual(TBASELINE + 1);
});
});
+48
View File
@@ -0,0 +1,48 @@
import {
getTransformHandles,
TransformHandleDirection,
} from "../element/transformHandles";
import { ExcalidrawElement } from "../element/types";
import { Keyboard, KeyboardModifiers, Pointer } from "./helpers/ui";
const mouse = new Pointer("mouse");
const { h } = window;
export const resize = (
element: ExcalidrawElement,
handleDir: TransformHandleDirection,
mouseMove: [number, number],
keyboardModifiers: KeyboardModifiers = {},
) => {
mouse.select(element);
const handle = getTransformHandles(element, h.state.zoom, "mouse")[
handleDir
]!;
const clientX = handle[0] + handle[2] / 2;
const clientY = handle[1] + handle[3] / 2;
Keyboard.withModifierKeys(keyboardModifiers, () => {
mouse.reset();
mouse.down(clientX, clientY);
mouse.move(mouseMove[0], mouseMove[1]);
mouse.up();
});
};
export const rotate = (
element: ExcalidrawElement,
deltaX: number,
deltaY: number,
keyboardModifiers: KeyboardModifiers = {},
) => {
mouse.select(element);
const handle = getTransformHandles(element, h.state.zoom, "mouse").rotation!;
const clientX = handle[0] + handle[2] / 2;
const clientY = handle[1] + handle[3] / 2;
Keyboard.withModifierKeys(keyboardModifiers, () => {
mouse.reset();
mouse.down(clientX, clientY);
mouse.move(clientX + deltaX, clientY + deltaY);
mouse.up();
});
};

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