Compare commits

...

27 Commits

Author SHA1 Message Date
7f0b97a163 fix tests 2025-07-23 19:09:12 +10:00
358f687b4f lint 2025-07-23 18:42:28 +10:00
4053ced148 switch to default selection tool after pasting 2025-07-23 18:39:46 +10:00
691ece340f paste to center on touch screen 2025-07-23 18:15:35 +10:00
1489b6a740 set to default selection tool after unlocking tool 2025-07-23 18:10:50 +10:00
2132c9ac44 double click to add text when using default selection tool 2025-07-23 17:58:01 +10:00
285134405b return to default selection tool after creation 2025-07-23 17:55:08 +10:00
5c449839ba toggle between laser and default selection 2025-07-23 17:46:14 +10:00
edc894fd04 finalize to default selection tool 2025-07-23 17:43:39 +10:00
4e20c8d722 if default lasso, close lasso toggle 2025-07-23 17:31:07 +10:00
0118f9b1b0 return to default tool after eraser toggle 2025-07-23 17:30:08 +10:00
385cb347bb reset to default tool after clearing out the canvas 2025-07-23 17:27:36 +10:00
c182115c92 return to default selection tool after deletion 2025-07-23 17:09:33 +10:00
d29c8e7d32 render according to default selection tool 2025-07-23 17:00:38 +10:00
19d434c366 add default selection tool 2025-07-23 17:00:08 +10:00
9eaf4385c5 add mobile lasso icon 2025-07-21 18:05:31 +10:00
69676fb325 improve mobile dection 2025-07-18 12:23:18 +02:00
9b644169ae alternative: drag after selection on PCs 2025-07-18 10:22:41 +10:00
85dc55c718 alternatvie: keep lasso drag to only mobile 2025-07-17 17:29:32 +10:00
a1b95c47a7 test: snapshots 2025-07-14 20:56:12 +10:00
f1c9dc08ce fix: alt+cmd getting stuck 2025-07-14 20:34:32 +10:00
b7762e5a92 fix: lasso dragging should snap too 2025-07-14 18:58:29 +10:00
993eaa361b alternative ux: drag with lasso right away 2025-07-14 18:54:54 +10:00
5337480583 feat: drag, resize, and rotate after selecting in lasso 2025-07-11 14:17:28 +10:00
2d127f8c22 docs: fix broken update scene button example in docs (#9726)
fix: update scene example in docs
2025-07-08 19:29:44 +05:30
4eadb891f8 fix(toast): prevent toast from re-rendering and resetting timeout Fixes #9714 (#9715)
* Update App.tsx

* fix: lint

---------

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2025-07-03 17:07:26 +10:00
258605d1d5 chore: release multiple packages (#9698) 2025-06-30 12:19:15 +02:00
28 changed files with 588 additions and 286 deletions

View File

@ -24,4 +24,4 @@ jobs:
- name: Auto release
run: |
yarn add @actions/core -W
yarn autorelease
yarn release --tag=next --non-interactive

View File

@ -1,55 +0,0 @@
name: Auto release excalidraw preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core -W
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"

View File

@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
## Releasing
### Create a test release
You can create a test release by posting the below comment in your pull request:
```bash
@excalibot trigger release
```
Once the version is released `@excalibot` will post a comment with the release version.
### Creating a production release
To release the next stable version follow the below steps:
```bash
yarn prerelease:excalidraw
yarn release --tag=latest --version=0.19.0
```
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
The next step is to run the `release` script:
```bash
yarn release:excalidraw
```
This will publish the package.
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.

View File

@ -33,6 +33,7 @@ const ExcalidrawScope = {
initialData,
useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
};
export default ExcalidrawScope;

View File

@ -3,7 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"build:packages": "yarn --cwd ../../ build:packages",
"build:workspace": "yarn build:packages && yarn copy:assets",
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",

View File

@ -17,6 +17,6 @@
"build": "vite build",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
"build:packages": "yarn --cwd ../../ build:packages"
}
}

View File

