Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ed78d4895 | |||
| 5ba2798306 | |||
| b8aae34e32 | |||
| 6317ac16ee | |||
| 9ad75b8375 | |||
| 4c9ad1a22f | |||
| 023895b49b | |||
| eb37be953a | |||
| b4903a7eab | |||
| c6f8ef9ad2 | |||
| 2535d73054 | |||
| dda3affcb0 | |||
| 54c148f390 | |||
| cc8e490c75 | |||
| 9036812b6d | |||
| df25de7e68 | |||
| a3763648fe | |||
| 178eca5828 | |||
| cb33de25f4 | |||
| 37ad85cbaf | |||
| d6a934ed19 | |||
| 416da62138 | |||
| f38f381989 | |||
| e5e07260c6 | |||
| 8492b144b0 | |||
| e46f038132 | |||
| 678dff25ed | |||
| 0cfa53b764 | |||
| cde46793f8 | |||
| 2d127f8c22 | |||
| 4eadb891f8 | |||
| 258605d1d5 | |||
| c141500400 | |||
| 8e27de2cdc | |||
| 0a19c93509 | |||
| 958597dfaa | |||
| 058918f8e5 | |||
| 3f194918e6 | |||
| 93c92d13e9 | |||
| 84e96e9393 | |||
| 320af405e9 | |||
| 60512f13d5 | |||
| f0458cc216 | |||
| 9f3fdf5505 | |||
| f42e1ab64e | |||
| 18808481fd |
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn autorelease
|
||||
yarn release --tag=next --non-interactive
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm install -g yarn && yarn
|
||||
- name: Install Playwright Browsers
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: yarn playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -17,9 +17,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
@@ -27,3 +27,9 @@ dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
FROM node:18 AS build
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,13 +6,14 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN yarn --network-timeout 600000
|
||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN yarn build:app:docker
|
||||
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
|
||||
import "./record";
|
||||
import CustomStats from "./CustomStats";
|
||||
import {
|
||||
Provider,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
isLineSegment,
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { isCurve } from "@excalidraw/math/curve";
|
||||
|
||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
||||
import React from "react";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
|
||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
const renderLine = (
|
||||
@@ -113,10 +115,6 @@ const _debugRenderer = (
|
||||
scale,
|
||||
);
|
||||
|
||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const context = bootstrapCanvas({
|
||||
canvas,
|
||||
scale,
|
||||
@@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||
interface DebugCanvasProps {
|
||||
appState: AppState;
|
||||
scale: number;
|
||||
ref?: React.Ref<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||
const { width, height } = appState;
|
||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||
({ appState, scale }, ref) => {
|
||||
const { width, height } = appState;
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||
ref,
|
||||
() => canvasRef.current,
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={canvasRef}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={ref}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default DebugCanvas;
|
||||
|
||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@@ -258,11 +258,16 @@ export const loadScene = async (
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<title>
|
||||
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
|
||||
</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
@@ -14,7 +16,7 @@
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
"start:test": "yarn && vite --mode test",
|
||||
"start:production": "yarn build && yarn serve",
|
||||
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "yarn build && vite preview --port 5000"
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { isDevEnv } from "@excalidraw/common";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
record: typeof Record;
|
||||
}
|
||||
}
|
||||
|
||||
export class Record {
|
||||
private static recording: boolean = false;
|
||||
private static events: string = "";
|
||||
private static timestamp: number = 0;
|
||||
|
||||
public static get isRecording() {
|
||||
return Record.recording;
|
||||
}
|
||||
|
||||
private static header() {
|
||||
Record.events += " await page.addInitScript(() => {\n";
|
||||
Record.events += " Math.random = () => 0.42;\n\n";
|
||||
|
||||
// Capture LocalStorage, which is essential to re-establish state
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key != null) {
|
||||
const value = JSON.stringify(localStorage.getItem(key));
|
||||
Record.events += ` localStorage.getItem("${key}");\n`;
|
||||
Record.events += ` localStorage.setItem("${key}", ${value});\n`;
|
||||
}
|
||||
}
|
||||
Record.events += " });\n";
|
||||
Record.events += ` await page.setViewportSize({ width: ${window.innerWidth}, height: ${window.innerHeight} });\n`;
|
||||
Record.events += ` await page.goto("http://localhost:3000");\n`;
|
||||
Record.events += ` await page.waitForLoadState("load");\n`;
|
||||
}
|
||||
|
||||
public static restart() {
|
||||
if (!Record.recording) {
|
||||
Record.start();
|
||||
return;
|
||||
}
|
||||
|
||||
Record.events += `});\n\n`;
|
||||
Record.events += `test("${
|
||||
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
|
||||
}", async ({ page }) => {\n`;
|
||||
|
||||
Record.header();
|
||||
}
|
||||
|
||||
public static start() {
|
||||
Record.recording = true;
|
||||
|
||||
// Record header
|
||||
this.header();
|
||||
|
||||
// Set up the events
|
||||
Record.timestamp = performance.now();
|
||||
|
||||
window.addEventListener("mousemove", this.onMouseMove);
|
||||
window.addEventListener("mousedown", this.onMouseDown);
|
||||
window.addEventListener("mouseup", this.onMouseUp);
|
||||
window.addEventListener("keydown", this.onKeyDown);
|
||||
window.addEventListener("keyup", this.onKeyUp);
|
||||
}
|
||||
|
||||
public static stop() {
|
||||
window.removeEventListener("mousemove", this.onMouseMove);
|
||||
window.removeEventListener("mousedown", this.onMouseDown);
|
||||
window.removeEventListener("mouseup", this.onMouseUp);
|
||||
window.removeEventListener("keydown", this.onKeyDown);
|
||||
window.removeEventListener("keyup", this.onKeyUp);
|
||||
Record.recording = false;
|
||||
}
|
||||
|
||||
/// Displays a window as an absolutely positioned DIV with the generated
|
||||
/// events within <pre> tags as formatted JSON, so it can be copied easily.
|
||||
public static showGeneratedEvents() {
|
||||
if (Record.recording) {
|
||||
Record.stop();
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.style.position = "absolute";
|
||||
div.style.top = "10px";
|
||||
div.style.right = "10px";
|
||||
div.style.left = "10px";
|
||||
div.style.height = "60vh";
|
||||
div.style.backgroundColor = "gray";
|
||||
div.style.padding = "10px";
|
||||
div.style.zIndex = "10000";
|
||||
|
||||
const pre = document.createElement("pre");
|
||||
|
||||
let textContent = `import { expect, test } from "@playwright/test";\n\n`;
|
||||
textContent += `test("${
|
||||
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
|
||||
}", async ({ page }) => {\n`;
|
||||
textContent += Record.events;
|
||||
textContent += `});\n`;
|
||||
|
||||
pre.textContent = textContent;
|
||||
//pre.textContent = Record.events;
|
||||
|
||||
pre.style.marginTop = "18px";
|
||||
pre.style.maxHeight = "60vh";
|
||||
pre.style.overflow = "auto";
|
||||
div.appendChild(pre);
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.textContent = "Copy";
|
||||
copyBtn.title = "Copy generated events to clipboard";
|
||||
copyBtn.setAttribute("aria-label", "Copy generated events to clipboard");
|
||||
copyBtn.style.position = "absolute";
|
||||
copyBtn.style.top = "4px";
|
||||
copyBtn.style.left = "4px";
|
||||
copyBtn.style.border = "none";
|
||||
copyBtn.style.background = "transparent";
|
||||
copyBtn.style.fontSize = "12px";
|
||||
copyBtn.style.lineHeight = "1";
|
||||
copyBtn.style.cursor = "pointer";
|
||||
copyBtn.style.padding = "4px 8px";
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const text = pre.textContent ?? "";
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
const orig = copyBtn.textContent;
|
||||
copyBtn.textContent = "Copied";
|
||||
setTimeout(() => (copyBtn.textContent = orig), 1000);
|
||||
} catch {}
|
||||
});
|
||||
div.appendChild(copyBtn);
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.title = "Close";
|
||||
closeBtn.style.position = "absolute";
|
||||
closeBtn.style.top = "4px";
|
||||
closeBtn.style.right = "4px";
|
||||
closeBtn.style.border = "none";
|
||||
closeBtn.style.background = "transparent";
|
||||
closeBtn.style.fontSize = "18px";
|
||||
closeBtn.style.lineHeight = "1";
|
||||
closeBtn.style.cursor = "pointer";
|
||||
closeBtn.addEventListener("click", () => {
|
||||
// remove the dialog from DOM
|
||||
if (div.parentNode) {
|
||||
div.parentNode.removeChild(div);
|
||||
}
|
||||
});
|
||||
div.appendChild(closeBtn);
|
||||
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
private static onMouseMove(event: MouseEvent) {
|
||||
if (
|
||||
event.clientX < 0 ||
|
||||
event.clientX > window.innerWidth ||
|
||||
event.clientY < 0 ||
|
||||
event.clientY > window.innerHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.mouse.move(${event.clientX}, ${event.clientY});\n`;
|
||||
}
|
||||
|
||||
private static onMouseDown(event: MouseEvent) {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
const button =
|
||||
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
|
||||
Record.events += ` await page.mouse.down({ button: "${button}" });\n`;
|
||||
}
|
||||
|
||||
private static onMouseUp(event: MouseEvent) {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
const button =
|
||||
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
|
||||
Record.events += ` await page.mouse.up({ button: "${button}" });\n`;
|
||||
|
||||
Record.events += " await expect(page).toHaveScreenshot({\n";
|
||||
Record.events += " maxDiffPixels: 100,\n";
|
||||
Record.events += " maxDiffPixelRatio: 0.01,\n";
|
||||
Record.events += " });\n";
|
||||
}
|
||||
|
||||
private static onKeyDown(event: KeyboardEvent) {
|
||||
// Only record if the recording key is not pressed
|
||||
if (event.key !== "F2") {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.keyboard.down("${event.key}");\n`;
|
||||
}
|
||||
}
|
||||
|
||||
private static onKeyUp(event: KeyboardEvent) {
|
||||
// Only record if the recording key is not pressed
|
||||
if (event.key !== "F2") {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.keyboard.up("${event.key}");\n`;
|
||||
|
||||
Record.events += " await expect(page).toHaveScreenshot({\n";
|
||||
Record.events += " maxDiffPixels: 100,\n";
|
||||
Record.events += " maxDiffPixelRatio: 0.01,\n";
|
||||
Record.events += " });\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDevEnv()) {
|
||||
window.record = Record;
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "F2") {
|
||||
if (Record.isRecording) {
|
||||
if (event.ctrlKey) {
|
||||
console.info("Stopping Playwright recording");
|
||||
Record.stop();
|
||||
} else {
|
||||
Record.restart();
|
||||
}
|
||||
} else {
|
||||
console.info("Starting Playwright recording");
|
||||
Record.start();
|
||||
}
|
||||
} else if (event.key === "Enter" && event.ctrlKey) {
|
||||
Record.showGeneratedEvents();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("17562123239901g67cqde", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Math.random = () => 0.42;
|
||||
|
||||
localStorage.getItem("i18nextLng");
|
||||
localStorage.setItem("i18nextLng", "en");
|
||||
localStorage.getItem("excalidraw-collab");
|
||||
localStorage.setItem("excalidraw-collab", '{"username":""}');
|
||||
localStorage.getItem("excalidraw-debug");
|
||||
localStorage.setItem("excalidraw-debug", '{"enabled":true}');
|
||||
localStorage.getItem("excalidraw-theme");
|
||||
localStorage.setItem("excalidraw-theme", "dark");
|
||||
localStorage.getItem("version-files");
|
||||
localStorage.setItem("version-files", "1756212319038");
|
||||
localStorage.getItem("version-dataState");
|
||||
localStorage.setItem("version-dataState", "1756212319038");
|
||||
localStorage.getItem("excalidraw-state");
|
||||
localStorage.setItem(
|
||||
"excalidraw-state",
|
||||
'{"showWelcomeScreen":true,"theme":"dark","currentChartType":"bar","currentItemBackgroundColor":"#a5d8ff","currentItemEndArrowhead":"arrow","currentItemFillStyle":"solid","currentItemFontFamily":5,"currentItemFontSize":20,"currentItemOpacity":100,"currentItemRoughness":2,"currentItemStartArrowhead":null,"currentItemStrokeColor":"#1e1e1e","currentItemRoundness":"round","currentItemArrowType":"round","currentItemStrokeStyle":"solid","currentItemStrokeWidth":2,"currentItemTextAlign":"left","cursorButton":"up","editingGroupId":null,"activeTool":{"type":"arrow","customType":null,"locked":false,"fromSelection":false,"lastActiveTool":null},"penMode":false,"penDetected":false,"exportBackground":true,"exportScale":1,"exportEmbedScene":false,"exportWithDarkMode":false,"gridSize":20,"gridStep":5,"gridModeEnabled":false,"defaultSidebarDockedPreference":false,"lastPointerDownWith":"mouse","name":"Untitled-2025-07-28-1603","openMenu":null,"openSidebar":null,"previousSelectedElementIds":{},"scrolledOutside":false,"scrollX":688.1079394457738,"scrollY":349.585883261872,"selectedElementIds":{},"selectedGroupIds":{},"shouldCacheIgnoreZoom":false,"stats":{"open":true,"panels":3},"viewBackgroundColor":"#ffffff","zenModeEnabled":false,"zoom":{"value":1.331666},"selectedLinearElement":null,"objectsSnapModeEnabled":false,"lockedMultiSelections":{}}',
|
||||
);
|
||||
localStorage.getItem("excalidraw");
|
||||
localStorage.setItem(
|
||||
"excalidraw",
|
||||
'[{"id":"hJuTw4QcwTsFtadNNnkLj","type":"rectangle","x":-100,"y":-100,"width":200,"height":200,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":{"type":3},"seed":43277494,"version":839,"versionNonce":1923298088,"isDeleted":false,"boundElements":[{"id":"0xSZCPMN8RzKiJOpvGaKB","type":"arrow"}],"updated":1756212298830,"link":null,"locked":false},{"id":"qXw5KqKvAjHRr5uwPi9B-","type":"rectangle","x":-523.1841597523046,"y":-129.52451989693097,"width":200,"height":200,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":{"type":3},"seed":392633439,"version":963,"versionNonce":210394408,"isDeleted":false,"boundElements":[{"id":"0xSZCPMN8RzKiJOpvGaKB","type":"arrow"}],"updated":1756212298830,"link":null,"locked":false}]',
|
||||
);
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 1001 });
|
||||
await page.goto("http://localhost:3000");
|
||||
await page.waitForLoadState("load");
|
||||
await page.waitForTimeout(3.599999999627471);
|
||||
await page.mouse.move(425, 390);
|
||||
await page.waitForTimeout(9.900000000372529);
|
||||
await page.mouse.move(424, 390);
|
||||
await page.waitForTimeout(51.09999999962747);
|
||||
await page.mouse.move(423, 390);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(423, 392);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(422, 392);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(420, 393);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(420, 395);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(418, 395);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(417, 396);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(416, 396);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(416, 397);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(414, 397);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(414, 399);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(413, 399);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(413, 401);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(413, 402);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(411, 402);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(411, 403);
|
||||
await page.waitForTimeout(32);
|
||||
await page.mouse.move(412, 403);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(412, 403);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(412, 402);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(413, 402);
|
||||
await page.waitForTimeout(0.900000000372529);
|
||||
await page.mouse.move(413, 400);
|
||||
await page.waitForTimeout(1.099999999627471);
|
||||
await page.mouse.move(415, 400);
|
||||
await page.waitForTimeout(7);
|
||||
await page.mouse.move(416, 400);
|
||||
await page.waitForTimeout(71);
|
||||
await page.mouse.move(417, 400);
|
||||
await page.waitForTimeout(450);
|
||||
await page.mouse.move(417, 399);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(418, 399);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(419, 399);
|
||||
await page.mouse.down({ button: "left" });
|
||||
await page.waitForTimeout(11.899999999441206);
|
||||
await page.mouse.move(420, 399);
|
||||
await page.waitForTimeout(69);
|
||||
await page.mouse.move(421, 399);
|
||||
await page.waitForTimeout(13);
|
||||
await page.mouse.move(422, 399);
|
||||
await page.waitForTimeout(7.1000000005587935);
|
||||
await page.mouse.move(423, 399);
|
||||
await page.waitForTimeout(5.3999999994412065);
|
||||
await page.mouse.move(425, 399);
|
||||
await page.waitForTimeout(0.5);
|
||||
await page.mouse.move(427, 401);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(428, 401);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(428, 403);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(430, 403);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(432, 403);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(432, 404);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(433, 404);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(434, 404);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(436, 406);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(437, 406);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(438, 407);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(440, 407);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(442, 407);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(443, 409);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(445, 409);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(447, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(451, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(455, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(457, 413);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(459, 413);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(461, 415);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(463, 417);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(467, 419);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(469, 419);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(471, 420);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(473, 424);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(477, 424);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(477, 426);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(478, 426);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(482, 428);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(484, 430);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(486, 430);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(488, 430);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(488, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(489, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(491, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(493, 434);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(495, 434);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(499, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(501, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(502, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(504, 438);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(506, 440);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(508, 440);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(510, 442);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(512, 444);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(514, 444);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(514, 445);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(515, 445);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(517, 445);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(517, 447);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(519, 448);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(521, 448);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(522, 450);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(524, 450);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(525, 450);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(527, 450);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(529, 450);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(530, 450);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(532, 450);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(533, 451);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(534, 451);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(535, 451);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(535, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(537, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(538, 453);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(540, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(540, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(541, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(542, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(543, 454);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(544, 454);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(544, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(546, 455);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(547, 455);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(548, 455);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(549, 455);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(551, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(552, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(554, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(555, 455);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(557, 455);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(559, 457);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(560, 457);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(562, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(564, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(566, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(567, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(569, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(570, 459);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(574, 460);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(575, 460);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(575, 462);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(577, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(578, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(580, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(582, 465);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(584, 465);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(585, 465);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(586, 465);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(588, 467);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(590, 467);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(591, 467);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(591, 468);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(594, 468);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(595, 468);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(597, 468);
|
||||
await page.waitForTimeout(2.7000000001862645);
|
||||
await page.mouse.move(601, 470);
|
||||
await page.waitForTimeout(1.400000000372529);
|
||||
await page.mouse.move(602, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(603, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(605, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(607, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(608, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(610, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(612, 470);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(613, 470);
|
||||
await page.waitForTimeout(1.2000000001862645);
|
||||
await page.mouse.move(613, 471);
|
||||
await page.waitForTimeout(1.7999999998137355);
|
||||
await page.mouse.move(614, 471);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(616, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(618, 473);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(621, 473);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(623, 473);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(625, 473);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(626, 473);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(628, 473);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(629, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(631, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(634, 475);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(636, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(638, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(639, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(640, 475);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(642, 475);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(644, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(647, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(649, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(650, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(652, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(653, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(655, 475);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(656, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(658, 476);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(659, 476);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(660, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(662, 476);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(663, 476);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(664, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(666, 474);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(668, 474);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(670, 474);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(671, 474);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(673, 476);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(675, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(678, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(680, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(682, 476);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(683, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(684, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(686, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(690, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(694, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(696, 478);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(698, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(699, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(703, 478);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(705, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(707, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(708, 478);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(711, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(713, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(714, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(716, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(717, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(719, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(722, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(724, 478);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(725, 478);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(726, 478);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(727, 478);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(733, 480);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(734, 480);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(735, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(737, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(739, 480);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(740, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(742, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(744, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(744, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(745, 482);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(747, 482);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(748, 482);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(749, 482);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(751, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(753, 482);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(755, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(758, 482);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(760, 482);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(761, 483);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(763, 484);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(765, 484);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(766, 485);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(767, 485);
|
||||
await page.waitForTimeout(11);
|
||||
await page.mouse.move(768, 485);
|
||||
await page.waitForTimeout(12.100000000558794);
|
||||
await page.mouse.move(769, 485);
|
||||
await page.waitForTimeout(51.89999999944121);
|
||||
await page.mouse.move(770, 485);
|
||||
await page.waitForTimeout(9);
|
||||
await page.mouse.move(771, 485);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(772, 485);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(773, 485);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(774, 485);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(776, 487);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(777, 487);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(778, 487);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(779, 487);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(780, 488);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(781, 488);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(782, 488);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(784, 488);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(785, 488);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(786, 488);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(788, 488);
|
||||
await page.waitForTimeout(8.899999999441206);
|
||||
await page.mouse.move(789, 488);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(790, 488);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(791, 488);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(792, 488);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(793, 488);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(793, 489);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(794, 489);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(795, 489);
|
||||
await page.waitForTimeout(7.599999999627471);
|
||||
await page.mouse.move(796, 489);
|
||||
await page.waitForTimeout(3.400000000372529);
|
||||
await page.mouse.move(797, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(798, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(799, 489);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(800, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(801, 489);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(802, 489);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(803, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(804, 489);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(805, 489);
|
||||
await page.waitForTimeout(5.8999999994412065);
|
||||
await page.mouse.move(806, 490);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(807, 490);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(809, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(810, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(812, 490);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(813, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(815, 490);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(816, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(818, 492);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(819, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(820, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(822, 492);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(823, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(825, 492);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(826, 492);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(827, 492);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(828, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(830, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(831, 492);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(832, 492);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(833, 493);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(835, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(836, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(838, 493);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(839, 493);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(841, 493);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(842, 493);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(843, 493);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(843, 492);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(844, 492);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(844, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(845, 493);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(846, 493);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(847, 494);
|
||||
await page.waitForTimeout(24.899999999441206);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(29.100000000558794);
|
||||
await page.mouse.move(848, 495);
|
||||
await page.waitForTimeout(297.8999999994412);
|
||||
await page.mouse.move(848, 495);
|
||||
await page.waitForTimeout(5.7000000001862645);
|
||||
await page.mouse.up({ button: "left" });
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
await page.waitForTimeout(11.299999999813735);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(199.1000000005588);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(249.29999999981374);
|
||||
await page.mouse.move(846, 495);
|
||||
await page.waitForTimeout(5.7000000001862645);
|
||||
await page.mouse.move(846, 496);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(845, 496);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(845, 497);
|
||||
await page.waitForTimeout(9);
|
||||
await page.mouse.move(845, 498);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(844, 498);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(844, 500);
|
||||
await page.waitForTimeout(8);
|
||||
await page.mouse.move(844, 501);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(843, 501);
|
||||
await page.waitForTimeout(137);
|
||||
await page.mouse.move(844, 501);
|
||||
await page.waitForTimeout(68.09999999962747);
|
||||
await page.mouse.move(845, 501);
|
||||
await page.waitForTimeout(7.7999999998137355);
|
||||
await page.mouse.move(845, 500);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(846, 500);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(847, 500);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(848, 500);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(849, 500);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(850, 500);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(851, 500);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(852, 499);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(854, 499);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(855, 499);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(856, 499);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(857, 499);
|
||||
await page.waitForTimeout(12);
|
||||
await page.mouse.move(858, 499);
|
||||
await page.waitForTimeout(24);
|
||||
await page.mouse.move(858, 498);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(858, 498);
|
||||
await page.waitForTimeout(59.200000000186265);
|
||||
await page.keyboard.down("Control");
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
+13
-5
@@ -11,9 +11,11 @@
|
||||
"@babel/preset-env": "7.26.9",
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@playwright/test": "1.55.0",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
@@ -52,13 +54,18 @@
|
||||
"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:test": "yarn --cwd ./excalidraw-app start:test",
|
||||
"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 +83,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"
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,6 +45,7 @@ export const APP_NAME = "Excalidraw";
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
ElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
@@ -1239,17 +1236,6 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
};
|
||||
|
||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
return Array.isArray(value);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +164,14 @@ export class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements);
|
||||
this.replaceAllElements(elements, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,12 +268,19 @@ export class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
replaceAllElements(
|
||||
nextElements: ElementsMapOrArray,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { _generateElementShape } from "./Shape";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
||||
|
||||
import type { Drawable } from "roughjs/bin/core";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
ShapeCache.cache.delete(element);
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates & caches shape for element if not already cached, otherwise
|
||||
* returns cached shape.
|
||||
*/
|
||||
public static generateElementShape = <
|
||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
>(
|
||||
element: T,
|
||||
renderConfig: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
if (cachedShape !== undefined) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
@@ -16,11 +18,12 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -34,9 +33,9 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
doBoundsIntersect,
|
||||
getCenterForBounds,
|
||||
getElementBounds,
|
||||
doBoundsIntersect,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { distanceToElement } from "./distance";
|
||||
@@ -61,7 +60,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { aabbForElement, elementCenterPoint } from "./bounds";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
@@ -385,6 +384,48 @@ export const getSuggestedBindingsForArrows = (
|
||||
);
|
||||
};
|
||||
|
||||
export const maybeSuggestBindingsForLinearElementAtCoords = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
/** scene coords */
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[],
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
// During line creation the start binding hasn't been written yet
|
||||
// into `linearElement`
|
||||
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||
): ExcalidrawBindableElement[] =>
|
||||
Array.from(
|
||||
pointerCoords.reduce(
|
||||
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
coords,
|
||||
scene.getNonDeletedElements(),
|
||||
scene.getNonDeletedElementsMap(),
|
||||
zoom,
|
||||
isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
);
|
||||
|
||||
if (
|
||||
hoveredBindableElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
oppositeBindingBoundElement?.id,
|
||||
hoveredBindableElement,
|
||||
)
|
||||
) {
|
||||
acc.add(hoveredBindableElement);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
|
||||
),
|
||||
);
|
||||
|
||||
export const maybeBindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
@@ -514,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
||||
|
||||
const isLinearElementSimple = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
): boolean => linearElement.points.length < 3;
|
||||
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
|
||||
|
||||
const unbindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
|
||||
@@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { generateRoughOptions } from "./shape";
|
||||
import { ShapeCache } from "./shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shapes";
|
||||
import { getElementShape } from "./shape";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
@@ -584,7 +584,7 @@ const solveQuadratic = (
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
const getCubicBezierCurveBound = (
|
||||
export const getCubicBezierCurveBound = (
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
@@ -1165,6 +1165,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the axis-aligned bounding box for a given element
|
||||
*/
|
||||
export const aabbForElement = (
|
||||
element: Readonly<ExcalidrawElement>,
|
||||
elementsMap: ElementsMap,
|
||||
offset?: [number, number, number, number],
|
||||
) => {
|
||||
const bbox = {
|
||||
minX: element.x,
|
||||
minY: element.y,
|
||||
maxX: element.x + element.width,
|
||||
maxY: element.y + element.height,
|
||||
midX: element.x + element.width / 2,
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const bounds = [
|
||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
] as Bounds;
|
||||
|
||||
if (offset) {
|
||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||
return [
|
||||
bounds[0] - leftOffset,
|
||||
bounds[1] - topOffset,
|
||||
bounds[2] + rightOffset,
|
||||
bounds[3] + downOffset,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const doBoundsIntersect = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1178,3 +1243,14 @@ export const doBoundsIntersect = (
|
||||
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import { isTransparent } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
@@ -18,15 +18,22 @@ import {
|
||||
ellipseSegmentInterceptPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
|
||||
import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isPathALoop } from "./shapes";
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
type Bounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
@@ -250,26 +257,79 @@ export const intersectElementWithLineSegment = (
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
onlyFirst,
|
||||
);
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
}
|
||||
};
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const lineIntersections = (
|
||||
lines: LineSegment<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, angle));
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const intersections = [];
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
@@ -283,6 +343,19 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
}
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
@@ -300,7 +373,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
@@ -308,48 +381,43 @@ const intersectRectanguloidWithLineSegment = (
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
l[0],
|
||||
segment[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
l[1],
|
||||
segment[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const s of sides) {
|
||||
const intersection = lineSegmentIntersectionPoints(
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
s,
|
||||
);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, element.angle));
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
for (const t of corners) {
|
||||
const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, element.angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
@@ -374,38 +442,32 @@ const intersectDiamondWithLineSegment = (
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const s of sides) {
|
||||
const intersection = lineSegmentIntersectionPoints(
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
s,
|
||||
);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, element.angle));
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
for (const t of corners) {
|
||||
const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, element.angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
|
||||
+370
-125
@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
||||
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@@ -150,13 +150,27 @@ export class Delta<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two deltas into a new one.
|
||||
*/
|
||||
public static merge<T>(
|
||||
delta1: Delta<T>,
|
||||
delta2: Delta<T>,
|
||||
delta3: Delta<T> = Delta.empty(),
|
||||
) {
|
||||
return Delta.create(
|
||||
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
|
||||
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges deleted and inserted object partials.
|
||||
*/
|
||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||
prev: T,
|
||||
added: T,
|
||||
removed: T,
|
||||
removed: T = {} as T,
|
||||
) {
|
||||
const cloned = { ...prev };
|
||||
|
||||
@@ -496,6 +510,11 @@ export interface DeltaContainer<T> {
|
||||
*/
|
||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||
|
||||
/**
|
||||
* Squashes the current delta with the given one.
|
||||
*/
|
||||
squash(delta: DeltaContainer<T>): this;
|
||||
|
||||
/**
|
||||
* Checks whether all `Delta`s are empty.
|
||||
*/
|
||||
@@ -503,7 +522,11 @@ export interface DeltaContainer<T> {
|
||||
}
|
||||
|
||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
|
||||
public static calculate<T extends ObservedAppState>(
|
||||
prevAppState: T,
|
||||
@@ -534,53 +557,124 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return new AppStateDelta(inversedDelta);
|
||||
}
|
||||
|
||||
public squash(delta: AppStateDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedElementIds ?? {},
|
||||
delta.delta.deleted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedElementIds ?? {},
|
||||
delta.delta.inserted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedGroupIds ?? {},
|
||||
delta.delta.deleted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedGroupIds ?? {},
|
||||
delta.delta.inserted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.deleted.lockedMultiSelections ?? {},
|
||||
delta.delta.deleted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.inserted.lockedMultiSelections ?? {},
|
||||
delta.delta.inserted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInserted: Partial<ObservedAppState> = {};
|
||||
const mergedDeleted: Partial<ObservedAppState> = {};
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedElementIds).length ||
|
||||
Object.keys(mergedInsertedSelectedElementIds).length
|
||||
) {
|
||||
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
||||
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
||||
Object.keys(mergedInsertedSelectedGroupIds).length
|
||||
) {
|
||||
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
||||
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
||||
Object.keys(mergedInsertedLockedMultiSelections).length
|
||||
) {
|
||||
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
||||
mergedInserted.lockedMultiSelections =
|
||||
mergedInsertedLockedMultiSelections;
|
||||
}
|
||||
|
||||
this.delta = Delta.merge(
|
||||
this.delta,
|
||||
delta.delta,
|
||||
Delta.create(mergedDeleted, mergedInserted),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
appState: AppState,
|
||||
nextElements: SceneElementsMap,
|
||||
): [AppState, boolean] {
|
||||
try {
|
||||
const {
|
||||
selectedElementIds: removedSelectedElementIds = {},
|
||||
selectedGroupIds: removedSelectedGroupIds = {},
|
||||
selectedElementIds: deletedSelectedElementIds = {},
|
||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||
} = this.delta.deleted;
|
||||
|
||||
const {
|
||||
selectedElementIds: addedSelectedElementIds = {},
|
||||
selectedGroupIds: addedSelectedGroupIds = {},
|
||||
selectedLinearElementId,
|
||||
editingLinearElementId,
|
||||
selectedElementIds: insertedSelectedElementIds = {},
|
||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||
selectedLinearElement: insertedSelectedLinearElement,
|
||||
...directlyApplicablePartial
|
||||
} = this.delta.inserted;
|
||||
|
||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||
appState.selectedElementIds,
|
||||
addedSelectedElementIds,
|
||||
removedSelectedElementIds,
|
||||
insertedSelectedElementIds,
|
||||
deletedSelectedElementIds,
|
||||
);
|
||||
|
||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||
appState.selectedGroupIds,
|
||||
addedSelectedGroupIds,
|
||||
removedSelectedGroupIds,
|
||||
insertedSelectedGroupIds,
|
||||
deletedSelectedGroupIds,
|
||||
);
|
||||
|
||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||
appState.lockedMultiSelections,
|
||||
insertedLockedMultiSelections,
|
||||
deletedLockedMultiSelections,
|
||||
);
|
||||
|
||||
const selectedLinearElement =
|
||||
selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
||||
insertedSelectedLinearElement &&
|
||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
selectedLinearElementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
)
|
||||
: null;
|
||||
|
||||
const editingLinearElement =
|
||||
editingLinearElementId && nextElements.has(editingLinearElementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
editingLinearElementId,
|
||||
insertedSelectedLinearElement.elementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
insertedSelectedLinearElement.isEditing,
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -589,14 +683,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
...directlyApplicablePartial,
|
||||
selectedElementIds: mergedSelectedElementIds,
|
||||
selectedGroupIds: mergedSelectedGroupIds,
|
||||
lockedMultiSelections: mergedLockedMultiSelections,
|
||||
selectedLinearElement:
|
||||
typeof selectedLinearElementId !== "undefined"
|
||||
? selectedLinearElement // element was either inserted or deleted
|
||||
: appState.selectedLinearElement, // otherwise assign what we had before
|
||||
editingLinearElement:
|
||||
typeof editingLinearElementId !== "undefined"
|
||||
? editingLinearElement // element was either inserted or deleted
|
||||
: appState.editingLinearElement, // otherwise assign what we had before
|
||||
typeof insertedSelectedLinearElement !== "undefined"
|
||||
? selectedLinearElement
|
||||
: appState.selectedLinearElement,
|
||||
};
|
||||
|
||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||
@@ -725,52 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "selectedLinearElementId":
|
||||
case "editingLinearElementId":
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
case "selectedLinearElement":
|
||||
const nextLinearElement = nextAppState[key];
|
||||
|
||||
if (!linearElement) {
|
||||
if (!nextLinearElement) {
|
||||
// previously there was a linear element (assuming visible), now there is none
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
const element = nextElements.get(linearElement.elementId);
|
||||
const element = nextElements.get(nextLinearElement.elementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// previously there wasn't a linear element, now there is one which is visible
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
// there was assigned a linear element now, but it's deleted
|
||||
nextAppState[appStateKey] = null;
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case "lockedMultiSelections": {
|
||||
case "lockedMultiSelections":
|
||||
const prevLockedUnits = prevAppState[key] || {};
|
||||
const nextLockedUnits = nextAppState[key] || {};
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeLockedId": {
|
||||
case "activeLockedId":
|
||||
const prevHitLockedId = prevAppState[key] || null;
|
||||
const nextHitLockedId = nextAppState[key] || null;
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (prevHitLockedId !== nextHitLockedId) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
assertNever(
|
||||
key,
|
||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,20 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return visibleDifferenceFlag.value;
|
||||
}
|
||||
|
||||
private static convertToAppStateKey(
|
||||
key: keyof Pick<
|
||||
ObservedElementsAppState,
|
||||
"selectedLinearElementId" | "editingLinearElementId"
|
||||
>,
|
||||
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
|
||||
switch (key) {
|
||||
case "selectedLinearElementId":
|
||||
return "selectedLinearElement";
|
||||
case "editingLinearElementId":
|
||||
return "editingLinearElement";
|
||||
}
|
||||
}
|
||||
|
||||
private static filterSelectedElements(
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
elements: SceneElementsMap,
|
||||
@@ -856,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds,
|
||||
editingLinearElementId,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
@@ -911,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||
@@ -945,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||
|
||||
export type ApplyToOptions = {
|
||||
excludedProperties: Set<keyof ElementPartial>;
|
||||
excludedProperties?: Set<keyof ElementPartial>;
|
||||
};
|
||||
|
||||
type ApplyToFlags = {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
applyDirection: "forward" | "backward" | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1051,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
deleted.version !== inserted.version
|
||||
);
|
||||
|
||||
private static satisfiesUniqueInvariants = (
|
||||
elementsDelta: ElementsDelta,
|
||||
id: string,
|
||||
) => {
|
||||
const { added, removed, updated } = elementsDelta;
|
||||
// it's required that there is only one unique delta type per element
|
||||
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
||||
};
|
||||
|
||||
private static validate(
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
@@ -1059,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||
if (
|
||||
!this.satisfiesCommmonInvariants(delta) ||
|
||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||
!satifiesSpecialInvariants(delta)
|
||||
) {
|
||||
console.error(
|
||||
@@ -1095,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||
const deleted = { ...prevElement } as ElementPartial;
|
||||
|
||||
const inserted = {
|
||||
isDeleted: true,
|
||||
@@ -1109,7 +1191,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
removed[prevElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!prevElement.isDeleted) {
|
||||
removed[prevElement.id] = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,7 +1210,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
const inserted = {
|
||||
...nextElement,
|
||||
isDeleted: false,
|
||||
} as ElementPartial;
|
||||
|
||||
const delta = Delta.create(
|
||||
@@ -1134,7 +1218,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
added[nextElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!nextElement.isDeleted) {
|
||||
added[nextElement.id] = delta;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -1163,8 +1250,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// making sure there are at least some changes
|
||||
if (!Delta.isEmpty(delta)) {
|
||||
const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
|
||||
const strippedInserted = ElementsDelta.stripVersionProps(
|
||||
delta.inserted,
|
||||
);
|
||||
|
||||
// making sure there are at least some changes and only changed version & versionNonce does not count!
|
||||
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
}
|
||||
@@ -1181,8 +1273,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||
}
|
||||
|
||||
return inversedDeltas;
|
||||
@@ -1280,8 +1372,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
latestDelta = delta;
|
||||
}
|
||||
|
||||
const strippedDeleted = ElementsDelta.stripVersionProps(
|
||||
latestDelta.deleted,
|
||||
);
|
||||
const strippedInserted = ElementsDelta.stripVersionProps(
|
||||
latestDelta.inserted,
|
||||
);
|
||||
|
||||
// it might happen that after applying latest changes the delta itself does not contain any changes
|
||||
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
|
||||
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) {
|
||||
modifiedDeltas[id] = latestDelta;
|
||||
}
|
||||
}
|
||||
@@ -1301,9 +1400,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@@ -1311,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const flags: ApplyToFlags = {
|
||||
containsVisibleDifference: false,
|
||||
containsZindexDifference: false,
|
||||
applyDirection: undefined,
|
||||
};
|
||||
|
||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
elements,
|
||||
nextElements,
|
||||
snapshot,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
const updatedElements = applyDeltas(this.updated);
|
||||
|
||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||
const affectedElements = this.resolveConflicts(
|
||||
elements,
|
||||
nextElements,
|
||||
flags.applyDirection,
|
||||
);
|
||||
|
||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||
changedElements = new Map([
|
||||
@@ -1350,22 +1453,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
);
|
||||
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements);
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
@@ -1380,12 +1476,113 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
}
|
||||
|
||||
public squash(delta: ElementsDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const { added, removed, updated } = delta;
|
||||
|
||||
const mergeBoundElements = (
|
||||
prevDelta: Delta<ElementPartial>,
|
||||
nextDelta: Delta<ElementPartial>,
|
||||
) => {
|
||||
const mergedDeletedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.deleted.boundElements ?? [],
|
||||
nextDelta.deleted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
const mergedInsertedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.inserted.boundElements ?? [],
|
||||
nextDelta.inserted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
if (
|
||||
!mergedDeletedBoundElements.length &&
|
||||
!mergedInsertedBoundElements.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Delta.create(
|
||||
{
|
||||
boundElements: mergedDeletedBoundElements,
|
||||
},
|
||||
{
|
||||
boundElements: mergedInsertedBoundElements,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(added)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.added[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.removed[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(removed)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.removed[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.added[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(updated)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.updated[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
|
||||
if (prevDelta === this.added[id]) {
|
||||
this.added[id] = updatedDelta;
|
||||
} else if (prevDelta === this.removed[id]) {
|
||||
this.removed[id] = updatedDelta;
|
||||
} else {
|
||||
this.updated[id] = updatedDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
|
||||
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
|
||||
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static createApplier =
|
||||
(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) =>
|
||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
@@ -1398,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsDelta.applyDelta(
|
||||
const nextElement = ElementsDelta.applyDelta(
|
||||
element,
|
||||
delta,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
nextElements.set(nextElement.id, nextElement);
|
||||
acc.set(nextElement.id, nextElement);
|
||||
|
||||
if (!flags.applyDirection) {
|
||||
const prevElement = prevElements.get(id);
|
||||
|
||||
if (prevElement) {
|
||||
flags.applyDirection =
|
||||
prevElement.version > nextElement.version
|
||||
? "backward"
|
||||
: "forward";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -1451,8 +1659,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static applyDelta(
|
||||
element: OrderedExcalidrawElement,
|
||||
delta: Delta<ElementPartial>,
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) {
|
||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||
|
||||
@@ -1466,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.excludedProperties.has(key)) {
|
||||
if (options?.excludedProperties?.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1506,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
}
|
||||
|
||||
return newElementWith(element, directlyApplicablePartial);
|
||||
return newElementWith(element, directlyApplicablePartial, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1546,6 +1754,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private resolveConflicts(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
applyDirection: "forward" | "backward" = "forward",
|
||||
) {
|
||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
const updater = (
|
||||
@@ -1557,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevElement = prevElements.get(element.id);
|
||||
const nextVersion =
|
||||
applyDirection === "forward"
|
||||
? nextElement.version + 1
|
||||
: nextElement.version - 1;
|
||||
|
||||
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
||||
|
||||
let affectedElement: OrderedExcalidrawElement;
|
||||
|
||||
if (prevElements.get(element.id) === nextElement) {
|
||||
if (prevElement === nextElement) {
|
||||
// create the new element instance in case we didn't modify the element yet
|
||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||
affectedElement = newElementWith(
|
||||
nextElement,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
{
|
||||
...elementUpdates,
|
||||
version: nextVersion,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
affectedElement = mutateElement(
|
||||
nextElement,
|
||||
nextElements,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
affectedElement = mutateElement(nextElement, nextElements, {
|
||||
...elementUpdates,
|
||||
// don't modify the version further, if it's already different
|
||||
version:
|
||||
prevElement?.version !== nextElement.version
|
||||
? nextElement.version
|
||||
: nextVersion,
|
||||
});
|
||||
}
|
||||
|
||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||
@@ -1609,25 +1833,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||
);
|
||||
|
||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||
// technically we could do better here if perf. would become an issue
|
||||
const { added, removed, updated } = ElementsDelta.calculate(
|
||||
prevAffectedElements,
|
||||
nextAffectedElements,
|
||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||
this.squash(
|
||||
// technically we could do better here if perf. would become an issue
|
||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||
);
|
||||
|
||||
for (const [id, delta] of Object.entries(added)) {
|
||||
this.added[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(removed)) {
|
||||
this.removed[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(updated)) {
|
||||
this.updated[id] = delta;
|
||||
}
|
||||
|
||||
return nextAffectedElements;
|
||||
}
|
||||
|
||||
@@ -1689,6 +1900,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||
}
|
||||
|
||||
public static redrawElements(
|
||||
nextElements: SceneElementsMap,
|
||||
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
try {
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
|
||||
// needs ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
} catch (e) {
|
||||
console.error(`Couldn't redraw elements`, e);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return nextElements;
|
||||
}
|
||||
}
|
||||
|
||||
private static redrawTextBoundingBoxes(
|
||||
scene: Scene,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
@@ -1743,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||
updateBoundElements(element, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
@@ -1838,4 +2075,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
|
||||
private static stripVersionProps(
|
||||
partial: Partial<OrderedExcalidrawElement>,
|
||||
): ElementPartial {
|
||||
const { version, versionNonce, ...strippedPartial } = partial;
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
@@ -16,6 +14,8 @@ import {
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { elementCenterPoint } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
@@ -48,7 +48,7 @@ export const distanceToElement = (
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
||||
return distanceToLinearOrFreeDraElement(element, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
|
||||
|
||||
const distanceToLinearOrFreeDraElement = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
return Math.min(
|
||||
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
@@ -14,6 +16,7 @@ export const distributeElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
@@ -21,7 +24,11 @@ export const distributeElements = (
|
||||
: (["minY", "midY", "maxY", "height"] as const);
|
||||
|
||||
const bounds = getCommonBoundingBox(selectedElements);
|
||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
||||
const groups = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
appState,
|
||||
)
|
||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
@@ -2208,20 +2209,12 @@ const getGlobalPoint = (
|
||||
return initialPoint;
|
||||
}
|
||||
|
||||
if (element && elementsMap) {
|
||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
||||
if (element) {
|
||||
return getGlobalFixedPointForBindableElement(
|
||||
fixedPointRatio || [0, 0],
|
||||
element,
|
||||
elementsMap,
|
||||
elementsMap ?? arrayToMap([element]),
|
||||
);
|
||||
|
||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
||||
return Math.abs(
|
||||
distanceToElement(element, elementsMap, fixedGlobalPoint) -
|
||||
FIXED_BINDING_DISTANCE,
|
||||
) > 0.01
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
|
||||
: fixedGlobalPoint;
|
||||
}
|
||||
|
||||
return initialPoint;
|
||||
|
||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
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]*$/;
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
@@ -56,6 +56,35 @@ const RE_REDDIT =
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const parseYouTubeTimestamp = (url: string): number => {
|
||||
let timeParam: string | null | undefined;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||
timeParam =
|
||||
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
|
||||
} catch (error) {
|
||||
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
|
||||
timeParam = timeMatch?.[1];
|
||||
}
|
||||
|
||||
if (!timeParam) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(timeParam)) {
|
||||
return parseInt(timeParam, 10);
|
||||
}
|
||||
|
||||
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||
if (!timeMatch) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
|
||||
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||
};
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@@ -113,7 +142,8 @@ export const getEmbedLink = (
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
||||
const startTime = parseYouTubeTimestamp(originalLink);
|
||||
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
switch (ytLink[1]) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { isBoundToContainer } from "./typeChecks";
|
||||
|
||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||
|
||||
import type {
|
||||
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
|
||||
|
||||
return copy;
|
||||
};
|
||||
|
||||
// given a list of selected elements, return the element grouped by their immediate group selected state
|
||||
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
|
||||
export const getSelectedElementsByGroup = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[][] => {
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
const unboundElements = selectedElements.filter(
|
||||
(element) => !isBoundToContainer(element),
|
||||
);
|
||||
const groups: Map<string, ExcalidrawElement[]> = new Map();
|
||||
const elements: Map<string, ExcalidrawElement[]> = new Map();
|
||||
|
||||
// helper function to add an element to the elements map
|
||||
const addToElementsMap = (element: ExcalidrawElement) => {
|
||||
// elements
|
||||
const currentElementMembers = elements.get(element.id) || [];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
currentElementMembers.push(boundTextElement);
|
||||
}
|
||||
elements.set(element.id, [...currentElementMembers, element]);
|
||||
};
|
||||
|
||||
// helper function to add an element to the groups map
|
||||
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
|
||||
// groups
|
||||
const currentGroupMembers = groups.get(groupId) || [];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
currentGroupMembers.push(boundTextElement);
|
||||
}
|
||||
groups.set(groupId, [...currentGroupMembers, element]);
|
||||
};
|
||||
|
||||
// helper function to handle the case where a single group is selected
|
||||
// and all elements selected are within the group, it will respect group hierarchy in accordance to
|
||||
// their nested grouping order
|
||||
const handleSingleSelectedGroupCase = (
|
||||
element: ExcalidrawElement,
|
||||
selectedGroupId: GroupId,
|
||||
) => {
|
||||
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
|
||||
const nestedGroupCount = element.groupIds.slice(
|
||||
0,
|
||||
indexOfSelectedGroupId,
|
||||
).length;
|
||||
return nestedGroupCount > 0
|
||||
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
|
||||
: addToElementsMap(element);
|
||||
};
|
||||
|
||||
const isAllInSameGroup = selectedElements.every((element) =>
|
||||
isSelectedViaGroup(appState, element),
|
||||
);
|
||||
|
||||
unboundElements.forEach((element) => {
|
||||
const selectedGroupId = getSelectedGroupIdForElement(
|
||||
element,
|
||||
appState.selectedGroupIds,
|
||||
);
|
||||
if (!selectedGroupId) {
|
||||
addToElementsMap(element);
|
||||
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
|
||||
handleSingleSelectedGroupCase(element, selectedGroupId);
|
||||
} else {
|
||||
addToGroupsMap(element, selectedGroupId);
|
||||
}
|
||||
});
|
||||
return Array.from(groups.values()).concat(Array.from(elements.values()));
|
||||
};
|
||||
|
||||
@@ -102,9 +102,7 @@ export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./Shape";
|
||||
export * from "./ShapeCache";
|
||||
export * from "./shapes";
|
||||
export * from "./shape";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -18,9 +20,14 @@ import {
|
||||
getGridPoint,
|
||||
invariant,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Store } from "@excalidraw/element";
|
||||
import {
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
isPathALoop,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
@@ -39,6 +46,7 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
maybeSuggestBindingsForLinearElementAtCoords,
|
||||
} from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@@ -55,16 +63,7 @@ import {
|
||||
isFixedPointBinding,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import {
|
||||
isPathALoop,
|
||||
getBezierCurveLength,
|
||||
getControlPointsForBezierCurve,
|
||||
mapIntervalToBezierT,
|
||||
getBezierXY,
|
||||
toggleLinePolygonState,
|
||||
} from "./shapes";
|
||||
import { ShapeCache, toggleLinePolygonState } from "./shape";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
@@ -150,10 +149,12 @@ export class LinearElementEditor {
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
public readonly customLineAngle: number | null;
|
||||
public readonly isEditing: boolean;
|
||||
|
||||
constructor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
isEditing: boolean = false,
|
||||
) {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -188,6 +189,7 @@ export class LinearElementEditor {
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
this.isEditing = isEditing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -195,6 +197,7 @@ export class LinearElementEditor {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static POINT_HANDLE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||
@@ -216,11 +219,14 @@ export class LinearElementEditor {
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) {
|
||||
if (!appState.editingLinearElement || !appState.selectionElement) {
|
||||
if (
|
||||
!appState.selectedLinearElement?.isEditing ||
|
||||
!appState.selectionElement
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const { editingLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId } = editingLinearElement;
|
||||
const { selectedLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId } = selectedLinearElement;
|
||||
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
@@ -261,8 +267,8 @@ export class LinearElementEditor {
|
||||
});
|
||||
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...selectedLinearElement,
|
||||
selectedPointsIndices: nextSelectedPoints.length
|
||||
? nextSelectedPoints
|
||||
: null,
|
||||
@@ -278,18 +284,13 @@ export class LinearElementEditor {
|
||||
app: AppClassProperties,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
): LinearElementEditor | null {
|
||||
): Pick<AppState, keyof AppState> | null {
|
||||
if (!linearElementEditor) {
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
let customLineAngle = linearElementEditor.customLineAngle;
|
||||
if (!element) {
|
||||
@@ -350,7 +351,7 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
selectedIndex,
|
||||
@@ -378,7 +379,7 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
app.scene,
|
||||
new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
@@ -410,46 +411,59 @@ export class LinearElementEditor {
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, scene, false);
|
||||
handleBindTextResize(element, app.scene, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
let suggestedBindings: ExcalidrawBindableElement[] = [];
|
||||
if (isBindingElement(element, false)) {
|
||||
const firstSelectedIndex = selectedPointsIndices[0] === 0;
|
||||
const lastSelectedIndex =
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1] ===
|
||||
element.points.length - 1;
|
||||
const coords: { x: number; y: number }[] = [];
|
||||
|
||||
const firstSelectedIndex = selectedPointsIndices[0];
|
||||
if (firstSelectedIndex === 0) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
if (!firstSelectedIndex !== !lastSelectedIndex) {
|
||||
coords.push({ x: scenePointerX, y: scenePointerY });
|
||||
} else {
|
||||
if (firstSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const lastSelectedIndex =
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1];
|
||||
if (lastSelectedIndex === element.points.length - 1) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[lastSelectedIndex],
|
||||
elementsMap,
|
||||
if (lastSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1]
|
||||
],
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (coords.length) {
|
||||
maybeSuggestBinding(element, coords);
|
||||
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
|
||||
element,
|
||||
coords,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const newLinearElementEditor = {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices,
|
||||
segmentMidPointHoveredCoords:
|
||||
@@ -469,6 +483,12 @@ export class LinearElementEditor {
|
||||
isDragging: true,
|
||||
customLineAngle,
|
||||
};
|
||||
|
||||
return {
|
||||
...app.state,
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBindings,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -482,6 +502,7 @@ export class LinearElementEditor {
|
||||
): LinearElementEditor {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
|
||||
|
||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||
editingLinearElement;
|
||||
@@ -537,13 +558,15 @@ export class LinearElementEditor {
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
? getHoveredElementForBinding(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
(selectedPointsIndices?.length ?? 0) > 1
|
||||
? tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
)
|
||||
: pointerCoords,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
@@ -599,7 +622,7 @@ export class LinearElementEditor {
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
if (
|
||||
!isElbowArrow(element) &&
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
element.points.length > 2 &&
|
||||
!boundText
|
||||
) {
|
||||
@@ -629,10 +652,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
@@ -668,7 +688,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
if (
|
||||
points.length >= 3 &&
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
return null;
|
||||
@@ -734,7 +754,18 @@ export class LinearElementEditor {
|
||||
|
||||
let distance = pointDistance(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
|
||||
invariant(
|
||||
lines.length === 0 && curves.length > 0,
|
||||
"Only linears built out of curves are supported",
|
||||
);
|
||||
invariant(
|
||||
lines.length + curves.length >= index,
|
||||
"Invalid segment index while calculating mid point",
|
||||
);
|
||||
|
||||
distance = curveLength<GlobalPoint>(curves[index]);
|
||||
}
|
||||
|
||||
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||
@@ -742,39 +773,42 @@ export class LinearElementEditor {
|
||||
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: GlobalPoint,
|
||||
endPoint: GlobalPoint,
|
||||
endPointIndex: number,
|
||||
elementsMap: ElementsMap,
|
||||
index: number,
|
||||
): GlobalPoint {
|
||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
const controlPoints = getControlPointsForBezierCurve(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
if (isElbowArrow(element)) {
|
||||
invariant(
|
||||
element.points.length >= index,
|
||||
"Invalid segment index while calculating elbow arrow mid point",
|
||||
);
|
||||
if (controlPoints) {
|
||||
const t = mapIntervalToBezierT(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
0.5,
|
||||
);
|
||||
|
||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||
|
||||
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||
}
|
||||
|
||||
return segmentMidPoint;
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
|
||||
invariant(
|
||||
(lines.length === 0 && curves.length > 0) ||
|
||||
(lines.length > 0 && curves.length === 0),
|
||||
"Only linears built out of either segments or curves are supported",
|
||||
);
|
||||
invariant(
|
||||
lines.length + curves.length >= index,
|
||||
"Invalid segment index while calculating mid point",
|
||||
);
|
||||
|
||||
if (lines.length) {
|
||||
const segment = lines[index - 1];
|
||||
return pointCenter(segment[0], segment[1]);
|
||||
}
|
||||
|
||||
if (curves.length) {
|
||||
const segment = curves[index - 1];
|
||||
return curvePointAtLength(segment, 0.5);
|
||||
}
|
||||
|
||||
invariant(false, "Invalid segment type while calculating mid point");
|
||||
}
|
||||
|
||||
static getSegmentMidPointIndex(
|
||||
@@ -851,7 +885,7 @@ export class LinearElementEditor {
|
||||
segmentMidpoint,
|
||||
elementsMap,
|
||||
);
|
||||
} else if (event.altKey && appState.editingLinearElement) {
|
||||
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
scene.mutateElement(element, {
|
||||
points: [
|
||||
@@ -993,14 +1027,14 @@ export class LinearElementEditor {
|
||||
app: AppClassProperties,
|
||||
): LinearElementEditor | null {
|
||||
const appState = app.state;
|
||||
if (!appState.editingLinearElement) {
|
||||
if (!appState.selectedLinearElement?.isEditing) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return appState.editingLinearElement;
|
||||
return appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@@ -1010,10 +1044,12 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
return appState.selectedLinearElement?.lastUncommittedPoint
|
||||
? {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
}
|
||||
: appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
let newPoint: LocalPoint;
|
||||
@@ -1037,8 +1073,8 @@ export class LinearElementEditor {
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
@@ -1062,7 +1098,7 @@ export class LinearElementEditor {
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
@@ -1221,12 +1257,12 @@ export class LinearElementEditor {
|
||||
// ---------------------------------------------------------------------------
|
||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||
invariant(
|
||||
appState.editingLinearElement,
|
||||
appState.selectedLinearElement?.isEditing,
|
||||
"Not currently editing a linear element",
|
||||
);
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
invariant(
|
||||
@@ -1288,8 +1324,8 @@ export class LinearElementEditor {
|
||||
|
||||
return {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
selectedPointsIndices: nextSelectedIndices,
|
||||
},
|
||||
};
|
||||
@@ -1301,8 +1337,9 @@ export class LinearElementEditor {
|
||||
pointIndices: readonly number[],
|
||||
) {
|
||||
const isUncommittedPoint =
|
||||
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
app.state.selectedLinearElement?.isEditing &&
|
||||
app.state.selectedLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
|
||||
const nextPoints = element.points.filter((_, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
@@ -1475,7 +1512,7 @@ export class LinearElementEditor {
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
||||
) {
|
||||
return false;
|
||||
@@ -1670,10 +1707,7 @@ export class LinearElementEditor {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ import {
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
import { getCornerRadius } from "./utils";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
return element.strokeWidth * 12;
|
||||
case "text":
|
||||
return element.fontSize / 2;
|
||||
case "arrow":
|
||||
if (element.endArrowhead || element.endArrowhead) {
|
||||
return 40;
|
||||
}
|
||||
return 20;
|
||||
default:
|
||||
return 20;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
@@ -104,18 +103,6 @@ export const transformElements = (
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
} else if (isTextElement(element) && transformHandleType) {
|
||||
resizeSingleTextElement(
|
||||
originalElements,
|
||||
element,
|
||||
scene,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
return true;
|
||||
} else if (transformHandleType) {
|
||||
const elementId = selectedElements[0].id;
|
||||
const latestElement = elementsMap.get(elementId);
|
||||
@@ -150,6 +137,9 @@ export const transformElements = (
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isTextElement(element)) {
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
return true;
|
||||
} else if (selectedElements.length > 1) {
|
||||
if (transformHandleType === "rotation") {
|
||||
@@ -282,151 +272,50 @@ export const measureFontSizeFromWidth = (
|
||||
};
|
||||
};
|
||||
|
||||
const resizeSingleTextElement = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
export const resizeSingleTextElement = (
|
||||
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
scene: Scene,
|
||||
transformHandleType: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
// rotation pointer with reverse angle
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
pointFrom(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
let scaleX = 0;
|
||||
let scaleY = 0;
|
||||
|
||||
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
||||
if (transformHandleType.includes("e")) {
|
||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("w")) {
|
||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("n")) {
|
||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||
}
|
||||
if (transformHandleType.includes("s")) {
|
||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||
}
|
||||
const metricsWidth = element.width * (nextHeight / element.height);
|
||||
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = Math.max(scaleX, scaleY);
|
||||
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
|
||||
if (scale > 0) {
|
||||
const nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTopLeft = [x1, y1];
|
||||
const startBottomRight = [x2, y2];
|
||||
const startCenter = [cx, cy];
|
||||
|
||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
startBottomRight[0] - Math.abs(nextWidth),
|
||||
startBottomRight[1] - Math.abs(nextHeight),
|
||||
);
|
||||
}
|
||||
if (transformHandleType === "ne") {
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
bottomLeft[0],
|
||||
bottomLeft[1] - Math.abs(nextHeight),
|
||||
);
|
||||
}
|
||||
if (transformHandleType === "sw") {
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
topRight[0] - Math.abs(nextWidth),
|
||||
topRight[1],
|
||||
);
|
||||
}
|
||||
|
||||
if (["s", "n"].includes(transformHandleType)) {
|
||||
newTopLeft[0] = startCenter[0] - nextWidth / 2;
|
||||
}
|
||||
if (["e", "w"].includes(transformHandleType)) {
|
||||
newTopLeft[1] = startCenter[1] - nextHeight / 2;
|
||||
}
|
||||
|
||||
if (shouldResizeFromCenter) {
|
||||
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
|
||||
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
|
||||
}
|
||||
|
||||
const angle = element.angle;
|
||||
const rotatedTopLeft = pointRotateRads(
|
||||
newTopLeft,
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
const newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
metricsWidth,
|
||||
nextHeight,
|
||||
origElement.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
const newCenter = pointFrom<GlobalPoint>(
|
||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
||||
);
|
||||
const rotatedNewCenter = pointRotateRads(
|
||||
newCenter,
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
-angle as Radians,
|
||||
);
|
||||
const [nextX, nextY] = newTopLeft;
|
||||
|
||||
scene.mutateElement(element, {
|
||||
fontSize: metrics.size,
|
||||
width: nextWidth,
|
||||
width: metricsWidth,
|
||||
height: nextHeight,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (transformHandleType === "e" || transformHandleType === "w") {
|
||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
stateAtResizeStart.width,
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle as Radians,
|
||||
);
|
||||
|
||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
element.width,
|
||||
element.height,
|
||||
true,
|
||||
);
|
||||
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
|
||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: element.fontSize,
|
||||
@@ -435,17 +324,7 @@ const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
|
||||
if (transformHandleType.includes("e")) {
|
||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
||||
}
|
||||
if (transformHandleType.includes("w")) {
|
||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
||||
}
|
||||
|
||||
const newWidth =
|
||||
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
|
||||
const newWidth = Math.max(minWidth, nextWidth);
|
||||
|
||||
const text = wrapText(
|
||||
element.originalText,
|
||||
@@ -458,49 +337,27 @@ const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const eleNewHeight = metrics.height;
|
||||
const newHeight = metrics.height;
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
newWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startTopLeft[1],
|
||||
];
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
const rotatedTopLeft = pointRotateRads(
|
||||
pointFromPair(newTopLeft),
|
||||
startCenter,
|
||||
angle,
|
||||
);
|
||||
const newCenter = pointFrom(
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
);
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
-angle as Radians,
|
||||
const newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
newWidth,
|
||||
newHeight,
|
||||
element.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
|
||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(metrics.height),
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
text,
|
||||
autoResize: false,
|
||||
};
|
||||
@@ -821,6 +678,18 @@ export const resizeSingleElement = (
|
||||
shouldInformMutation?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||
return resizeSingleTextElement(
|
||||
origElement,
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldResizeFromCenter,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
);
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
@@ -1518,11 +1387,7 @@ export const resizeMultipleElements = (
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
scene.mutateElement(element, update, {
|
||||
informMutation: true,
|
||||
// needed for the fixed binding point udpate to take effect
|
||||
isDragging: true,
|
||||
});
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
|
||||
import {
|
||||
type GeometricShape,
|
||||
getClosedCurveShape,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
} from "@excalidraw/utils/shape";
|
||||
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type LocalPoint,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
isTransparent,
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
isTestEnv,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
@@ -14,17 +30,26 @@ import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
|
||||
import {
|
||||
canBecomePolygon,
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { getCornerRadius, isPathALoop } from "./utils";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
@@ -33,8 +58,9 @@ import {
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { shouldTestInside } from "./collision";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -44,11 +70,87 @@ import type {
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
ShapeCache.cache.delete(element);
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates & caches shape for element if not already cached, otherwise
|
||||
* returns cached shape.
|
||||
*/
|
||||
public static generateElementShape = <
|
||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
>(
|
||||
element: T,
|
||||
renderConfig: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
if (cachedShape !== undefined) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
@@ -81,7 +183,7 @@ export const generateRoughOptions = (
|
||||
continuousPath = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
seed: isTestEnv() ? 1 : element.seed,
|
||||
strokeLineDash:
|
||||
element.strokeStyle === "dashed"
|
||||
? getDashArrayDashed(element.strokeWidth)
|
||||
@@ -320,7 +422,6 @@ const getArrowheadShapes = (
|
||||
|
||||
export const generateLinearCollisionShape = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const generator = new RoughGenerator();
|
||||
const options: Options = {
|
||||
@@ -331,7 +432,18 @@ export const generateLinearCollisionShape = (
|
||||
preserveVertices: true,
|
||||
};
|
||||
const center = getCenterForBounds(
|
||||
getElementBounds(element, elementsMap, true),
|
||||
// Need a non-rotated center point
|
||||
element.points.reduce(
|
||||
(acc, point) => {
|
||||
return [
|
||||
Math.min(element.x + point[0], acc[0]),
|
||||
Math.min(element.y + point[1], acc[1]),
|
||||
Math.max(element.x + point[0], acc[2]),
|
||||
Math.max(element.y + point[1], acc[3]),
|
||||
];
|
||||
},
|
||||
[Infinity, Infinity, -Infinity, -Infinity],
|
||||
),
|
||||
);
|
||||
|
||||
switch (element.type) {
|
||||
@@ -491,7 +603,7 @@ export const generateLinearCollisionShape = (
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const _generateElementShape = (
|
||||
const generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
@@ -792,3 +904,103 @@ const generateElbowArrowShape = (
|
||||
|
||||
return d.join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* get the pure geometric shape of an excalidraw elementw
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "iframe":
|
||||
case "text":
|
||||
case "selection":
|
||||
return getPolygonShape(element);
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
return shouldTestInside(element)
|
||||
? getClosedCurveShape<Point>(
|
||||
element,
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
);
|
||||
}
|
||||
|
||||
case "ellipse":
|
||||
return getEllipseShape(element);
|
||||
|
||||
case "freedraw": {
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
return getFreedrawShape(
|
||||
element,
|
||||
pointFrom(cx, cy),
|
||||
shouldTestInside(element),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleLinePolygonState = (
|
||||
element: ExcalidrawLineElement,
|
||||
nextPolygonState: boolean,
|
||||
): {
|
||||
polygon: ExcalidrawLineElement["polygon"];
|
||||
points: ExcalidrawLineElement["points"];
|
||||
} | null => {
|
||||
const updatedPoints = [...element.points];
|
||||
|
||||
if (nextPolygonState) {
|
||||
if (!canBecomePolygon(element.points)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstPoint = updatedPoints[0];
|
||||
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
||||
|
||||
const distance = Math.hypot(
|
||||
firstPoint[0] - lastPoint[0],
|
||||
firstPoint[1] - lastPoint[1],
|
||||
);
|
||||
|
||||
if (
|
||||
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
||||
updatedPoints.length < 4
|
||||
) {
|
||||
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
||||
} else {
|
||||
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
||||
firstPoint[0],
|
||||
firstPoint[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
||||
const ret = {
|
||||
polygon: nextPolygonState,
|
||||
points: updatedPoints,
|
||||
};
|
||||
|
||||
return ret;
|
||||
};
|
||||
@@ -1,447 +0,0 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
invariant,
|
||||
elementCenterPoint,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
isPoint,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
getClosedCurveShape,
|
||||
getCurvePathOps,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
type GeometricShape,
|
||||
} from "@excalidraw/utils/shape";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { shouldTestInside } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
||||
|
||||
import { canBecomePolygon } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawLineElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* get the pure geometric shape of an excalidraw elementw
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "iframe":
|
||||
case "text":
|
||||
case "selection":
|
||||
return getPolygonShape(element);
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
return shouldTestInside(element)
|
||||
? getClosedCurveShape<Point>(
|
||||
element,
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
);
|
||||
}
|
||||
|
||||
case "ellipse":
|
||||
return getEllipseShape(element);
|
||||
|
||||
case "freedraw": {
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
return getFreedrawShape(
|
||||
element,
|
||||
pointFrom(cx, cy),
|
||||
shouldTestInside(element),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> | null => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
if (element.type === "arrow") {
|
||||
return getElementShape(
|
||||
{
|
||||
...boundTextElement,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
),
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return getElementShape(boundTextElement, elementsMap);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getControlPointsForBezierCurve = <
|
||||
P extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const shape = ShapeCache.generateElementShape(element, null);
|
||||
if (!shape) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
let currentP = pointFrom<P>(0, 0);
|
||||
let index = 0;
|
||||
let minDistance = Infinity;
|
||||
let controlPoints: P[] | null = null;
|
||||
|
||||
while (index < ops.length) {
|
||||
const { op, data } = ops[index];
|
||||
if (op === "move") {
|
||||
invariant(
|
||||
isPoint(data),
|
||||
"The returned ops is not compatible with a point",
|
||||
);
|
||||
currentP = pointFromPair(data);
|
||||
}
|
||||
if (op === "bcurveTo") {
|
||||
const p0 = currentP;
|
||||
const p1 = pointFrom<P>(data[0], data[1]);
|
||||
const p2 = pointFrom<P>(data[2], data[3]);
|
||||
const p3 = pointFrom<P>(data[4], data[5]);
|
||||
const distance = pointDistance(p3, endPoint);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
controlPoints = [p0, p1, p2, p3];
|
||||
}
|
||||
currentP = p3;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return controlPoints;
|
||||
};
|
||||
|
||||
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
||||
p0: P,
|
||||
p1: P,
|
||||
p2: P,
|
||||
p3: P,
|
||||
t: number,
|
||||
): P => {
|
||||
const equation = (t: number, idx: number) =>
|
||||
Math.pow(1 - t, 3) * p3[idx] +
|
||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||
p0[idx] * Math.pow(t, 3);
|
||||
const tx = equation(t, 0);
|
||||
const ty = equation(t, 1);
|
||||
return pointFrom(tx, ty);
|
||||
};
|
||||
|
||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
|
||||
if (!controlPoints) {
|
||||
return [];
|
||||
}
|
||||
const pointsOnCurve: P[] = [];
|
||||
let t = 1;
|
||||
// Take 20 points on curve for better accuracy
|
||||
while (t > 0) {
|
||||
const p = getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
);
|
||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
||||
t -= 0.05;
|
||||
}
|
||||
if (pointsOnCurve.length) {
|
||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
||||
}
|
||||
}
|
||||
return pointsOnCurve;
|
||||
};
|
||||
|
||||
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const arcLengths: number[] = [];
|
||||
arcLengths[0] = 0;
|
||||
const points = getPointsInBezierCurve(element, endPoint);
|
||||
let index = 0;
|
||||
let distance = 0;
|
||||
while (index < points.length - 1) {
|
||||
const segmentDistance = pointDistance(points[index], points[index + 1]);
|
||||
distance += segmentDistance;
|
||||
arcLengths.push(distance);
|
||||
index++;
|
||||
}
|
||||
|
||||
return arcLengths;
|
||||
};
|
||||
|
||||
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
) => {
|
||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||
return arcLengths.at(-1) as number;
|
||||
};
|
||||
|
||||
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
||||
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: P,
|
||||
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
||||
) => {
|
||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||
const pointsCount = arcLengths.length - 1;
|
||||
const curveLength = arcLengths.at(-1) as number;
|
||||
const targetLength = interval * curveLength;
|
||||
let low = 0;
|
||||
let high = pointsCount;
|
||||
let index = 0;
|
||||
// Doing a binary search to find the largest length that is less than the target length
|
||||
while (low < high) {
|
||||
index = Math.floor(low + (high - low) / 2);
|
||||
if (arcLengths[index] < targetLength) {
|
||||
low = index + 1;
|
||||
} else {
|
||||
high = index;
|
||||
}
|
||||
}
|
||||
if (arcLengths[index] > targetLength) {
|
||||
index--;
|
||||
}
|
||||
if (arcLengths[index] === targetLength) {
|
||||
return index / pointsCount;
|
||||
}
|
||||
|
||||
return (
|
||||
1 -
|
||||
(index +
|
||||
(targetLength - arcLengths[index]) /
|
||||
(arcLengths[index + 1] - arcLengths[index])) /
|
||||
pointsCount
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the axis-aligned bounding box for a given element
|
||||
*/
|
||||
export const aabbForElement = (
|
||||
element: Readonly<ExcalidrawElement>,
|
||||
elementsMap: ElementsMap,
|
||||
offset?: [number, number, number, number],
|
||||
) => {
|
||||
const bbox = {
|
||||
minX: element.x,
|
||||
minY: element.y,
|
||||
maxX: element.x + element.width,
|
||||
maxY: element.y + element.height,
|
||||
midX: element.x + element.width / 2,
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const bounds = [
|
||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
] as Bounds;
|
||||
|
||||
if (offset) {
|
||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||
return [
|
||||
bounds[0] - leftOffset,
|
||||
bounds[1] - topOffset,
|
||||
bounds[2] + rightOffset,
|
||||
bounds[3] + downOffset,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||
element.roundness?.type === ROUNDNESS.LEGACY
|
||||
) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||
|
||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||
|
||||
if (x <= CUTOFF_SIZE) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
return fixedRadiusSize;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (
|
||||
points: ExcalidrawLinearElement["points"],
|
||||
/** supply if you want the loop detection to account for current zoom */
|
||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||
): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [first, last] = [points[0], points[points.length - 1]];
|
||||
const distance = pointDistance(first, last);
|
||||
|
||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||
// really close we make the threshold smaller, and vice versa.
|
||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const toggleLinePolygonState = (
|
||||
element: ExcalidrawLineElement,
|
||||
nextPolygonState: boolean,
|
||||
): {
|
||||
polygon: ExcalidrawLineElement["polygon"];
|
||||
points: ExcalidrawLineElement["points"];
|
||||
} | null => {
|
||||
const updatedPoints = [...element.points];
|
||||
|
||||
if (nextPolygonState) {
|
||||
if (!canBecomePolygon(element.points)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstPoint = updatedPoints[0];
|
||||
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
||||
|
||||
const distance = Math.hypot(
|
||||
firstPoint[0] - lastPoint[0],
|
||||
firstPoint[1] - lastPoint[1],
|
||||
);
|
||||
|
||||
if (
|
||||
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
||||
updatedPoints.length < 4
|
||||
) {
|
||||
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
||||
} else {
|
||||
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
||||
firstPoint[0],
|
||||
firstPoint[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
||||
const ret = {
|
||||
polygon: nextPolygonState,
|
||||
points: updatedPoints,
|
||||
};
|
||||
|
||||
return ret;
|
||||
};
|
||||
@@ -23,8 +23,12 @@ import {
|
||||
syncInvalidIndicesImmutable,
|
||||
hashElementsVersion,
|
||||
hashString,
|
||||
isInitializedImageElement,
|
||||
isImageElement,
|
||||
} from "./index";
|
||||
|
||||
import type { ApplyToOptions } from "./delta";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
@@ -72,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// internally used by history
|
||||
// for internal use by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
// for public use as part of onIncrement API
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
@@ -137,6 +142,8 @@ export class Store {
|
||||
} else {
|
||||
// immediately create an immutable change of the scheduled updates,
|
||||
// compared to the current state, so that they won't mutate later on during batching
|
||||
// also, we have to compare against the current state,
|
||||
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
|
||||
const currentSnapshot = StoreSnapshot.create(
|
||||
this.app.scene.getElementsMapIncludingDeleted(),
|
||||
this.app.state,
|
||||
@@ -233,7 +240,6 @@ export class Store {
|
||||
if (!storeDelta.isEmpty()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
@@ -546,10 +552,26 @@ export class StoreDelta {
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
return new this(id, elements, appState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash the passed deltas into the aggregated delta instance.
|
||||
*/
|
||||
public static squash(...deltas: StoreDelta[]) {
|
||||
const aggregatedDelta = StoreDelta.empty();
|
||||
|
||||
for (const delta of deltas) {
|
||||
aggregatedDelta.elements.squash(delta.elements);
|
||||
aggregatedDelta.appState.squash(delta.appState);
|
||||
}
|
||||
|
||||
return aggregatedDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -566,9 +588,13 @@ export class StoreDelta {
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
delta.elements.applyTo(elements);
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
StoreSnapshot.empty().elements,
|
||||
options,
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
delta.appState.applyTo(appState, nextElements);
|
||||
@@ -601,6 +627,10 @@ export class StoreDelta {
|
||||
);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
@@ -869,7 +899,7 @@ export class StoreSnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if there any changed elements.
|
||||
* Detect if there are any changed elements.
|
||||
*/
|
||||
private detectChangedElements(
|
||||
nextElements: SceneElementsMap,
|
||||
@@ -904,6 +934,14 @@ export class StoreSnapshot {
|
||||
!prevElement || // element was added
|
||||
prevElement.version < nextElement.version // element was updated
|
||||
) {
|
||||
if (
|
||||
isImageElement(nextElement) &&
|
||||
!isInitializedImageElement(nextElement)
|
||||
) {
|
||||
// ignore any updates on uninitialized image elements
|
||||
continue;
|
||||
}
|
||||
|
||||
changedElements.set(nextElement.id, nextElement);
|
||||
}
|
||||
}
|
||||
@@ -958,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
@@ -978,14 +1015,12 @@ export const getObservedAppState = (
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
editingLinearElementId:
|
||||
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
|
||||
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
|
||||
null,
|
||||
selectedLinearElementId:
|
||||
(appState as AppState).selectedLinearElement?.elementId ??
|
||||
(appState as ObservedAppState).selectedLinearElementId ??
|
||||
null,
|
||||
selectedLinearElement: appState.selectedLinearElement
|
||||
? {
|
||||
elementId: appState.selectedLinearElement.elementId,
|
||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@@ -326,10 +326,7 @@ export const getContainerCenter = (
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
||||
@@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
rectangle,
|
||||
@@ -11,14 +19,13 @@ import {
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "./shapes";
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./Shape";
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
@@ -83,9 +90,14 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
shapes.set(offset, shape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||
*
|
||||
* @param element The linear element to deconstruct
|
||||
* @returns The rotated in components.
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
@@ -93,7 +105,7 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element, elementsMap) as {
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
@@ -165,11 +177,11 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves.
|
||||
* line segments and curves **unrotated**.
|
||||
*
|
||||
* @param element Target rectanguloid element
|
||||
* @param offset Optional offset to expand the rectanguloid shape
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
@@ -304,12 +316,12 @@ export function deconstructRectanguloidElement(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a diamond element in the form of
|
||||
* line segments and curves as a tuple, in this order.
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
@@ -428,3 +440,44 @@ export function deconstructDiamondElement(
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (
|
||||
points: ExcalidrawLinearElement["points"],
|
||||
/** supply if you want the loop detection to account for current zoom */
|
||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||
): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [first, last] = [points[0], points[points.length - 1]];
|
||||
const distance = pointDistance(first, last);
|
||||
|
||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||
// really close we make the threshold smaller, and vice versa.
|
||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||
element.roundness?.type === ROUNDNESS.LEGACY
|
||||
) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||
|
||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||
|
||||
if (x <= CUTOFF_SIZE) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
return fixedRadiusSize;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -589,4 +589,424 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||
});
|
||||
|
||||
const createGroupAndSelectInEditGroupMode = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
mouse.reset();
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.doubleClick();
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns elements within a group while in group edit mode correctly to the top", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the left", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the right", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
});
|
||||
|
||||
const createNestedGroupAndSelectInEditGroupMode = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(200, 200);
|
||||
// create third element
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// third element is already selected, select the initial group and group together
|
||||
mouse.reset();
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
// double click to enter edit mode
|
||||
mouse.doubleClick();
|
||||
|
||||
// select nested group and other element within the group
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(200, 200);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns element and nested group while in group edit mode correctly to the top", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the left", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the right", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
|
||||
const createAndSelectSingleGroup = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
};
|
||||
|
||||
it("aligns elements within a single-selected group correctly to the top", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the bottom", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the left", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the right", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the vertical center", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
});
|
||||
|
||||
const createAndSelectSingleGroupWithNestedGroup = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(10, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
// Create the nested group
|
||||
API.executeAction(actionGroup);
|
||||
};
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,10 +155,10 @@ describe("element binding", () => {
|
||||
// NOTE this mouse down/up + await needs to be done in order to repro
|
||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||
mouse.reset();
|
||||
expect(h.state.editingLinearElement).not.toBe(null);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
mouse.down(0, 0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(h.state.editingLinearElement).toBe(null);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
mouse.up();
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("check rotated elements can be hit:", () => {
|
||||
//const p = [120, -211];
|
||||
//const p = [0, 13];
|
||||
const hit = hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(87, -68),
|
||||
point: pointFrom<GlobalPoint>(88, -68),
|
||||
element: window.h.elements[0],
|
||||
threshold: 10,
|
||||
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||
|
||||
@@ -1,13 +1,328 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||
|
||||
describe("ElementsDelta", () => {
|
||||
describe("elements delta calculation", () => {
|
||||
it("should not create removed delta when element gets removed but was already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map([[element.id, element]]);
|
||||
const nextElements = new Map();
|
||||
|
||||
const delta = ElementsDelta.calculate(prevElements, nextElements);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not create added delta when adding element as already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map();
|
||||
const nextElements = new Map([[element.id, element]]);
|
||||
|
||||
const delta = ElementsDelta.calculate(prevElements, nextElements);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not create updated delta when there is only version and versionNonce change", () => {
|
||||
const baseElement = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
const modifiedElement = {
|
||||
...baseElement,
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
};
|
||||
|
||||
// Create maps for the delta calculation
|
||||
const prevElements = new Map([[baseElement.id, baseElement]]);
|
||||
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
||||
|
||||
// Calculate the delta
|
||||
const delta = ElementsDelta.calculate(
|
||||
prevElements as SceneElementsMap,
|
||||
nextElements as SceneElementsMap,
|
||||
);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id1: updatedDelta },
|
||||
);
|
||||
const elementsDelta2 = ElementsDelta.empty();
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash mutually exclusive delta types", () => {
|
||||
const addedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
);
|
||||
|
||||
const removedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
);
|
||||
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{ id1: addedDelta },
|
||||
{ id2: removedDelta },
|
||||
{},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id3: updatedDelta },
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toBe(addedDelta);
|
||||
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
||||
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash the same delta types", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.removed.id2).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.updated.id3).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash different delta types ", () => {
|
||||
// id1: added -> updated => added
|
||||
// id2: removed -> added => added
|
||||
// id3: updated -> removed => removed
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 300, version: 1, versionNonce: 1 },
|
||||
{ x: 301, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 101, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added).toEqual({
|
||||
id1: Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
id2: Delta.create(
|
||||
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.removed).toEqual({
|
||||
id3: Delta.create(
|
||||
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.updated).toEqual({});
|
||||
});
|
||||
|
||||
it("should squash bound elements", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
boundElements: [{ id: "t1", type: "text" }],
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "t2", type: "text" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "a1", type: "arrow" }],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
versionNonce: 3,
|
||||
boundElements: [{ id: "a2", type: "arrow" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
||||
{ id: "t1", type: "text" },
|
||||
{ id: "a1", type: "arrow" },
|
||||
]);
|
||||
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
||||
{ id: "t2", type: "text" },
|
||||
{ id: "a2", type: "arrow" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
const selectedLinearElement = {
|
||||
elementId: "id1" as LinearElementEditor["elementId"],
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
@@ -16,6 +331,7 @@ describe("AppStateDelta", () => {
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
@@ -23,23 +339,23 @@ describe("AppStateDelta", () => {
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
@@ -57,8 +373,7 @@ describe("AppStateDelta", () => {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -104,8 +419,7 @@ describe("AppStateDelta", () => {
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -146,4 +460,97 @@ describe("AppStateDelta", () => {
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const delta = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta);
|
||||
const appStateDelta2 = AppStateDelta.empty();
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toBe(delta);
|
||||
});
|
||||
|
||||
it("should squash exclusive properties", () => {
|
||||
const delta1 = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
const delta2 = Delta.create(
|
||||
{ viewBackgroundColor: "#ffffff" },
|
||||
{ viewBackgroundColor: "#000000" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create(
|
||||
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
||||
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
||||
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true },
|
||||
selectedGroupIds: {},
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
);
|
||||
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
selectedElementIds: { id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
{
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true, id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true, g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
distributeHorizontally,
|
||||
distributeVertically,
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
unmountComponent,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
// Scenario: three rectangles that will be distributed with gaps
|
||||
const createAndSelectThreeRectanglesWithGap = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(300, 300);
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
// Last rectangle is selected by default
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(0, 10);
|
||||
mouse.click(10, 0);
|
||||
});
|
||||
};
|
||||
|
||||
// Scenario: three rectangles that will be distributed by their centers
|
||||
const createAndSelectThreeRectanglesWithoutGap = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(200, 200);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
// Last rectangle is selected by default
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(0, 10);
|
||||
mouse.click(10, 0);
|
||||
});
|
||||
};
|
||||
|
||||
describe("distributing", () => {
|
||||
beforeEach(async () => {
|
||||
unmountComponent();
|
||||
mouse.reset();
|
||||
|
||||
await act(() => {
|
||||
return setLanguage(defaultLang);
|
||||
});
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("should distribute selected elements horizontally", async () => {
|
||||
createAndSelectThreeRectanglesWithGap();
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||
|
||||
API.executeAction(distributeHorizontally);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||
});
|
||||
|
||||
it("should distribute selected elements vertically", async () => {
|
||||
createAndSelectThreeRectanglesWithGap();
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||
|
||||
API.executeAction(distributeVertically);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||
});
|
||||
|
||||
it("should distribute selected elements horizontally based on their centers", async () => {
|
||||
createAndSelectThreeRectanglesWithoutGap();
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(distributeHorizontally);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
|
||||
it("should distribute selected elements vertically with based on their centers", async () => {
|
||||
createAndSelectThreeRectanglesWithoutGap();
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(distributeVertically);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getEmbedLink } from "../src/embeddable";
|
||||
|
||||
describe("YouTube timestamp parsing", () => {
|
||||
it("should parse YouTube URLs with timestamp in seconds", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
|
||||
expectedStart: 90,
|
||||
},
|
||||
{
|
||||
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
|
||||
expectedStart: 120,
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
|
||||
expectedStart: 150,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse YouTube URLs with timestamp in time format", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
|
||||
expectedStart: 90, // 1*60 + 30
|
||||
},
|
||||
{
|
||||
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
|
||||
expectedStart: 165, // 2*60 + 45
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
|
||||
expectedStart: 3723, // 1*3600 + 2*60 + 3
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
|
||||
expectedStart: 45,
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
|
||||
expectedStart: 300, // 5*60
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
|
||||
expectedStart: 7200, // 2*3600
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle YouTube URLs without timestamps", () => {
|
||||
const testCases = [
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
];
|
||||
|
||||
testCases.forEach((url) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).not.toContain("start=");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle YouTube shorts URLs with timestamps", () => {
|
||||
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=30");
|
||||
}
|
||||
// Shorts should have portrait aspect ratio
|
||||
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
||||
});
|
||||
|
||||
it("should handle playlist URLs with timestamps", () => {
|
||||
const url =
|
||||
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=60");
|
||||
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle malformed or edge case timestamps", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
|
||||
expectedStart: 0, // Invalid timestamp should default to 0
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
|
||||
expectedStart: 0, // Empty timestamp should default to 0
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
|
||||
expectedStart: 0, // Zero timestamp should be handled
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
if (expectedStart === 0) {
|
||||
expect(result.link).not.toContain("start=");
|
||||
} else {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve other URL parameters", () => {
|
||||
const url =
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=90");
|
||||
expect(result.link).toContain("enablejsapi=1");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,8 @@ describe("Test Linear Elements", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
|
||||
};
|
||||
|
||||
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
||||
@@ -253,75 +254,82 @@ describe("Test Linear Elements", () => {
|
||||
});
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
||||
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor via enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
// ctrl+enter alias (to align with arrows)
|
||||
it("should enter line editor via ctrl+enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor via ctrl+enter (arrow)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on dblclick (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should not enter line editor on dblclick (arrow)", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement).toEqual(null);
|
||||
expect(h.state.selectedLinearElement).toBe(null);
|
||||
await getTextEditor();
|
||||
});
|
||||
|
||||
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
enterLineEditingMode(arrow);
|
||||
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
@@ -367,7 +377,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -423,12 +433,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -469,7 +479,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -488,12 +498,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"105.96978",
|
||||
"67.44233",
|
||||
"104.27552",
|
||||
"66.16120",
|
||||
],
|
||||
[
|
||||
"126.08587",
|
||||
"63.29417",
|
||||
"126.95494",
|
||||
"64.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -537,7 +547,7 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -588,7 +598,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -629,7 +639,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -677,7 +687,7 @@ describe("Test Linear Elements", () => {
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
`17`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -735,7 +745,7 @@ describe("Test Linear Elements", () => {
|
||||
),
|
||||
);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -804,12 +814,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"31.88408",
|
||||
"23.13276",
|
||||
"29.28349",
|
||||
"20.91105",
|
||||
],
|
||||
[
|
||||
"77.74793",
|
||||
"44.57841",
|
||||
"78.86048",
|
||||
"46.12277",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -833,7 +843,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -893,12 +903,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -1060,8 +1070,8 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
{
|
||||
"x": "85.82202",
|
||||
"y": "75.63461",
|
||||
"x": "86.17305",
|
||||
"y": "76.11251",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element";
|
||||
@@ -38,7 +40,11 @@ export const alignActionsPredicate = (
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -52,7 +58,12 @@ const alignSelectedElements = (
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
||||
const updatedElements = alignElements(
|
||||
selectedElements,
|
||||
alignment,
|
||||
app.scene,
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -205,16 +205,19 @@ export const actionDeleteSelected = register({
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
const {
|
||||
elementId,
|
||||
selectedPointsIndices,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
} = appState.selectedLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
const linearElement = LinearElementEditor.getElement(
|
||||
elementId,
|
||||
elementsMap,
|
||||
);
|
||||
if (!linearElement) {
|
||||
return false;
|
||||
}
|
||||
// case: no point selected → do nothing, as deleting the whole element
|
||||
@@ -225,10 +228,10 @@ export const actionDeleteSelected = register({
|
||||
return false;
|
||||
}
|
||||
|
||||
// case: deleting last remaining point
|
||||
if (element.points.length < 2) {
|
||||
// case: deleting all points
|
||||
if (selectedPointsIndices.length >= linearElement.points.length) {
|
||||
const nextElements = elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
if (el.id === linearElement.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
@@ -239,7 +242,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -252,20 +255,24 @@ export const actionDeleteSelected = register({
|
||||
? null
|
||||
: startBindingElement,
|
||||
endBindingElement: selectedPointsIndices?.includes(
|
||||
element.points.length - 1,
|
||||
linearElement.points.length - 1,
|
||||
)
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
linearElement,
|
||||
app,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
...binding,
|
||||
selectedPointsIndices:
|
||||
selectedPointsIndices?.[0] > 0
|
||||
@@ -291,7 +298,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,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Distribution } from "@excalidraw/element";
|
||||
@@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 2 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -49,6 +55,7 @@ const distributeSelectedElements = (
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
distribution,
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
@@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
|
||||
}
|
||||
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||
try {
|
||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isValidPolygon,
|
||||
LinearElementEditor,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
@@ -14,7 +18,12 @@ import {
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
tupleToCoors,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element";
|
||||
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
@@ -43,12 +52,16 @@ export const actionFinalize = register({
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, data, app) => {
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
|
||||
const { event, sceneCoords } =
|
||||
(data as {
|
||||
event?: PointerEvent;
|
||||
sceneCoords?: { x: number; y: number };
|
||||
}) ?? {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (data?.event && appState.selectedLinearElement) {
|
||||
if (event && appState.selectedLinearElement) {
|
||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||
data.event,
|
||||
event,
|
||||
appState.selectedLinearElement,
|
||||
appState,
|
||||
app.scene,
|
||||
@@ -69,7 +82,14 @@ export const actionFinalize = register({
|
||||
let newElements = elements;
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, {
|
||||
isDeleted: true,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
return {
|
||||
elements: newElements,
|
||||
@@ -85,9 +105,9 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
appState.selectedLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (element) {
|
||||
@@ -108,12 +128,21 @@ export const actionFinalize = register({
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
? elements.filter((el) => el.id !== element.id)
|
||||
? elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
})
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
element,
|
||||
arrayToMap(elementsMap),
|
||||
false, // exit editing mode
|
||||
),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -145,11 +174,7 @@ export const actionFinalize = register({
|
||||
|
||||
if (element) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
appState.multiElement &&
|
||||
element.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
if (appState.multiElement && element.type !== "freedraw") {
|
||||
const { points, lastCommittedPoint } = element;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
@@ -163,7 +188,12 @@ export const actionFinalize = register({
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element?.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
@@ -204,12 +234,17 @@ export const actionFinalize = register({
|
||||
element.points.length > 1 &&
|
||||
isBindingEnabled(appState)
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(element, appState, { x, y }, scene);
|
||||
const coords =
|
||||
sceneCoords ??
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
),
|
||||
);
|
||||
|
||||
maybeBindLinearElement(element, appState, coords, scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,13 +261,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,7 +310,7 @@ export const actionFinalize = register({
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
(appState.editingLinearElement !== null ||
|
||||
(appState.selectedLinearElement?.isEditing ||
|
||||
(!appState.newElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { arrayToMap, invariant } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import {
|
||||
toggleLinePolygonState,
|
||||
CaptureUpdateAction,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawLinearElement,
|
||||
@@ -22,8 +24,6 @@ import { ButtonIcon } from "../components/ButtonIcon";
|
||||
|
||||
import { newElementWith } from "../../element/src/mutateElement";
|
||||
|
||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
@@ -45,7 +45,7 @@ export const actionToggleLinearEditor = register({
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
@@ -60,14 +60,25 @@ export const actionToggleLinearEditor = register({
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
||||
invariant(selectedElement, "No selected element found");
|
||||
invariant(
|
||||
appState.selectedLinearElement,
|
||||
"No selected linear element found",
|
||||
);
|
||||
invariant(
|
||||
selectedElement.id === appState.selectedLinearElement.elementId,
|
||||
"Selected element ID and linear editor elementId does not match",
|
||||
);
|
||||
|
||||
const selectedLinearElement = {
|
||||
...appState.selectedLinearElement,
|
||||
isEditing: !appState.selectedLinearElement.isEditing,
|
||||
};
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
selectedLinearElement,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
||||
@@ -52,9 +52,11 @@ import {
|
||||
|
||||
import { hasStrokeColor } from "@excalidraw/element";
|
||||
|
||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import {
|
||||
updateElbowArrowPoints,
|
||||
CaptureUpdateAction,
|
||||
toggleLinePolygonState,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
@@ -135,8 +137,6 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
|
||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
|
||||
@@ -21,7 +21,7 @@ export const actionSelectAll = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export const getDefaultAppState = (): Omit<
|
||||
newElement: null,
|
||||
editingTextElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
customType: null,
|
||||
@@ -175,7 +174,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
newElement: { browser: false, export: false, server: false },
|
||||
editingTextElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
|
||||
import { SHAPES } from "./shapes";
|
||||
import { getToolbarTools } from "./shapes";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
@@ -140,7 +140,7 @@ export const SelectedShapeActions = ({
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
const showLineEditorAction =
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
@@ -295,7 +295,8 @@ 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";
|
||||
|
||||
@@ -303,63 +304,68 @@ export const ShapesSwitcher = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
{getToolbarTools(app).map(
|
||||
({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<
|
||||
typeof value,
|
||||
keyof AppProps["UIOptions"]["tools"]
|
||||
>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
@@ -418,14 +424,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>
|
||||
@@ -505,15 +513,3 @@ export const ExitZenModeAction = ({
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const FinalizeAction = ({
|
||||
renderAction,
|
||||
className,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`finalize-button ${className}`}>
|
||||
{renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { useStable } from "../../hooks/useStable";
|
||||
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||
|
||||
import "./CommandPalette.scss";
|
||||
@@ -964,7 +966,7 @@ const CommandItem = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{command.label}
|
||||
<Ellipsify>{command.label}</Ellipsify>
|
||||
</div>
|
||||
{showShortcut && command.shortcut && (
|
||||
<CommandShortcutHint shortcut={command.shortcut} />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const Ellipsify = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
{...rest}
|
||||
style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
...rest.style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -115,7 +115,7 @@ const getHints = ({
|
||||
appState.selectionElement &&
|
||||
!selectedElements.length &&
|
||||
!appState.editingTextElement &&
|
||||
!appState.editingLinearElement
|
||||
!appState.selectedLinearElement?.isEditing
|
||||
) {
|
||||
return [t("hints.deepBoxSelect")];
|
||||
}
|
||||
@@ -130,8 +130,8 @@ const getHints = ({
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
if (isLinearElement(selectedElements[0])) {
|
||||
if (appState.editingLinearElement) {
|
||||
return appState.editingLinearElement.selectedPointsIndices
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return appState.selectedLinearElement.selectedPointsIndices
|
||||
? t("hints.lineEditor_pointSelected")
|
||||
: t("hints.lineEditor_nothingSelected");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
||||
display: "inline-block",
|
||||
lineHeight: 0,
|
||||
verticalAlign: "middle",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
} from "@excalidraw/element";
|
||||
import { resizeSingleElement } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { getElementsInResizingFrame } from "@excalidraw/element";
|
||||
import { replaceAllElementsInFrame } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element";
|
||||
import DragInput from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface DimensionDragInputProps {
|
||||
@@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
originalAppState,
|
||||
instantChange,
|
||||
scene,
|
||||
app,
|
||||
setAppState,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements[0];
|
||||
@@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
return;
|
||||
}
|
||||
|
||||
// User types in a value to stats then presses Enter
|
||||
if (nextValue !== undefined) {
|
||||
const nextWidth = Math.max(
|
||||
property === "width"
|
||||
@@ -184,52 +193,123 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
},
|
||||
);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
// Stats slider is dragged
|
||||
{
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (keepAspectRatio) {
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (keepAspectRatio) {
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
|
||||
resizeSingleElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
latestElement,
|
||||
origElement,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldMaintainAspectRatio: keepAspectRatio,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle highlighting frame element candidates
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: nextElementsInFrame,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
const handleDragFinished: DragFinishedCallbackType = ({
|
||||
setAppState,
|
||||
app,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements?.[0];
|
||||
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||
|
||||
resizeSingleElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
// Handle frame membership update for resized frames
|
||||
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
origElement,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldMaintainAspectRatio: keepAspectRatio,
|
||||
},
|
||||
originalAppState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,6 +349,7 @@ const DimensionDragInput = ({
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { useApp } from "../App";
|
||||
import { useApp, useExcalidrawSetAppState } from "../App";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
|
||||
import { SMALLEST_DELTA } from "./utils";
|
||||
@@ -36,6 +36,15 @@ export type DragInputCallbackType<
|
||||
property: P;
|
||||
originalAppState: AppState;
|
||||
setInputValue: (value: number) => void;
|
||||
app: ReturnType<typeof useApp>;
|
||||
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||
}) => void;
|
||||
|
||||
export type DragFinishedCallbackType<E = ExcalidrawElement> = (props: {
|
||||
app: ReturnType<typeof useApp>;
|
||||
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||
originalElements: readonly E[] | null;
|
||||
originalAppState: AppState;
|
||||
}) => void;
|
||||
|
||||
interface StatsDragInputProps<
|
||||
@@ -54,6 +63,7 @@ interface StatsDragInputProps<
|
||||
appState: AppState;
|
||||
/** how many px you need to drag to get 1 unit change */
|
||||
sensitivity?: number;
|
||||
dragFinishedCallback?: DragFinishedCallbackType;
|
||||
}
|
||||
|
||||
const StatsDragInput = <
|
||||
@@ -71,8 +81,10 @@ const StatsDragInput = <
|
||||
scene,
|
||||
appState,
|
||||
sensitivity = 1,
|
||||
dragFinishedCallback,
|
||||
}: StatsDragInputProps<T, E>) => {
|
||||
const app = useApp();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -137,6 +149,8 @@ const StatsDragInput = <
|
||||
property,
|
||||
originalAppState: appState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
app.syncActionResult({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -263,6 +277,8 @@ const StatsDragInput = <
|
||||
scene,
|
||||
originalAppState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
|
||||
stepChange = 0;
|
||||
@@ -287,6 +303,14 @@ const StatsDragInput = <
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
// Notify implementors
|
||||
dragFinishedCallback?.({
|
||||
app,
|
||||
setAppState,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
});
|
||||
|
||||
lastPointer = null;
|
||||
accumulatedChange = 0;
|
||||
stepChange = 0;
|
||||
|
||||
@@ -2,7 +2,12 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
||||
import { updateBoundElements } from "@excalidraw/element";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
isFrameLikeElement,
|
||||
replaceAllElementsInFrame,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
import {
|
||||
rescalePointsInElement,
|
||||
resizeSingleElement,
|
||||
@@ -25,7 +30,10 @@ import DragInput from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
@@ -153,6 +161,8 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextValue,
|
||||
scene,
|
||||
property,
|
||||
setAppState,
|
||||
app,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||
@@ -239,6 +249,25 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
shouldInformMutation: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +279,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
const elementsToHighlight: ExcalidrawElement[] = [];
|
||||
|
||||
for (const atomicUnit of atomicUnits) {
|
||||
const elementsInUnit = getElementsInAtomicUnit(
|
||||
@@ -342,13 +372,63 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
shouldInformMutation: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle highlighting frame element candidates
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
elementsToHighlight.push(...nextElementsInFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight,
|
||||
});
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
const handleDragFinished: DragFinishedCallbackType = ({
|
||||
setAppState,
|
||||
app,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements?.[0];
|
||||
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const MultiDimension = ({
|
||||
property,
|
||||
elements,
|
||||
@@ -396,6 +476,7 @@ const MultiDimension = ({
|
||||
appState={appState}
|
||||
property={property}
|
||||
scene={scene}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -401,11 +401,23 @@ describe("stats for a non-generic element", () => {
|
||||
UI.updateInput(input, "36");
|
||||
expect(text.fontSize).toBe(36);
|
||||
|
||||
// cannot change width or height
|
||||
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
|
||||
expect(width).toBeUndefined();
|
||||
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
|
||||
expect(height).toBeUndefined();
|
||||
// can change width or height
|
||||
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width).toBeDefined();
|
||||
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).toBeDefined();
|
||||
|
||||
const textHeightBeforeWrapping = text.height;
|
||||
const textBeforeWrapping = text.text;
|
||||
const originalTextBeforeWrapping = textBeforeWrapping;
|
||||
UI.updateInput(width, "30");
|
||||
expect(text.height).toBeGreaterThan(textHeightBeforeWrapping);
|
||||
expect(text.text).not.toBe(textBeforeWrapping);
|
||||
expect(text.originalText).toBe(originalTextBeforeWrapping);
|
||||
|
||||
// min font size is 4
|
||||
UI.updateInput(input, "0");
|
||||
@@ -627,12 +639,11 @@ describe("stats for multiple elements", () => {
|
||||
) as HTMLInputElement;
|
||||
expect(fontSize).toBeDefined();
|
||||
|
||||
// changing width does not affect text
|
||||
UI.updateInput(width, "200");
|
||||
|
||||
expect(rectangle?.width).toBe(200);
|
||||
expect(frame.width).toBe(200);
|
||||
expect(text?.width).not.toBe(200);
|
||||
expect(text?.width).toBe(200);
|
||||
|
||||
UI.updateInput(angle, "40");
|
||||
|
||||
@@ -726,3 +737,196 @@ describe("stats for multiple elements", () => {
|
||||
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame resizing behavior", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
API.setElements([]);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
stats = UI.queryStats();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should add shapes to frame when resizing frame to encompass them", () => {
|
||||
// Create a frame
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
// Create a rectangle outside the frame
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 150,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle]);
|
||||
|
||||
// Initially, rectangle should not be in the frame
|
||||
expect(rectangle.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Find the width input and update it to encompass the rectangle
|
||||
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(widthInput).toBeDefined();
|
||||
expect(widthInput.value).toBe("100");
|
||||
|
||||
// Resize frame to width 250, which should encompass the rectangle
|
||||
UI.updateInput(widthInput, "250");
|
||||
|
||||
// After resizing, the rectangle should now be part of the frame
|
||||
expect(h.elements.find((el) => el.id === rectangle.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add multiple shapes when frame encompasses them through height resize", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 180,
|
||||
width: 40,
|
||||
height: 40,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle1, rectangle2]);
|
||||
|
||||
// Initially, rectangles should not be in the frame
|
||||
expect(rectangle1.frameId).toBe(null);
|
||||
expect(rectangle2.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Resize frame height to encompass both rectangles
|
||||
const heightInput = UI.queryStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
// Resize frame to height 250, which should encompass both rectangles
|
||||
UI.updateInput(heightInput, "250");
|
||||
|
||||
// After resizing, both rectangles should now be part of the frame
|
||||
expect(h.elements.find((el) => el.id === rectangle1.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
expect(h.elements.find((el) => el.id === rectangle2.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect shapes that remain outside frame after resize", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const insideRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 120,
|
||||
y: 50,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
const outsideRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 50,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
API.setElements([frame, insideRect, outsideRect]);
|
||||
|
||||
// Initially, both rectangles should not be in the frame
|
||||
expect(insideRect.frameId).toBe(null);
|
||||
expect(outsideRect.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Resize frame width to 200, which should only encompass insideRect
|
||||
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
UI.updateInput(widthInput, "200");
|
||||
|
||||
// After resizing, only insideRect should be in the frame
|
||||
expect(h.elements.find((el) => el.id === insideRect.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
expect(h.elements.find((el) => el.id === outsideRect.id)?.frameId).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
|
||||
import { getBoundTextElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement, isTextElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
@@ -41,12 +41,6 @@ export const isPropertyEditable = (
|
||||
element: ExcalidrawElement,
|
||||
property: keyof ExcalidrawElement,
|
||||
) => {
|
||||
if (property === "height" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "width" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "angle" && isFrameLikeElement(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,6 @@ const getRelevantAppStateProps = (
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
openDialog: appState.openDialog,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
|
||||
@@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const isComponentMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
props.canvas.style.width = `${props.appState.width}px`;
|
||||
props.canvas.style.height = `${props.appState.height}px`;
|
||||
props.canvas.width = props.appState.width * props.scale;
|
||||
props.canvas.height = props.appState.height * props.scale;
|
||||
}, [props.appState.height, props.appState.width, props.canvas, props.scale]);
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) {
|
||||
@@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
canvas.classList.add("excalidraw__canvas", "static");
|
||||
}
|
||||
|
||||
const widthString = `${props.appState.width}px`;
|
||||
const heightString = `${props.appState.height}px`;
|
||||
if (canvas.style.width !== widthString) {
|
||||
canvas.style.width = widthString;
|
||||
}
|
||||
if (canvas.style.height !== heightString) {
|
||||
canvas.style.height = heightString;
|
||||
}
|
||||
|
||||
const scaledWidth = props.appState.width * props.scale;
|
||||
const scaledHeight = props.appState.height * props.scale;
|
||||
// setting width/height resets the canvas even if dimensions not changed,
|
||||
// which would cause flicker when we skip frame (due to throttling)
|
||||
if (canvas.width !== scaledWidth) {
|
||||
canvas.width = scaledWidth;
|
||||
}
|
||||
if (canvas.height !== scaledHeight) {
|
||||
canvas.height = scaledHeight;
|
||||
}
|
||||
|
||||
renderStaticScene(
|
||||
{
|
||||
canvas,
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
@@ -100,6 +102,7 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
flex: 1 0 auto;
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
height: 2.25rem;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useDevice } from "../App";
|
||||
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const MenuItemContent = ({
|
||||
@@ -18,7 +20,7 @@ const MenuItemContent = ({
|
||||
<>
|
||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||
<div style={textStyle} className="dropdown-menu-item__text">
|
||||
{children}
|
||||
<Ellipsify>{children}</Ellipsify>
|
||||
</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
|
||||
@@ -2,13 +2,7 @@ import clsx from "clsx";
|
||||
|
||||
import { actionShortcuts } from "../../actions";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import {
|
||||
ExitZenModeAction,
|
||||
FinalizeAction,
|
||||
UndoRedoActions,
|
||||
ZoomActions,
|
||||
} from "../Actions";
|
||||
import { useDevice } from "../App";
|
||||
import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
|
||||
import { HelpButton } from "../HelpButton";
|
||||
import { Section } from "../Section";
|
||||
import Stack from "../Stack";
|
||||
@@ -29,10 +23,6 @@ const Footer = ({
|
||||
}) => {
|
||||
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
||||
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
@@ -60,15 +50,6 @@ const Footer = ({
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{showFinalize && (
|
||||
<FinalizeAction
|
||||
renderAction={actionManager.renderAction}
|
||||
className={clsx("zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EraserIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const SHAPES = [
|
||||
{
|
||||
icon: SelectionIcon,
|
||||
@@ -86,8 +88,23 @@ export const SHAPES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
const shape = SHAPES.find((shape, index) => {
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
return app.defaultSelectionTool === "lasso"
|
||||
? ([
|
||||
{
|
||||
value: "lasso",
|
||||
icon: SelectionIcon,
|
||||
key: KEYS.V,
|
||||
numericKey: KEYS["1"],
|
||||
fillable: true,
|
||||
},
|
||||
...SHAPES.slice(1),
|
||||
] as const)
|
||||
: SHAPES;
|
||||
};
|
||||
|
||||
export const findShapeByKey = (key: string, app: AppClassProperties) => {
|
||||
const shape = getToolbarTools(app).find((shape, index) => {
|
||||
return (
|
||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||
(shape.key &&
|
||||
|
||||
@@ -1791,7 +1791,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 120,
|
||||
"x": 187.7545,
|
||||
"x": 187.75450000000004,
|
||||
"y": 44.5,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
||||
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||
MakeBrand<"RemoteExcalidrawElement">;
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
export const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: OrderedExcalidrawElement | undefined,
|
||||
remote: RemoteExcalidrawElement,
|
||||
@@ -30,7 +30,7 @@ const shouldDiscardRemoteElement = (
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingTextElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||
local.id === localAppState.newElement?.id ||
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
|
||||
@@ -241,8 +241,9 @@ const restoreElementWithProperties = <
|
||||
return ret;
|
||||
};
|
||||
|
||||
const restoreElement = (
|
||||
export const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
opts?: { deleteInvisibleElements?: boolean },
|
||||
): typeof element | null => {
|
||||
element = { ...element };
|
||||
|
||||
@@ -290,7 +291,8 @@ const restoreElement = (
|
||||
|
||||
// if empty text, mark as deleted. We keep in array
|
||||
// for data integrity purposes (collab etc.)
|
||||
if (!text && !element.isDeleted) {
|
||||
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
|
||||
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
|
||||
element = { ...element, originalText: text, isDeleted: true };
|
||||
element = bumpVersion(element);
|
||||
}
|
||||
@@ -523,7 +525,13 @@ export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||
opts?:
|
||||
| {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
@@ -532,24 +540,38 @@ export const restoreElements = (
|
||||
(elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(
|
||||
migratedElement,
|
||||
localElement.version,
|
||||
);
|
||||
}
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
if (element.type === "selection") {
|
||||
return elements;
|
||||
}
|
||||
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
|
||||
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
||||
});
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
|
||||
const shouldMarkAsDeleted =
|
||||
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
||||
|
||||
if (
|
||||
shouldMarkAsDeleted ||
|
||||
(localElement && localElement.version > migratedElement.version)
|
||||
) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
||||
}
|
||||
|
||||
if (shouldMarkAsDeleted) {
|
||||
migratedElement = { ...migratedElement, isDeleted: true };
|
||||
}
|
||||
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}, [] as ExcalidrawElement[]),
|
||||
);
|
||||
@@ -790,7 +812,11 @@ export const restore = (
|
||||
*/
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
|
||||
elementsConfig?: {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
},
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
||||
|
||||
@@ -175,7 +175,7 @@ export class History {
|
||||
let nextAppState = appState;
|
||||
let containsVisibleChange = false;
|
||||
|
||||
// iterate through the history entries in case ;they result in no visible changes
|
||||
// iterate through the history entries in case they result in no visible changes
|
||||
while (historyDelta) {
|
||||
try {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
|
||||
@@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
|
||||
export {
|
||||
restore,
|
||||
restoreAppState,
|
||||
restoreElement,
|
||||
restoreElements,
|
||||
restoreLibraryItems,
|
||||
} from "./data/restore";
|
||||
@@ -281,6 +282,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar";
|
||||
export { Button } from "./components/Button";
|
||||
export { Footer };
|
||||
export { MainMenu };
|
||||
export { Ellipsify } from "./components/Ellipsify";
|
||||
export { useDevice } from "./components/App";
|
||||
export { WelcomeScreen };
|
||||
export { LiveCollaborationTrigger };
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
|
||||
import { THEME, THEME_FILTER } from "@excalidraw/common";
|
||||
|
||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
|
||||
import { getDiamondPoints } from "@excalidraw/element";
|
||||
import { getCornerRadius } from "@excalidraw/element";
|
||||
import { elementCenterPoint, getCornerRadius } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
curve,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "@excalidraw/element/types";
|
||||
@@ -128,14 +129,12 @@ function drawCatmullRomCubicApprox(
|
||||
export const drawHighlightForRectWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
padding: number,
|
||||
) => {
|
||||
const [x, y] = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
elementCenterPoint(
|
||||
element,
|
||||
window.h.app.scene.getElementsMapIncludingDeleted(),
|
||||
),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -289,13 +288,11 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
padding: number,
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const [x, y] = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
elementCenterPoint(
|
||||
element,
|
||||
window.h.app.scene.getElementsMapIncludingDeleted(),
|
||||
),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
element.angle,
|
||||
);
|
||||
context.save();
|
||||
|
||||
@@ -118,7 +118,8 @@ const renderLinearElementPointHighlight = (
|
||||
) => {
|
||||
const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
|
||||
if (
|
||||
appState.editingLinearElement?.selectedPointsIndices?.includes(
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement?.selectedPointsIndices?.includes(
|
||||
hoverPointIndex,
|
||||
)
|
||||
) {
|
||||
@@ -180,7 +181,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
point[0],
|
||||
point[1],
|
||||
(isOverlappingPoint
|
||||
? radius * (appState.editingLinearElement ? 1.5 : 2)
|
||||
? radius * (appState.selectedLinearElement?.isEditing ? 1.5 : 2)
|
||||
: radius) / appState.zoom.value,
|
||||
!isPhantomPoint,
|
||||
!isOverlappingPoint || isSelected,
|
||||
@@ -205,10 +206,15 @@ const renderBindingHighlightForBindableElement = (
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
drawHighlightForRectWithRotation(context, element, padding);
|
||||
drawHighlightForRectWithRotation(context, element, elementsMap, padding);
|
||||
break;
|
||||
case "diamond":
|
||||
drawHighlightForDiamondWithRotation(context, padding, element);
|
||||
drawHighlightForDiamondWithRotation(
|
||||
context,
|
||||
padding,
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
break;
|
||||
case "ellipse": {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
@@ -443,7 +449,7 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
||||
const radius = appState.editingLinearElement
|
||||
const radius = appState.selectedLinearElement?.isEditing
|
||||
? POINT_HANDLE_SIZE
|
||||
: POINT_HANDLE_SIZE / 2;
|
||||
|
||||
@@ -465,7 +471,8 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
let isSelected =
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
!!appState.selectedLinearElement?.isEditing &&
|
||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
// when element is a polygon, highlight the last point as well if first
|
||||
// point is selected since they overlap and the last point tends to be
|
||||
// rendered on top
|
||||
@@ -474,7 +481,8 @@ const renderLinearPointHandles = (
|
||||
element.polygon &&
|
||||
!isSelected &&
|
||||
idx === element.points.length - 1 &&
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
|
||||
!!appState.selectedLinearElement?.isEditing &&
|
||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(0)
|
||||
) {
|
||||
isSelected = true;
|
||||
}
|
||||
@@ -530,7 +538,7 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
midPoints.forEach((segmentMidPoint) => {
|
||||
if (appState.editingLinearElement || points.length === 2) {
|
||||
if (appState.selectedLinearElement?.isEditing || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
@@ -755,7 +763,10 @@ const _renderInteractiveScene = ({
|
||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||
// ShapeCache returns empty hence making sure that we get the
|
||||
// correct element from visible elements
|
||||
if (appState.editingLinearElement?.elementId === element.id) {
|
||||
if (
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement.elementId === element.id
|
||||
) {
|
||||
if (element) {
|
||||
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
|
||||
}
|
||||
@@ -848,7 +859,8 @@ const _renderInteractiveScene = ({
|
||||
// correct element from visible elements
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
appState.editingLinearElement?.elementId === selectedElements[0].id
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement.elementId === selectedElements[0].id
|
||||
) {
|
||||
renderLinearPointHandles(
|
||||
context,
|
||||
@@ -879,7 +891,7 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||
if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
|
||||
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
||||
|
||||
const isSingleLinearElementSelected =
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
|
||||
import { renderElement } from "@excalidraw/element";
|
||||
import {
|
||||
getTargetFrame,
|
||||
isInvisiblySmallElement,
|
||||
renderElement,
|
||||
shouldApplyFrameClip,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
||||
|
||||
import { frameClip } from "./staticScene";
|
||||
|
||||
import type { NewElementSceneRenderConfig } from "../scene/types";
|
||||
|
||||
const _renderNewElementScene = ({
|
||||
@@ -29,11 +36,37 @@ const _renderNewElementScene = ({
|
||||
normalizedHeight,
|
||||
});
|
||||
|
||||
// Apply zoom
|
||||
context.save();
|
||||
|
||||
// Apply zoom
|
||||
context.scale(appState.zoom.value, appState.zoom.value);
|
||||
|
||||
if (newElement && newElement.type !== "selection") {
|
||||
// e.g. when creating arrows and we're still below the arrow drag distance
|
||||
// threshold
|
||||
// (for now we skip render only with elements while we're creating to be
|
||||
// safe)
|
||||
if (isInvisiblySmallElement(newElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = newElement.frameId || appState.frameToHighlight?.id;
|
||||
|
||||
if (
|
||||
frameId &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
const frame = getTargetFrame(newElement, elementsMap, appState);
|
||||
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
}
|
||||
|
||||
renderElement(
|
||||
newElement,
|
||||
elementsMap,
|
||||
@@ -46,6 +79,8 @@ const _renderNewElementScene = ({
|
||||
} else {
|
||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ const strokeGrid = (
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const frameClip = (
|
||||
export const frameClip = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getDraggedElementsBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
|
||||
import { isBoundToContainer } from "@excalidraw/element";
|
||||
|
||||
import { getMaximumGroups } from "@excalidraw/element";
|
||||
|
||||
@@ -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] &&
|
||||
@@ -311,20 +317,13 @@ const getReferenceElements = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const selectedFrames = selectedElements
|
||||
.filter((element) => isFrameLikeElement(element))
|
||||
.map((frame) => frame.id);
|
||||
|
||||
return getVisibleAndNonSelectedElements(
|
||||
) =>
|
||||
getVisibleAndNonSelectedElements(
|
||||
elements,
|
||||
selectedElements,
|
||||
appState,
|
||||
elementsMap,
|
||||
).filter(
|
||||
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getVisibleGaps = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
|
||||
@@ -908,7 +908,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1106,7 +1105,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1319,7 +1317,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1649,7 +1646,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1979,7 +1975,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2192,7 +2187,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2432,7 +2426,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2729,7 +2722,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3100,7 +3092,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3592,7 +3583,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3692,14 +3682,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 +3714,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,
|
||||
@@ -3914,7 +3904,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -4236,7 +4225,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -5520,7 +5508,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -6736,7 +6723,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -7670,7 +7656,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8669,7 +8654,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -9659,7 +9643,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
|
||||
@@ -15,7 +15,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Click me
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Click me
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
@@ -27,7 +31,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Excalidraw blog
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Excalidraw blog
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
@@ -88,7 +96,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Help
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -138,7 +150,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Open
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -175,7 +191,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Save to...
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Save to...
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -231,7 +251,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Export image...
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Export image...
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -280,7 +304,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Find on canvas
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Find on canvas
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -337,7 +365,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Help
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -374,7 +406,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Reset the canvas
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Reset the canvas
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
@@ -419,7 +455,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
GitHub
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
GitHub
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
@@ -465,7 +505,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Follow us
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Follow us
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
@@ -505,7 +549,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Discord chat
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Discord chat
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -542,7 +590,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Dark mode
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Dark mode
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user