@ -1,5 +1,5 @@
{
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:package && yarn build"
"buildCommand": "yarn build:packages && yarn build"
}

View File

@ -52,13 +52,17 @@
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
"build:common": "yarn --cwd ./packages/common build:esm",
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@ -76,9 +80,10 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"release": "node scripts/release.js",
"release:test": "node scripts/release.js --tag=test",
"release:next": "node scripts/release.js --tag=next",
"release:latest": "node scripts/release.js --tag=latest",
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"

View File

@ -1,6 +1,6 @@
{
"name": "@excalidraw/common",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/common/src/*.d.ts"
"types": "./dist/types/common/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [

View File

@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/.test(navigator.platform) ||
/iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent.toLowerCase(),
) ||
/android|ios|ipod|blackberry|windows phone/i.test(
navigator.platform.toLowerCase(),
);
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;

View File

@ -1,6 +1,6 @@
{
"name": "@excalidraw/element",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/element/src/*.d.ts"
"types": "./dist/types/element/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
@ -52,5 +55,9 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
}
}

View File

@ -121,7 +121,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
? { ...appState.activeTool, type: app.defaultSelectionTool }
: appState.activeTool,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool",
label: "toolBar.eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: "selection",
type: app.defaultSelectionTool,
}),
lastActiveToolBeforeEraser: null,
});
@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso",
icon: LassoIcon,
trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.defaultSelectionTool !== "lasso";
},
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];

View File

@ -291,7 +291,9 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }),
activeTool: updateActiveTool(appState, {
type: app.defaultSelectionTool,
}),
multiElement: null,
activeEmbeddable: null,
selectedLinearElement: null,

View File

@ -240,13 +240,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: "selection",
type: app.defaultSelectionTool,
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
type: app.defaultSelectionTool,
});
}

View File

@ -63,6 +63,7 @@ import {
laserPointerToolIcon,
MagicIcon,
LassoIcon,
LassoIconMobile,
} from "./icons";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
@ -295,15 +296,31 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso";
const lassoToolSelected =
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
// we'll need to update SHAPES to swap lasso and selection
const _SHAPES =
app.defaultSelectionTool === "lasso"
? ([
{
value: "lasso",
icon: LassoIconMobile,
key: KEYS.L,
numericKey: KEYS["1"],
fillable: true,
},
...SHAPES.slice(1),
] as const)
: SHAPES;
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
{_SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
@ -418,14 +435,16 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
{app.defaultSelectionTool !== "lasso" && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
)}
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>

View File

@ -100,6 +100,7 @@ import {
randomInteger,
CLASSES,
Emitter,
isMobile,
} from "@excalidraw/common";
import {
@ -593,6 +594,10 @@ class App extends React.Component<AppProps, AppState> {
* insert to DOM before user initially scrolls to them) */
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
private handleToastClose = () => {
this.setToast(null);
};
private elementsPendingErasure: ElementsPendingErasure = new Set();
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
@ -645,9 +650,14 @@ class App extends React.Component<AppProps, AppState> {
>();
onRemoveEventListenersEmitter = new Emitter<[]>();
defaultSelectionTool: "selection" | "lasso" = "selection";
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = this.isMobileOrTablet()
? ("lasso" as const)
: ("selection" as const);
const {
excalidrawAPI,
viewModeEnabled = false,
@ -1707,14 +1717,16 @@ class App extends React.Component<AppProps, AppState> {
/>
</ElementCanvasButtons>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
onClose={this.handleToastClose}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
@ -2331,6 +2343,7 @@ class App extends React.Component<AppProps, AppState> {
};
}
const scene = restore(initialData, null, null, { repairBindings: true });
const activeTool = scene.appState.activeTool;
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
@ -2340,8 +2353,13 @@ class App extends React.Component<AppProps, AppState> {
// with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool:
scene.appState.activeTool.type === "image"
? { ...scene.appState.activeTool, type: "selection" }
activeTool.type === "image" ||
activeTool.type === "lasso" ||
activeTool.type === "selection"
? {
...activeTool,
type: this.defaultSelectionTool,
}
: scene.appState.activeTool,
isLoading: false,
toast: this.state.toast,
@ -2380,6 +2398,15 @@ class App extends React.Component<AppProps, AppState> {
}
};
private isMobileOrTablet = (): boolean => {
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const hasCoarsePointer =
"matchMedia" in window && window.matchMedia("(pointer: coarse)").matches;
const isTouchMobile = hasTouch && hasCoarsePointer;
return isMobile || isTouchMobile;
};
private isMobileBreakpoint = (width: number, height: number) => {
return (
width < MQ_MAX_WIDTH_PORTRAIT ||
@ -3098,7 +3125,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({
elements,
files: data.files || null,
position: "cursor",
position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste,
});
} else if (data.text) {
@ -3116,7 +3143,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({
elements,
files,
position: "cursor",
position: this.isMobileOrTablet() ? "center" : "cursor",
});
return;
@ -3176,7 +3203,7 @@ class App extends React.Component<AppProps, AppState> {
}
this.addTextFromPaste(data.text, isPlainPaste);
}
this.setActiveTool({ type: "selection" });
this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault();
},
);
@ -3320,7 +3347,7 @@ class App extends React.Component<AppProps, AppState> {
}
},
);
this.setActiveTool({ type: "selection" });
this.setActiveTool({ type: this.defaultSelectionTool }, true);
if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, {
@ -3566,7 +3593,7 @@ class App extends React.Component<AppProps, AppState> {
...updateActiveTool(
this.state,
prevState.activeTool.locked
? { type: "selection" }
? { type: this.defaultSelectionTool }
: prevState.activeTool,
),
locked: !prevState.activeTool.locked,
@ -4555,7 +4582,7 @@ 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" });
this.setActiveTool({ type: this.defaultSelectionTool });
} else {
this.setActiveTool({ type: "laser" });
}
@ -5397,7 +5424,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// we should only be able to double click when mode is selection
if (this.state.activeTool.type !== "selection") {
if (this.state.activeTool.type !== this.defaultSelectionTool) {
return;
}
@ -6007,6 +6034,7 @@ class App extends React.Component<AppProps, AppState> {
if (
hasDeselectedButton ||
(this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser")
) {
@ -6178,7 +6206,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (
this.state.activeTool.type !== "lasso" ||
selectedElements.length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
@ -6287,7 +6320,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
}
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
@ -6296,7 +6334,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
}
}
@ -6558,11 +6601,119 @@ class App extends React.Component<AppProps, AppState> {
}
if (this.state.activeTool.type === "lasso") {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
const hitSelectedElement =
pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element);
const isMobileOrTablet = this.isMobileOrTablet();
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement
) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
pointerDownState.drag.blockDragging = !isMobileOrTablet;
}
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
if (
isMobileOrTablet &&
pointerDownState.hit.element &&
!hitSelectedElement
) {
this.setState((prevState) => {
const nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[pointerDownState.hit.element!.id]: true,
};
const previouslySelectedElements: ExcalidrawElement[] = [];
Object.keys(prevState.selectedElementIds).forEach((id) => {
const element = this.scene.getElement(id);
element && previouslySelectedElements.push(element);
});
const hitElement = pointerDownState.hit.element!;
// if hitElement is frame-like, deselect all of its elements
// if they are selected
if (isFrameLikeElement(hitElement)) {
getFrameChildren(previouslySelectedElements, hitElement.id).forEach(
(element) => {
delete nextSelectedElementIds[element.id];
},
);
} else if (hitElement.frameId) {
// if hitElement is in a frame and its frame has been selected
// disable selection for the given element
if (nextSelectedElementIds[hitElement.frameId]) {
delete nextSelectedElementIds[hitElement.id];
}
} else {
// hitElement is neither a frame nor an element in a frame
// but since hitElement could be in a group with some frames
// this means selecting hitElement will have the frames selected as well
// because we want to keep the invariant:
// - frames and their elements are not selected at the same time
// we deselect elements in those frames that were previously selected
const groupIds = hitElement.groupIds;
const framesInGroups = new Set(
groupIds
.flatMap((gid) =>
getElementsInGroup(this.scene.getNonDeletedElements(), gid),
)
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id),
);
if (framesInGroups.size > 0) {
previouslySelectedElements.forEach((element) => {
if (element.frameId && framesInGroups.has(element.frameId)) {
// deselect element and groups containing the element
delete nextSelectedElementIds[element.id];
element.groupIds
.flatMap((gid) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
gid,
),
)
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
}
});
}
}
return {
...selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: nextSelectedElementIds,
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
showHyperlinkPopup:
hitElement.link || isEmbeddableElement(hitElement)
? "info"
: false,
};
});
pointerDownState.hit.wasAddedToSelection = true;
}
} else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState);
} else if (
@ -6942,6 +7093,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred: false,
offset: null,
origin: { ...origin },
blockDragging: false,
},
eventListeners: {
onMove: null,
@ -7017,7 +7169,10 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>,
pointerDownState: PointerDownState,
): boolean => {
if (this.state.activeTool.type === "selection") {
if (
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso"
) {
const elements = this.scene.getNonDeletedElements();
const elementsMap = this.scene.getNonDeletedElementsMap();
const selectedElements = this.scene.getSelectedElements(this.state);
@ -7229,7 +7384,18 @@ class App extends React.Component<AppProps, AppState> {
// on CMD/CTRL, drill down to hit element regardless of groups etc.
if (event[KEYS.CTRL_OR_CMD]) {
if (event.altKey) {
// ctrl + alt means we're lasso selecting
// ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool
// Close any open dialogs that might interfere with lasso selection
if (this.state.openDialog?.name === "elementLinkSelector") {
this.setOpenDialog(null);
}
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
this.setActiveTool({ type: "lasso", fromSelection: true });
return false;
}
if (!this.state.selectedElementIds[hitElement.id]) {
@ -7450,7 +7616,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas);
if (!this.state.activeTool.locked) {
this.setState({
activeTool: updateActiveTool(this.state, { type: "selection" }),
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
});
}
};
@ -8251,11 +8419,13 @@ class App extends React.Component<AppProps, AppState> {
(hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor &&
this.state.activeTool.type !== "lasso"
!pointerDownState.drag.blockDragging
) {
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.every((element) => element.locked)) {
if (
selectedElements.length > 0 &&
selectedElements.every((element) => element.locked)
) {
return;
}
@ -8276,6 +8446,17 @@ class App extends React.Component<AppProps, AppState> {
// if elements should be deselected on pointerup
pointerDownState.drag.hasOccurred = true;
// Clear lasso trail when starting to drag selected elements with lasso tool
// Only clear if we're actually dragging (not during lasso selection)
if (
this.state.activeTool.type === "lasso" &&
selectedElements.length > 0 &&
pointerDownState.drag.hasOccurred &&
!this.state.activeTool.fromSelection
) {
this.lassoTrail.endPath();
}
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen)
// Checking for editingTextElement to avoid jump while editing on mobile #6503
@ -8872,6 +9053,7 @@ class App extends React.Component<AppProps, AppState> {
): (event: PointerEvent) => void {
return withBatchedUpdates((childEvent: PointerEvent) => {
this.removePointer(childEvent);
pointerDownState.drag.blockDragging = false;
if (pointerDownState.eventListeners.onMove) {
pointerDownState.eventListeners.onMove.flush();
}
@ -9131,7 +9313,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(this.state, {
type: "selection",
type: this.defaultSelectionTool,
}),
selectedElementIds: makeNextSelectedElementIds(
{
@ -9743,7 +9925,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
newElement: null,
suggestedBindings: [],
activeTool: updateActiveTool(this.state, { type: "selection" }),
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
});
} else {
this.setState({
@ -10037,7 +10221,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState(
{
newElement: null,
activeTool: updateActiveTool(this.state, { type: "selection" }),
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
},
() => {
this.actionManager.executeAction(actionFinalize);

View File

@ -314,6 +314,28 @@ export const LassoIcon = createIcon(
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
);
export const LassoIconMobile = createIcon(
<g>
<path
d="M2.5 12
C2.5 6.5, 8 4.5, 15 7.5
C21 9, 22 12, 20 14.5
C18 17, 11.5 17.5, 7 16
C4 15.2, 2.5 13.5, 2.5 12Z"
fill="none"
stroke="black"
strokeDasharray="2 2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>,
{
width: 24,
height: 24,
strokeWidth: 1.5,
},
);
// tabler-icons: square
export const RectangleIcon = createIcon(
<g strokeWidth="1.5">

View File

@ -66,12 +66,22 @@
"last 1 safari version"
]
},
"repository": "https://github.com/excalidraw/excalidraw",
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
},
"peerDependencies": {
"react": "^17.0.2 || ^18.2.0 || ^19.0.0",
"react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0"
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
"@excalidraw/random-username": "1.1.0",
@ -124,12 +134,5 @@
"harfbuzzjs": "0.3.6",
"jest-diff": "29.7.0",
"typescript": "4.9.4"
},
"repository": "https://github.com/excalidraw/excalidraw",
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
}
}

View File

@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
selectedElements: NonDeletedExcalidrawElement[];
}) => {
if (event) {
// Allow snapping for lasso tool when dragging selected elements
// but not during lasso selection phase
const isLassoDragging =
app.state.activeTool.type === "lasso" &&
app.state.selectedElementsAreBeingDragged;
return (
app.state.activeTool.type !== "lasso" &&
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] &&

View File

@ -3692,14 +3692,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1116226695,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 23633383,
"versionNonce": 81784553,
"width": 20,
"x": 20,
"y": 30,
@ -3724,14 +3724,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,

View File

@ -733,6 +733,8 @@ export type AppClassProperties = {
onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"];
defaultSelectionTool: "selection" | "lasso";
};
export type PointerDownState = Readonly<{
@ -782,6 +784,10 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication.
origin: { x: number; y: number };
// Whether to block drag after lasso selection
// this is meant to be used to block dragging after lasso selection on PCs
// until the next pointer down
blockDragging: boolean;
};
// We need to have these in the state so that we can unsubscribe them
eventListeners: {

View File

@ -1,6 +1,6 @@
{
"name": "@excalidraw/math",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/math/src/index.d.ts",
"main": "./dist/prod/index.js",
@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/math/src/*.d.ts"
"types": "./dist/types/math/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
@ -56,5 +59,8 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0"
}
}

View File

@ -1,71 +0,0 @@
const { exec, execSync } = require("child_process");
const fs = require("fs");
const core = require("@actions/core");
const excalidrawDir = `${__dirname}/../packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const isPreview = process.argv.slice(2)[0] === "preview";
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const publish = () => {
const tag = isPreview ? "preview" : "next";
try {
execSync(`yarn --frozen-lockfile`);
execSync(`yarn run build:esm`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
console.info(`Published ${pkg.name}@${tag}🎉`);
core.setOutput(
"result",
`**Preview version has been shipped** :rocket:
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
);
} catch (error) {
core.setOutput("result", "package couldn't be published :warning:!");
console.error(error);
process.exit(1);
}
};
// get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) {
console.error(error);
core.setOutput("result", ":warning: Package couldn't be published!");
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const excalidrawPackageFiles = changedFiles.filter((file) => {
return (
file.indexOf("packages/excalidraw") >= 0 ||
file.indexOf("buildPackage.js") > 0
);
});
if (!excalidrawPackageFiles.length) {
console.info("Skipping release as no valid diff found");
core.setOutput("result", "Skipping release as no valid diff found");
process.exit(0);
}
// update package.json
let version = `${pkg.version}-${getShortCommitHash()}`;
// update readme
if (isPreview) {
// use pullNumber-commithash as the version for preview
const pullRequestNumber = process.argv.slice(3)[0];
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
}
pkg.version = version;
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
console.info("Publish in progress...");
publish();
});

View File

@ -11,12 +11,9 @@ const getConfig = (outdir) => ({
entryNames: "[name]",
assetNames: "[dir]/[name]",
alias: {
"@excalidraw/common": path.resolve(__dirname, "../packages/common/src"),
"@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
"@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
"@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
});
function buildDev(config) {

View File

@ -28,12 +28,9 @@ const getConfig = (outdir) => ({
assetNames: "[dir]/[name]",
chunkNames: "[dir]/[name]-[hash]",
alias: {
"@excalidraw/common": path.resolve(__dirname, "../packages/common/src"),
"@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
"@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
"@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
loader: {
".woff2": "file",
},

View File

@ -1,38 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../packages/excalidraw/`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const prerelease = async (nextVersion) => {
try {
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
console.info("Done!");
} catch (error) {
console.error(error);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
prerelease(nextVersion);

View File

@ -1,28 +1,239 @@
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const excalidrawDir = `${__dirname}/../packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const updateChangelog = require("./updateChangelog");
const publish = () => {
try {
console.info("Installing the dependencies in root folder...");
execSync(`yarn --frozen-lockfile`);
console.info("Installing the dependencies in excalidraw directory...");
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
console.info("Building ESM Package...");
execSync(`yarn run build:esm`, { cwd: excalidrawDir });
console.info("Publishing the package...");
execSync(`yarn --cwd ${excalidrawDir} publish`);
} catch (error) {
console.error(error);
// skipping utils for now, as it has independent release process
const PACKAGES = ["common", "math", "element", "excalidraw"];
const PACKAGES_DIR = path.resolve(__dirname, "../packages");
/**
* Returns the arguments for the release script.
*
* Usage examples:
* - yarn release --help -> prints this help message
* - yarn release -> publishes `@excalidraw` packages with "test" tag and "-[hash]" version suffix
* - yarn release --tag=test -> same as above
* - yarn release --tag=next -> publishes `@excalidraw` packages with "next" tag and version "-[hash]" suffix
* - yarn release --tag=next --non-interactive -> skips interactive prompts (runs on CI/CD), otherwise same as above
* - yarn release --tag=latest --version=0.19.0 -> publishes `@excalidraw` packages with "latest" tag and version "0.19.0" & prepares changelog for the release
*
* @returns [tag, version, nonInteractive]
*/
const getArguments = () => {
let tag = "test";
let version = "";
let nonInteractive = false;
for (const argument of process.argv.slice(2)) {
if (/--help/.test(argument)) {
console.info(`Available arguments:
--tag=<tag> -> (optional) "test" (default), "next" for auto release, "latest" for stable release
--version=<version> -> (optional) for "next" and "test", (required) for "latest" i.e. "0.19.0"
--non-interactive -> (optional) disables interactive prompts`);
console.info(`\nUsage examples:
- yarn release -> publishes \`@excalidraw\` packages with "test" tag and "-[hash]" version suffix
- yarn release --tag=test -> same as above
- yarn release --tag=next -> publishes \`@excalidraw\` packages with "next" tag and version "-[hash]" suffix
- yarn release --tag=next --non-interactive -> skips interactive prompts (runs on CI/CD), otherwise same as above
- yarn release --tag=latest --version=0.19.0 -> publishes \`@excalidraw\` packages with "latest" tag and version "0.19.0" & prepares changelog for the release`);
process.exit(0);
}
if (/--tag=/.test(argument)) {
tag = argument.split("=")[1];
}
if (/--version=/.test(argument)) {
version = argument.split("=")[1];
}
if (/--non-interactive/.test(argument)) {
nonInteractive = true;
}
}
if (tag !== "latest" && tag !== "next" && tag !== "test") {
console.error(`Unsupported tag "${tag}", use "latest", "next" or "test".`);
process.exit(1);
}
if (tag === "latest" && !version) {
console.error("Pass the version to make the latest stable release!");
process.exit(1);
}
if (!version) {
// set the next version based on the excalidraw package version + commit hash
const excalidrawPackageVersion = require(getPackageJsonPath(
"excalidraw",
)).version;
const hash = getShortCommitHash();
if (!excalidrawPackageVersion.includes(hash)) {
version = `${excalidrawPackageVersion}-${hash}`;
} else {
// ensuring idempotency
version = excalidrawPackageVersion;
}
}
console.info(`Running with tag "${tag}" and version "${version}"...`);
return [tag, version, nonInteractive];
};
const validatePackageName = (packageName) => {
if (!PACKAGES.includes(packageName)) {
console.error(`Package "${packageName}" not found!`);
process.exit(1);
}
};
const release = () => {
publish();
console.info(`Published ${pkg.version}!`);
const getPackageJsonPath = (packageName) => {
validatePackageName(packageName);
return path.resolve(PACKAGES_DIR, packageName, "package.json");
};
release();
const updatePackageJsons = (nextVersion) => {
const packageJsons = new Map();
for (const packageName of PACKAGES) {
const pkg = require(getPackageJsonPath(packageName));
pkg.version = nextVersion;
if (pkg.dependencies) {
for (const dependencyName of PACKAGES) {
if (!pkg.dependencies[`@excalidraw/${dependencyName}`]) {
continue;
}
pkg.dependencies[`@excalidraw/${dependencyName}`] = nextVersion;
}
}
packageJsons.set(packageName, `${JSON.stringify(pkg, null, 2)}\n`);
}
// modify once, to avoid inconsistent state
for (const packageName of PACKAGES) {
const content = packageJsons.get(packageName);
fs.writeFileSync(getPackageJsonPath(packageName), content, "utf-8");
}
};
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const askToCommit = (tag, nextVersion) => {
if (tag !== "latest") {
return Promise.resolve();
}
return new Promise((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(
"Do you want to commit these changes to git? (Y/n): ",
(answer) => {
rl.close();
if (answer.toLowerCase() === "y") {
execSync(`git add -u`);
execSync(
`git commit -m "chore: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
} else {
console.warn(
"Skipping commit. Don't forget to commit manually later!",
);
}
resolve();
},
);
});
};
const buildPackages = () => {
console.info("Running yarn install...");
execSync(`yarn --frozen-lockfile`, { stdio: "inherit" });
console.info("Removing existing build artifacts...");
execSync(`yarn rm:build`, { stdio: "inherit" });
for (const packageName of PACKAGES) {
console.info(`Building "@excalidraw/${packageName}"...`);
execSync(`yarn run build:esm`, {
cwd: path.resolve(PACKAGES_DIR, packageName),
stdio: "inherit",
});
}
};
const askToPublish = (tag, version) => {
return new Promise((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(
"Do you want to publish these changes to npm? (Y/n): ",
(answer) => {
rl.close();
if (answer.toLowerCase() === "y") {
publishPackages(tag, version);
} else {
console.info("Skipping publish.");
}
resolve();
},
);
});
};
const publishPackages = (tag, version) => {
for (const packageName of PACKAGES) {
execSync(`yarn publish --tag ${tag}`, {
cwd: path.resolve(PACKAGES_DIR, packageName),
stdio: "inherit",
});
console.info(
`Published "@excalidraw/${packageName}@${tag}" with version "${version}"! 🎉`,
);
}
};
/** main */
(async () => {
const [tag, version, nonInteractive] = getArguments();
buildPackages();
if (tag === "latest") {
await updateChangelog(version);
}
updatePackageJsons(version);
if (nonInteractive) {
publishPackages(tag, version);
} else {
await askToCommit(tag, version);
await askToPublish(tag, version);
}
})();

View File

@ -20,14 +20,16 @@ const headerForType = {
perf: "Performance",
build: "Build",
};
const badCommits = [];
const getCommitHashForLastVersion = async () => {
try {
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
const commitMessage = `"release @excalidraw/excalidraw"`;
const { stdout } = await exec(
`git log --format=format:"%H" --grep=${commitMessage}`,
);
return stdout;
// take commit hash from latest release
return stdout.split(/\r?\n/)[0];
} catch (error) {
console.error(error);
}