mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge branch 'master' into mrazator/delta-based-sync
This commit is contained in:
commit
de81ba25fd
582 changed files with 24885 additions and 15321 deletions
3
packages/common/.eslintrc.json
Normal file
3
packages/common/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../eslintrc.base.json"]
|
||||
}
|
19
packages/common/README.md
Normal file
19
packages/common/README.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
# @excalidraw/common
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @excalidraw/common
|
||||
```
|
||||
|
||||
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
|
||||
|
||||
```bash
|
||||
yarn add @excalidraw/common
|
||||
```
|
||||
|
||||
With PNPM, similarly install the package with this command:
|
||||
|
||||
```bash
|
||||
pnpm add @excalidraw/common
|
||||
```
|
3
packages/common/global.d.ts
vendored
Normal file
3
packages/common/global.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
56
packages/common/package.json
Normal file
56
packages/common/package.json
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
"module": "./dist/prod/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./../common/dist/types/common/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"description": "Excalidraw common functions, constants, etc.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"excalidraw",
|
||||
"excalidraw-utils"
|
||||
],
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not safari < 12",
|
||||
"not kaios <= 2.5",
|
||||
"not edge < 79",
|
||||
"not chrome < 70",
|
||||
"not and_uc < 13",
|
||||
"not samsung < 10"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
export default class BinaryHeap<T> {
|
||||
export class BinaryHeap<T> {
|
||||
private content: T[] = [];
|
||||
|
||||
constructor(private scoreFunction: (node: T) => number) {}
|
|
@ -1,4 +1,5 @@
|
|||
import oc from "open-color";
|
||||
|
||||
import type { Merge } from "./utility-types";
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
|
@ -1,6 +1,9 @@
|
|||
import cssVariables from "./css/variables.module.scss";
|
||||
import type { AppProps, AppState } from "./types";
|
||||
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FontFamilyValues,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
|
@ -188,7 +191,7 @@ export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
|||
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
|
||||
// a small epsilon to make side resizing always take precedence
|
||||
// (avoids an increase in renders and changes to tests)
|
||||
const EPSILON = 0.00001;
|
||||
export const EPSILON = 0.00001;
|
||||
export const DEFAULT_COLLISION_THRESHOLD =
|
||||
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
|
||||
|
||||
|
@ -269,7 +272,8 @@ export const IDLE_THRESHOLD = 60_000;
|
|||
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
||||
export const ACTIVE_THRESHOLD = 3_000;
|
||||
|
||||
export const THEME_FILTER = cssVariables.themeFilter;
|
||||
// duplicates --theme-filter, should be removed soon
|
||||
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
|
||||
|
||||
export const URL_QUERY_KEYS = {
|
||||
addLibrary: "addLibrary",
|
||||
|
@ -304,8 +308,6 @@ export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
|||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
|
||||
|
||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
export const EXPORT_SCALES = [1, 2, 3];
|
||||
|
@ -458,3 +460,12 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
|
|||
|
||||
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
||||
export const ELEMENT_LINK_KEY = "element";
|
||||
|
||||
/** used in tests */
|
||||
export const ORIG_ID = Symbol.for("__test__originalId__");
|
||||
|
||||
export enum UserIdleState {
|
||||
ACTIVE = "active",
|
||||
AWAY = "away",
|
||||
IDLE = "idle",
|
||||
}
|
|
@ -1,10 +1,9 @@
|
|||
import {
|
||||
FreedrawIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyHeadingIcon,
|
||||
FontFamilyCodeIcon,
|
||||
} from "../components/icons";
|
||||
import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "../constants";
|
||||
import type {
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "./constants";
|
||||
|
||||
/**
|
||||
* Encapsulates font metrics with additional font metadata.
|
||||
|
@ -21,8 +20,6 @@ export interface FontMetadata {
|
|||
/** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */
|
||||
lineHeight: number;
|
||||
};
|
||||
/** element to be displayed as an icon */
|
||||
icon?: JSX.Element;
|
||||
/** flag to indicate a deprecated font */
|
||||
deprecated?: true;
|
||||
/** flag to indicate a server-side only font */
|
||||
|
@ -41,7 +38,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
descender: -374,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
icon: FreedrawIcon,
|
||||
},
|
||||
[FONT_FAMILY.Nunito]: {
|
||||
metrics: {
|
||||
|
@ -50,7 +46,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
descender: -353,
|
||||
lineHeight: 1.35,
|
||||
},
|
||||
icon: FontFamilyNormalIcon,
|
||||
},
|
||||
[FONT_FAMILY["Lilita One"]]: {
|
||||
metrics: {
|
||||
|
@ -59,7 +54,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
descender: -220,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
icon: FontFamilyHeadingIcon,
|
||||
},
|
||||
[FONT_FAMILY["Comic Shanns"]]: {
|
||||
metrics: {
|
||||
|
@ -68,7 +62,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
descender: -250,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
icon: FontFamilyCodeIcon,
|
||||
},
|
||||
[FONT_FAMILY.Virgil]: {
|
||||
metrics: {
|
||||
|
@ -77,7 +70,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
descender: -374,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
icon: FreedrawIcon,
|
||||
deprecated: true,
|
||||
},
|
||||
[FONT_FAMILY.Helvetica]: {
|
||||
|
@ -87,7 +79,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
descender: -471,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
icon: FontFamilyNormalIcon,
|
||||
deprecated: true,
|
||||
local: true,
|
||||
},
|
||||
|
@ -98,7 +89,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
descender: -480,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
icon: FontFamilyCodeIcon,
|
||||
deprecated: true,
|
||||
},
|
||||
[FONT_FAMILY["Liberation Sans"]]: {
|
||||
|
@ -147,3 +137,34 @@ export const GOOGLE_FONTS_RANGES = {
|
|||
|
||||
/** local protocol to skip the local font from registering or inlining */
|
||||
export const LOCAL_FONT_PROTOCOL = "local:";
|
||||
|
||||
/**
|
||||
* Calculates vertical offset for a text with alphabetic baseline.
|
||||
*/
|
||||
export const getVerticalOffset = (
|
||||
fontFamily: ExcalidrawTextElement["fontFamily"],
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeightPx: number,
|
||||
) => {
|
||||
const { unitsPerEm, ascender, descender } =
|
||||
FONT_METADATA[fontFamily]?.metrics ||
|
||||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
|
||||
|
||||
const fontSizeEm = fontSize / unitsPerEm;
|
||||
const lineGap =
|
||||
(lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
|
||||
|
||||
const verticalOffset = fontSizeEm * ascender + lineGap;
|
||||
return verticalOffset;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets line height for a selected family.
|
||||
*/
|
||||
export const getLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
const { lineHeight } =
|
||||
FONT_METADATA[fontFamily]?.metrics ||
|
||||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
|
||||
|
||||
return lineHeight as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
11
packages/common/src/index.ts
Normal file
11
packages/common/src/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export * from "./binary-heap";
|
||||
export * from "./colors";
|
||||
export * from "./constants";
|
||||
export * from "./font-metadata";
|
||||
export * from "./queue";
|
||||
export * from "./keys";
|
||||
export * from "./points";
|
||||
export * from "./promise-pool";
|
||||
export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
|
@ -1,4 +1,5 @@
|
|||
import { isDarwin } from "./constants";
|
||||
|
||||
import type { ValueOf } from "./utility-types";
|
||||
|
||||
export const CODES = {
|
|
@ -1,4 +1,10 @@
|
|||
import { pointFromPair, type GlobalPoint, type LocalPoint } from "../math";
|
||||
import {
|
||||
pointFromPair,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { NullableGridSize } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export const getSizeFromPoints = (
|
||||
points: readonly (GlobalPoint | LocalPoint)[],
|
||||
|
@ -57,3 +63,18 @@ export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
|
|||
|
||||
return nextPoints;
|
||||
};
|
||||
|
||||
// TODO: Rounding this point causes some shake when free drawing
|
||||
export const getGridPoint = (
|
||||
x: number,
|
||||
y: number,
|
||||
gridSize: NullableGridSize,
|
||||
): [number, number] => {
|
||||
if (gridSize) {
|
||||
return [
|
||||
Math.round(x / gridSize) * gridSize,
|
||||
Math.round(y / gridSize) * gridSize,
|
||||
];
|
||||
}
|
||||
return [x, y];
|
||||
};
|
50
packages/common/src/promise-pool.ts
Normal file
50
packages/common/src/promise-pool.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import Pool from "es6-promise-pool";
|
||||
|
||||
// extending the missing types
|
||||
// relying on the [Index, T] to keep a correct order
|
||||
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
|
||||
addEventListener: (
|
||||
type: "fulfilled",
|
||||
listener: (event: { data: { result: [Index, T] } }) => void,
|
||||
) => (event: { data: { result: [Index, T] } }) => void;
|
||||
removeEventListener: (
|
||||
type: "fulfilled",
|
||||
listener: (event: { data: { result: [Index, T] } }) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export class PromisePool<T> {
|
||||
private readonly pool: TPromisePool<T>;
|
||||
private readonly entries: Record<number, T> = {};
|
||||
|
||||
constructor(
|
||||
source: IterableIterator<Promise<void | readonly [number, T]>>,
|
||||
concurrency: number,
|
||||
) {
|
||||
this.pool = new Pool(
|
||||
source as unknown as () => void | PromiseLike<[number, T][]>,
|
||||
concurrency,
|
||||
) as TPromisePool<T>;
|
||||
}
|
||||
|
||||
public all() {
|
||||
const listener = (event: { data: { result: void | [number, T] } }) => {
|
||||
if (event.data.result) {
|
||||
// by default pool does not return the results, so we are gathering them manually
|
||||
// with the correct call order (represented by the index in the tuple)
|
||||
const [index, value] = event.data.result;
|
||||
this.entries[index] = value;
|
||||
}
|
||||
};
|
||||
|
||||
this.pool.addEventListener("fulfilled", listener);
|
||||
|
||||
return this.pool.start().then(() => {
|
||||
setTimeout(() => {
|
||||
this.pool.removeEventListener("fulfilled", listener);
|
||||
});
|
||||
|
||||
return Object.values(this.entries);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
import { promiseTry, resolvablePromise } from ".";
|
||||
|
||||
import type { ResolvablePromise } from ".";
|
||||
|
||||
import type { MaybePromise } from "./utility-types";
|
||||
import type { ResolvablePromise } from "./utils";
|
||||
import { promiseTry, resolvablePromise } from "./utils";
|
||||
|
||||
type Job<T, TArgs extends unknown[]> = (...args: TArgs) => MaybePromise<T>;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Random } from "roughjs/bin/math";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Random } from "roughjs/bin/math";
|
||||
|
||||
import { isTestEnv } from "./utils";
|
||||
|
||||
let random = new Random(Date.now());
|
|
@ -1,12 +1,13 @@
|
|||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { sanitizeHTMLAttribute } from "../utils";
|
||||
|
||||
import { escapeDoubleQuotes } from "./utils";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (!link) {
|
||||
return link;
|
||||
}
|
||||
return sanitizeUrl(sanitizeHTMLAttribute(link));
|
||||
return sanitizeUrl(escapeDoubleQuotes(link));
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
|
@ -70,3 +70,6 @@ export type MaybePromise<T> = T | Promise<T>;
|
|||
export type DTO<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
// get union of all keys from the union of types
|
||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
|
@ -1,24 +1,33 @@
|
|||
import Pool from "es6-promise-pool";
|
||||
import { average } from "../math";
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import type { EVENT } from "./constants";
|
||||
import {
|
||||
DEFAULT_VERSION,
|
||||
FONT_FAMILY,
|
||||
getFontFamilyFallbacks,
|
||||
isDarwin,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
import type { FontFamilyValues, FontString } from "./element/types";
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
ActiveTool,
|
||||
AppState,
|
||||
ToolType,
|
||||
UnsubscribeCallback,
|
||||
Zoom,
|
||||
} from "./types";
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
DEFAULT_VERSION,
|
||||
ENV,
|
||||
FONT_FAMILY,
|
||||
getFontFamilyFallbacks,
|
||||
isDarwin,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
|
||||
import type { MaybePromise, ResolutionType } from "./utility-types";
|
||||
|
||||
import type { EVENT } from "./constants";
|
||||
|
||||
let mockDateTime: string | null = null;
|
||||
|
||||
export const setDateTimeForTests = (dateTime: string) => {
|
||||
|
@ -163,7 +172,7 @@ export const throttleRAF = <T extends any[]>(
|
|||
};
|
||||
|
||||
const ret = (...args: T) => {
|
||||
if (import.meta.env.MODE === "test") {
|
||||
if (isTestEnv()) {
|
||||
fn(...args);
|
||||
return;
|
||||
}
|
||||
|
@ -543,6 +552,9 @@ export const isTransparent = (color: string) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined]
|
||||
? (value?: MaybePromise<Awaited<T>>) => void
|
||||
|
@ -721,9 +733,9 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
|||
return acc;
|
||||
}, [] as Node<T>[]);
|
||||
|
||||
export const isTestEnv = () => import.meta.env.MODE === "test";
|
||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||
|
||||
export const isDevEnv = () => import.meta.env.MODE === "development";
|
||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||
|
||||
export const isServerEnv = () =>
|
||||
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
|
||||
|
@ -1177,64 +1189,14 @@ export const safelyParseJSON = (json: string): Record<string, any> | null => {
|
|||
return null;
|
||||
}
|
||||
};
|
||||
// extending the missing types
|
||||
// relying on the [Index, T] to keep a correct order
|
||||
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
|
||||
addEventListener: (
|
||||
type: "fulfilled",
|
||||
listener: (event: { data: { result: [Index, T] } }) => void,
|
||||
) => (event: { data: { result: [Index, T] } }) => void;
|
||||
removeEventListener: (
|
||||
type: "fulfilled",
|
||||
listener: (event: { data: { result: [Index, T] } }) => void,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* use when you need to render unsafe string as HTML attribute, but MAKE SURE
|
||||
* the attribute is double-quoted when constructing the HTML string
|
||||
*/
|
||||
export const escapeDoubleQuotes = (str: string) => {
|
||||
return str.replace(/"/g, """);
|
||||
};
|
||||
|
||||
export class PromisePool<T> {
|
||||
private readonly pool: TPromisePool<T>;
|
||||
private readonly entries: Record<number, T> = {};
|
||||
|
||||
constructor(
|
||||
source: IterableIterator<Promise<void | readonly [number, T]>>,
|
||||
concurrency: number,
|
||||
) {
|
||||
this.pool = new Pool(
|
||||
source as unknown as () => void | PromiseLike<[number, T][]>,
|
||||
concurrency,
|
||||
) as TPromisePool<T>;
|
||||
}
|
||||
|
||||
public all() {
|
||||
const listener = (event: { data: { result: void | [number, T] } }) => {
|
||||
if (event.data.result) {
|
||||
// by default pool does not return the results, so we are gathering them manually
|
||||
// with the correct call order (represented by the index in the tuple)
|
||||
const [index, value] = event.data.result;
|
||||
this.entries[index] = value;
|
||||
}
|
||||
};
|
||||
|
||||
this.pool.addEventListener("fulfilled", listener);
|
||||
|
||||
return this.pool.start().then(() => {
|
||||
setTimeout(() => {
|
||||
this.pool.removeEventListener("fulfilled", listener);
|
||||
});
|
||||
|
||||
return Object.values(this.entries);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sanitizeHTMLAttribute = (html: string) => {
|
||||
return (
|
||||
html
|
||||
// note, if we're not doing stupid things, escaping " is enough,
|
||||
// but we might end up doing stupid things
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
);
|
||||
};
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
|
@ -1,4 +1,4 @@
|
|||
import { KEYS, matchKey } from "./keys";
|
||||
import { KEYS, matchKey } from "../src/keys";
|
||||
|
||||
describe("key matcher", async () => {
|
||||
it("should not match unexpected key", async () => {
|
|
@ -1,4 +1,4 @@
|
|||
import { Queue } from "./queue";
|
||||
import { Queue } from "../src/queue";
|
||||
|
||||
describe("Queue", () => {
|
||||
const calls: any[] = [];
|
|
@ -1,4 +1,4 @@
|
|||
import { normalizeLink } from "./url";
|
||||
import { normalizeLink } from "../src/url";
|
||||
|
||||
describe("normalizeLink", () => {
|
||||
// NOTE not an extensive XSS test suite, just to check if we're not
|
||||
|
@ -25,7 +25,7 @@ describe("normalizeLink", () => {
|
|||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
});
|
||||
});
|
8
packages/common/tsconfig.json
Normal file
8
packages/common/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
|
@ -86,13 +86,10 @@ export const getNonDeletedGroupIds = (elements: ElementsMap) => {
|
|||
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// @ts-expect-error
|
||||
export const isTestEnv = () => import.meta.env.MODE === "test";
|
||||
|
||||
// @ts-expect-error
|
||||
export const isDevEnv = () => import.meta.env.MODE === "development";
|
||||
|
||||
// @ts-expect-error
|
||||
export const isServerEnv = () => import.meta.env.MODE === "server";
|
||||
|
||||
export const shouldThrow = () => isDevEnv() || isTestEnv() || isServerEnv();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Delta } from "../common/delta";
|
||||
import { elementsToMap, newElementWith, shouldThrow } from "../common/utils";
|
||||
import { newElementWith, shouldThrow } from "../common/utils";
|
||||
|
||||
import type { DeltaContainer } from "../common/interfaces";
|
||||
import type {
|
||||
|
|
3
packages/element/.eslintrc.json
Normal file
3
packages/element/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../eslintrc.base.json"]
|
||||
}
|
19
packages/element/README.md
Normal file
19
packages/element/README.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
# @excalidraw/element
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @excalidraw/element
|
||||
```
|
||||
|
||||
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
|
||||
|
||||
```bash
|
||||
yarn add @excalidraw/element
|
||||
```
|
||||
|
||||
With PNPM, similarly install the package with this command:
|
||||
|
||||
```bash
|
||||
pnpm add @excalidraw/element
|
||||
```
|
3
packages/element/global.d.ts
vendored
Normal file
3
packages/element/global.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
56
packages/element/package.json
Normal file
56
packages/element/package.json
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "@excalidraw/element",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
"module": "./dist/prod/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./../element/dist/types/element/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"description": "Excalidraw elements-related logic",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"excalidraw",
|
||||
"excalidraw-utils"
|
||||
],
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not safari < 12",
|
||||
"not kaios <= 2.5",
|
||||
"not edge < 79",
|
||||
"not chrome < 70",
|
||||
"not and_uc < 13",
|
||||
"not samsung < 10"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
|
@ -1,31 +1,38 @@
|
|||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
||||
import { getDiamondPoints, getArrowheadPoints } from "../element";
|
||||
import type { ElementShapes } from "./types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "../element/types";
|
||||
import { generateFreeDrawShape } from "../renderer/renderElement";
|
||||
import { isTransparent, assertNever } from "../utils";
|
||||
import { simplify } from "points-on-curve";
|
||||
import { ROUGHNESS } from "../constants";
|
||||
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import {
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import type { EmbedsValidationStatus } from "../types";
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "../../math";
|
||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||
import { headingForPointIsHorizontal } from "../element/heading";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
|
@ -430,12 +437,26 @@ export const _generateElementShape = (
|
|||
: [pointFrom<LocalPoint>(0, 0)];
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
shape = [
|
||||
generator.path(
|
||||
generateElbowArrowShape(points, 16),
|
||||
generateRoughOptions(element, true),
|
||||
),
|
||||
];
|
||||
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes
|
||||
if (
|
||||
!points.every(
|
||||
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
|
||||
)
|
||||
) {
|
||||
console.error(
|
||||
`Elbow arrow with extreme point positions detected. Arrow not rendered.`,
|
||||
element.id,
|
||||
JSON.stringify(points),
|
||||
);
|
||||
shape = [];
|
||||
} else {
|
||||
shape = [
|
||||
generator.path(
|
||||
generateElbowArrowShape(points, 16),
|
||||
generateRoughOptions(element, true),
|
||||
),
|
||||
];
|
||||
}
|
||||
} else if (!element.roundness) {
|
||||
// curve is always the first element
|
||||
// this simplifies finding the curve for an element
|
||||
|
@ -494,7 +515,10 @@ export const _generateElementShape = (
|
|||
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(element.points, 0.75);
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
|
@ -1,14 +1,23 @@
|
|||
import type { Drawable } from "roughjs/bin/core";
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
} from "../element/types";
|
||||
import { elementWithCanvasCache } from "../renderer/renderElement";
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { _generateElementShape } from "./Shape";
|
||||
import type { ElementShape, ElementShapes } from "./types";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import type { AppState, EmbedsValidationStatus } from "../types";
|
||||
|
||||
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();
|
|
@ -1,10 +1,12 @@
|
|||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import type { BoundingBox } from "./element/bounds";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { updateBoundElements } from "./element/binding";
|
||||
import type Scene from "./scene/Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,42 @@
|
|||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import { rescalePoints, arrayToMap, invariant } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
Degrees,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -7,39 +46,8 @@ import type {
|
|||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import type { Drawable, Op } from "roughjs/bin/core";
|
||||
import type { AppState } from "../types";
|
||||
import { generateRoughOptions } from "../scene/Shape";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { rescalePoints } from "../points";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { arrayToMap, invariant } from "../utils";
|
||||
import type {
|
||||
Degrees,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Radians,
|
||||
} from "../../math";
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "../../math";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
|
@ -367,15 +375,6 @@ export const getDiamondPoints = (element: ExcalidrawElement) => {
|
|||
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
|
||||
};
|
||||
|
||||
export const getCurvePathOps = (shape: Drawable): Op[] => {
|
||||
for (const set of shape.sets) {
|
||||
if (set.type === "path") {
|
||||
return set.ops;
|
||||
}
|
||||
}
|
||||
return shape.sets[0].ops;
|
||||
};
|
||||
|
||||
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
|
||||
const getBezierValueForT = (
|
||||
t: number,
|
||||
|
@ -583,6 +582,10 @@ export const getArrowheadPoints = (
|
|||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
) => {
|
||||
if (shape.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
if (ops.length < 1) {
|
||||
return null;
|
||||
|
@ -1013,3 +1016,17 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
|||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
|
||||
export const doBoundsIntersect = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
): boolean => {
|
||||
if (bounds1 == null || bounds2 == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [minX1, minY1, maxX1, maxY1] = bounds1;
|
||||
const [minX2, minY2, maxX2, maxY2] = bounds2;
|
||||
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
319
packages/element/src/collision.ts
Normal file
319
packages/element/src/collision.ts
Normal file
|
@ -0,0 +1,319 @@
|
|||
import { isTransparent } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
line,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
ellipse,
|
||||
ellipseLineIntersectionPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { getPolygonShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Polygon,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { GeometricShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
if (element.type === "arrow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isIframeLikeElement(element) ||
|
||||
isTextElement(element);
|
||||
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
|
||||
if (element.type === "freedraw") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
element: ExcalidrawElement;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
shape,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
});
|
||||
}
|
||||
|
||||
return hit;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
x: number,
|
||||
y: number,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
x1 -= tolerance;
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(
|
||||
hitArgs.x,
|
||||
hitArgs.y,
|
||||
getBoundTextShape(hitArgs.element, elementsMap),
|
||||
) &&
|
||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Intersect a line with an element for binding test
|
||||
*
|
||||
* @param element
|
||||
* @param line
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export const intersectElementWithLineSegment = (
|
||||
element: ExcalidrawElement,
|
||||
line: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(element, line, offset);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(element, line, offset);
|
||||
default:
|
||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
||||
}
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
// 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],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
l[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
return (
|
||||
// Test intersection against the sides, keep only the valid
|
||||
// intersection points and rotate them back to scene space
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.filter((x) => x != null)
|
||||
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
|
||||
// Test intersection against the corners which are cubic bezier curves,
|
||||
// keep only the valid intersection points and rotate them back to scene
|
||||
// space
|
||||
.concat(
|
||||
corners
|
||||
.flatMap((t) =>
|
||||
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
|
||||
)
|
||||
.filter((i) => i != null)
|
||||
.map((j) => pointRotateRads(j, center, element.angle)),
|
||||
)
|
||||
// Remove duplicates
|
||||
.filter(
|
||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param element
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
const intersectDiamondWithLineSegment = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// 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 [sides, curves] = deconstructDiamondElement(element, offset);
|
||||
|
||||
return (
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.filter((p): p is GlobalPoint => p != null)
|
||||
// Rotate back intersection points
|
||||
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
|
||||
.concat(
|
||||
curves
|
||||
.flatMap((p) =>
|
||||
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
|
||||
)
|
||||
.filter((p) => p != null)
|
||||
// Rotate back intersection points
|
||||
.map((p) => pointRotateRads(p, center, element.angle)),
|
||||
)
|
||||
// Remove duplicates
|
||||
.filter(
|
||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param element
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
const intersectEllipseWithLineSegment = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseLineIntersectionPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
line(rotatedA, rotatedB),
|
||||
).map((p) => pointRotateRads(p, center, element.angle));
|
||||
};
|
44
packages/element/src/comparisons.ts
Normal file
44
packages/element/src/comparisons.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "line" ||
|
||||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
|
||||
export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
|
||||
export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "line" ||
|
||||
type === "diamond" ||
|
||||
type === "image";
|
||||
|
||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||
|
||||
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
|
|
@ -1,4 +1,3 @@
|
|||
import { type Point } from "points-on-curve";
|
||||
import {
|
||||
type Radians,
|
||||
pointFrom,
|
||||
|
@ -12,7 +11,14 @@ import {
|
|||
pointFromVector,
|
||||
clamp,
|
||||
isCloseTo,
|
||||
} from "../../math";
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
|
||||
import type { TransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
|
@ -21,10 +27,6 @@ import type {
|
|||
ImageCrop,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
|
||||
export const MINIMAL_CROP_SIZE = 10;
|
||||
|
127
packages/element/src/distance.ts
Normal file
127
packages/element/src/distance.ts
Normal file
|
@ -0,0 +1,127 @@
|
|||
import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectanguloidElement(element, p);
|
||||
case "diamond":
|
||||
return distanceToDiamondElement(element, p);
|
||||
case "ellipse":
|
||||
return distanceToEllipseElement(element, p);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point and the provided rectangular-shaped element,
|
||||
* accounting for roundness and rotation
|
||||
*
|
||||
* @param element The rectanguloid element
|
||||
* @param p The point to consider
|
||||
* @returns The eucledian distance to the outline of the rectanguloid element
|
||||
*/
|
||||
const distanceToRectanguloidElement = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element);
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...corners
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point and the provided diamond element, accounting
|
||||
* for roundness and rotation
|
||||
*
|
||||
* @param element The diamond element
|
||||
* @param p The point to consider
|
||||
* @returns The eucledian distance to the outline of the diamond
|
||||
*/
|
||||
const distanceToDiamondElement = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
||||
const [sides, curves] = deconstructDiamondElement(element);
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...curves
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point and the provided ellipse element, accounting
|
||||
* for roundness and rotation
|
||||
*
|
||||
* @param element The ellipse element
|
||||
* @param p The point to consider
|
||||
* @returns The eucledian distance to the outline of the ellipse
|
||||
*/
|
||||
const distanceToEllipseElement = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = pointFrom(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
return ellipseDistanceFromPoint(
|
||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||
pointRotateRads(p, center, -element.angle as Radians),
|
||||
ellipse(center, element.width / 2, element.height / 2),
|
||||
);
|
||||
};
|
|
@ -1,7 +1,9 @@
|
|||
import { newElementWith } from "./element/mutateElement";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Distribution {
|
||||
space: "between";
|
|
@ -1,17 +1,26 @@
|
|||
import { updateBoundElements } from "./binding";
|
||||
import type { Bounds } from "./bounds";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import type { NonDeletedExcalidrawElement } from "./types";
|
||||
import {
|
||||
TEXT_AUTOWRAP_THRESHOLD,
|
||||
getGridPoint,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
NormalizedZoomValue,
|
||||
NullableGridSize,
|
||||
PointerDownState,
|
||||
} from "../types";
|
||||
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||
import type Scene from "../scene/Scene";
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { getMinTextElementWidth } from "./textMeasurements";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
|
@ -19,9 +28,9 @@ import {
|
|||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { getFontString } from "../utils";
|
||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||
import { getGridPoint } from "../snapping";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
|
@ -75,13 +84,20 @@ export const dragSelectedElements = (
|
|||
}
|
||||
}
|
||||
|
||||
const commonBounds = getCommonBounds(
|
||||
Array.from(elementsToUpdate).map(
|
||||
(el) => pointerDownState.originalElements.get(el.id) ?? el,
|
||||
),
|
||||
);
|
||||
const origElements: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of elementsToUpdate) {
|
||||
const origElement = pointerDownState.originalElements.get(element.id);
|
||||
// if original element is not set (e.g. when you duplicate during a drag
|
||||
// operation), exit to avoid undefined behavior
|
||||
if (!origElement) {
|
||||
return;
|
||||
}
|
||||
origElements.push(origElement);
|
||||
}
|
||||
|
||||
const adjustedOffset = calculateOffset(
|
||||
commonBounds,
|
||||
getCommonBounds(origElements),
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
485
packages/element/src/duplicate.ts
Normal file
485
packages/element/src/duplicate.ts
Normal file
|
@ -0,0 +1,485 @@
|
|||
import {
|
||||
ORIG_ID,
|
||||
randomId,
|
||||
randomInteger,
|
||||
arrayToMap,
|
||||
castArray,
|
||||
findLastIndex,
|
||||
getUpdatedTimestamp,
|
||||
isTestEnv,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
getElementsInGroup,
|
||||
getNewGroupIdsForDuplication,
|
||||
getSelectedGroupForElement,
|
||||
} from "./groups";
|
||||
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameChildren,
|
||||
} from "./frame";
|
||||
|
||||
import { normalizeElementOrder } from "./sortElements";
|
||||
|
||||
import { bumpVersion } from "./mutateElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
|
||||
import { fixBindingsAfterDuplication } from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
GroupId,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Duplicate an element, often used in the alt-drag operation.
|
||||
* Note that this method has gotten a bit complicated since the
|
||||
* introduction of gruoping/ungrouping elements.
|
||||
* @param editingGroupId The current group being edited. The new
|
||||
* element will inherit this group and its
|
||||
* parents.
|
||||
* @param groupIdMapForOperation A Map that maps old group IDs to
|
||||
* duplicated ones. If you are duplicating
|
||||
* multiple elements at once, share this map
|
||||
* amongst all of them
|
||||
* @param element Element to duplicate
|
||||
* @param overrides Any element properties to override
|
||||
*/
|
||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
randomizeSeed?: boolean,
|
||||
): Readonly<TElement> => {
|
||||
let copy = deepCopyElement(element);
|
||||
|
||||
if (isTestEnv()) {
|
||||
__test__defineOrigId(copy, element.id);
|
||||
}
|
||||
|
||||
copy.id = randomId();
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
if (randomizeSeed) {
|
||||
copy.seed = randomInteger();
|
||||
bumpVersion(copy);
|
||||
}
|
||||
|
||||
copy.groupIds = getNewGroupIdsForDuplication(
|
||||
copy.groupIds,
|
||||
editingGroupId,
|
||||
(groupId) => {
|
||||
if (!groupIdMapForOperation.has(groupId)) {
|
||||
groupIdMapForOperation.set(groupId, randomId());
|
||||
}
|
||||
return groupIdMapForOperation.get(groupId)!;
|
||||
},
|
||||
);
|
||||
if (overrides) {
|
||||
copy = Object.assign(copy, overrides);
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
export const duplicateElements = (
|
||||
opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
randomizeSeed?: boolean;
|
||||
overrides?: (
|
||||
originalElement: ExcalidrawElement,
|
||||
) => Partial<ExcalidrawElement>;
|
||||
} & (
|
||||
| {
|
||||
/**
|
||||
* Duplicates all elements in array.
|
||||
*
|
||||
* Use this when programmaticaly duplicating elements, without direct
|
||||
* user interaction.
|
||||
*/
|
||||
type: "everything";
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Duplicates specified elements and inserts them back into the array
|
||||
* in specified order.
|
||||
*
|
||||
* Use this when duplicating Scene elements, during user interaction
|
||||
* such as alt-drag or on duplicate action.
|
||||
*/
|
||||
type: "in-place";
|
||||
idsOfElementsToDuplicate: Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
appState: {
|
||||
editingGroupId: AppState["editingGroupId"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
};
|
||||
/**
|
||||
* If true, duplicated elements are inserted _before_ specified
|
||||
* elements. Case: alt-dragging elements to duplicate them.
|
||||
*
|
||||
* TODO: remove this once (if) we stop replacing the original element
|
||||
* with the duplicated one in the scene array.
|
||||
*/
|
||||
reverseOrder: boolean;
|
||||
}
|
||||
),
|
||||
) => {
|
||||
let { elements } = opts;
|
||||
|
||||
const appState =
|
||||
"appState" in opts
|
||||
? opts.appState
|
||||
: ({
|
||||
editingGroupId: null,
|
||||
selectedGroupIds: {},
|
||||
} as const);
|
||||
|
||||
const reverseOrder = opts.type === "in-place" ? opts.reverseOrder : false;
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
// into the array twice if we end up backtracking when retrieving
|
||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||
// cases such as a group containing deleted elements which were not selected).
|
||||
//
|
||||
// This is not enough to prevent duplicates, so we do a second loop afterwards
|
||||
// to remove them.
|
||||
//
|
||||
// For convenience we mark even the newly created ones even though we don't
|
||||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
const elementsMap = arrayToMap(elements) as ElementsMap;
|
||||
const _idsOfElementsToDuplicate =
|
||||
opts.type === "in-place"
|
||||
? opts.idsOfElementsToDuplicate
|
||||
: new Map(elements.map((el) => [el.id, el]));
|
||||
|
||||
// For sanity
|
||||
if (opts.type === "in-place") {
|
||||
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
|
||||
elements
|
||||
.filter((el) => el.groupIds?.includes(groupId))
|
||||
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el));
|
||||
}
|
||||
}
|
||||
|
||||
elements = normalizeElementOrder(elements);
|
||||
|
||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
||||
|
||||
// helper functions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Used for the heavy lifing of copying a single element, a group of elements
|
||||
// an element with bound text etc.
|
||||
const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>(
|
||||
element: T,
|
||||
): T extends ExcalidrawElement[]
|
||||
? ExcalidrawElement[]
|
||||
: ExcalidrawElement | null => {
|
||||
const elements = castArray(element);
|
||||
|
||||
const _newElements = elements.reduce(
|
||||
(acc: ExcalidrawElement[], element) => {
|
||||
if (processedIds.has(element.id)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
processedIds.set(element.id, true);
|
||||
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
opts.overrides?.(element),
|
||||
opts.randomizeSeed,
|
||||
);
|
||||
|
||||
processedIds.set(newElement.id, true);
|
||||
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
|
||||
acc.push(newElement);
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
Array.isArray(element) ? _newElements : _newElements[0] || null
|
||||
) as T extends ExcalidrawElement[]
|
||||
? ExcalidrawElement[]
|
||||
: ExcalidrawElement | null;
|
||||
};
|
||||
|
||||
// Helper to position cloned elements in the Z-order the product needs it
|
||||
const insertBeforeOrAfterIndex = (
|
||||
index: number,
|
||||
elements: ExcalidrawElement | null | ExcalidrawElement[],
|
||||
) => {
|
||||
if (!elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reverseOrder && index < 1) {
|
||||
elementsWithClones.unshift(...castArray(elements));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reverseOrder && index > elementsWithClones.length - 1) {
|
||||
elementsWithClones.push(...castArray(elements));
|
||||
return;
|
||||
}
|
||||
|
||||
elementsWithClones.splice(
|
||||
index + (reverseOrder ? 0 : 1),
|
||||
0,
|
||||
...castArray(elements),
|
||||
);
|
||||
};
|
||||
|
||||
const frameIdsToDuplicate = new Set(
|
||||
elements
|
||||
.filter(
|
||||
(el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
|
||||
)
|
||||
.map((el) => el.id),
|
||||
);
|
||||
|
||||
for (const element of elements) {
|
||||
if (processedIds.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_idsOfElementsToDuplicate.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// groups
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
||||
(element) =>
|
||||
isFrameLikeElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
const targetIndex = reverseOrder
|
||||
? elementsWithClones.findIndex((el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
})
|
||||
: findLastIndex(elementsWithClones, (el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
});
|
||||
|
||||
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
|
||||
continue;
|
||||
}
|
||||
|
||||
// frame duplication
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
const frameId = element.id;
|
||||
|
||||
const frameChildren = getFrameChildren(elements, frameId);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.frameId === frameId || el.id === frameId;
|
||||
});
|
||||
|
||||
insertBeforeOrAfterIndex(
|
||||
targetIndex,
|
||||
copyElements([...frameChildren, element]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// text container
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return (
|
||||
el.id === element.id ||
|
||||
("containerId" in el && el.containerId === element.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (boundTextElement) {
|
||||
insertBeforeOrAfterIndex(
|
||||
targetIndex + (reverseOrder ? -1 : 0),
|
||||
copyElements([element, boundTextElement]),
|
||||
);
|
||||
} else {
|
||||
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.id === element.id || el.id === container?.id;
|
||||
});
|
||||
|
||||
if (container) {
|
||||
insertBeforeOrAfterIndex(
|
||||
targetIndex,
|
||||
copyElements([container, element]),
|
||||
);
|
||||
} else {
|
||||
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// default duplication (regular elements)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
insertBeforeOrAfterIndex(
|
||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
||||
copyElements(element),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fixBindingsAfterDuplication(
|
||||
newElements,
|
||||
oldIdToDuplicatedId,
|
||||
duplicatedElementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
bindElementsToFramesAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
return {
|
||||
newElements,
|
||||
elementsWithClones,
|
||||
};
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
||||
//
|
||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
// Typed arrays and other non-null objects.
|
||||
//
|
||||
// Adapted from https://github.com/lukeed/klona
|
||||
//
|
||||
// The reason for `deepCopyElement()` wrapper is type safety (only allow
|
||||
// passing ExcalidrawElement as the top-level argument).
|
||||
const _deepCopyElement = (val: any, depth: number = 0) => {
|
||||
// only clone non-primitives
|
||||
if (val == null || typeof val !== "object") {
|
||||
return val;
|
||||
}
|
||||
|
||||
const objectType = Object.prototype.toString.call(val);
|
||||
|
||||
if (objectType === "[object Object]") {
|
||||
const tmp =
|
||||
typeof val.constructor === "function"
|
||||
? Object.create(Object.getPrototypeOf(val))
|
||||
: {};
|
||||
for (const key in val) {
|
||||
if (val.hasOwnProperty(key)) {
|
||||
// don't copy non-serializable objects like these caches. They'll be
|
||||
// populated when the element is rendered.
|
||||
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
||||
continue;
|
||||
}
|
||||
tmp[key] = _deepCopyElement(val[key], depth + 1);
|
||||
}
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
let k = val.length;
|
||||
const arr = new Array(k);
|
||||
while (k--) {
|
||||
arr[k] = _deepCopyElement(val[k], depth + 1);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// we're not cloning non-array & non-plain-object objects because we
|
||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
||||
// sure we start cloning them, so let's warn about it.
|
||||
if (import.meta.env.DEV) {
|
||||
if (
|
||||
objectType !== "[object Object]" &&
|
||||
objectType !== "[object Array]" &&
|
||||
objectType.startsWith("[object ")
|
||||
) {
|
||||
console.warn(
|
||||
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
|
||||
* any value. The purpose is to to break object references for immutability
|
||||
* reasons, whenever we want to keep the original element, but ensure it's not
|
||||
* mutated.
|
||||
*
|
||||
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
* Typed arrays and other non-null objects.
|
||||
*/
|
||||
export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
val: T,
|
||||
): Mutable<T> => {
|
||||
return _deepCopyElement(val);
|
||||
};
|
||||
|
||||
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
||||
Object.defineProperty(clonedObj, ORIG_ID, {
|
||||
value: origId,
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
});
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
clamp,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointScaleFromOrigin,
|
||||
|
@ -10,24 +11,28 @@ import {
|
|||
vectorScale,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "../../math";
|
||||
import BinaryHeap from "../binaryheap";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { aabbForElement, pointInsideBounds } from "../shapes";
|
||||
import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
|
||||
import type { AppState } from "../types";
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
BinaryHeap,
|
||||
invariant,
|
||||
isAnyTrue,
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
bindPointToSnapToElementOutline,
|
||||
distanceToBindableElement,
|
||||
avoidRectangularCorner,
|
||||
getHoveredElementForBinding,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
snapToMid,
|
||||
getHoveredElementForBinding,
|
||||
} from "./binding";
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import {
|
||||
compareHeading,
|
||||
flipHeading,
|
||||
|
@ -41,18 +46,24 @@ import {
|
|||
headingForPoint,
|
||||
} from "./heading";
|
||||
import { type ElementUpdate } from "./mutateElement";
|
||||
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
|
||||
import { isBindableElement } from "./typeChecks";
|
||||
import {
|
||||
type ExcalidrawElbowArrowElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
type SceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
import type {
|
||||
Arrowhead,
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
FixedPointBinding,
|
||||
FixedSegment,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||
|
@ -101,10 +112,10 @@ export const BASE_PADDING = 40;
|
|||
|
||||
const handleSegmentRenormalization = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
|
||||
? structuredClone(arrow.fixedSegments)
|
||||
? arrow.fixedSegments.slice()
|
||||
: null;
|
||||
|
||||
if (nextFixedSegments) {
|
||||
|
@ -244,6 +255,12 @@ const handleSegmentRenormalization = (
|
|||
);
|
||||
}
|
||||
|
||||
isDevEnv() &&
|
||||
invariant(
|
||||
validateElbowPoints(nextPoints),
|
||||
"Invalid elbow points with fixed segments",
|
||||
);
|
||||
|
||||
return normalizeArrowElementUpdate(
|
||||
nextPoints,
|
||||
filteredNextFixedSegments,
|
||||
|
@ -264,8 +281,8 @@ const handleSegmentRenormalization = (
|
|||
|
||||
const handleSegmentRelease = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
fixedSegments: FixedSegment[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
fixedSegments: readonly FixedSegment[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
|
||||
const oldFixedSegmentIndices =
|
||||
|
@ -289,6 +306,8 @@ const handleSegmentRelease = (
|
|||
// We need to render a sub-arrow path to restore deleted segments
|
||||
const x = arrow.x + (prevSegment ? prevSegment.end[0] : 0);
|
||||
const y = arrow.y + (prevSegment ? prevSegment.end[1] : 0);
|
||||
const startBinding = prevSegment ? null : arrow.startBinding;
|
||||
const endBinding = nextSegment ? null : arrow.endBinding;
|
||||
const {
|
||||
startHeading,
|
||||
endHeading,
|
||||
|
@ -301,10 +320,11 @@ const handleSegmentRelease = (
|
|||
{
|
||||
x,
|
||||
y,
|
||||
startBinding: prevSegment ? null : arrow.startBinding,
|
||||
endBinding: nextSegment ? null : arrow.endBinding,
|
||||
startBinding,
|
||||
endBinding,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
points: arrow.points,
|
||||
},
|
||||
elementsMap,
|
||||
[
|
||||
|
@ -438,7 +458,7 @@ const handleSegmentRelease = (
|
|||
*/
|
||||
const handleSegmentMove = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
fixedSegments: FixedSegment[],
|
||||
fixedSegments: readonly FixedSegment[],
|
||||
startHeading: Heading,
|
||||
endHeading: Heading,
|
||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||
|
@ -680,7 +700,7 @@ const handleSegmentMove = (
|
|||
const handleEndpointDrag = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
updatedPoints: readonly LocalPoint[],
|
||||
fixedSegments: FixedSegment[],
|
||||
fixedSegments: readonly FixedSegment[],
|
||||
startHeading: Heading,
|
||||
endHeading: Heading,
|
||||
startGlobalPoint: GlobalPoint,
|
||||
|
@ -857,15 +877,19 @@ const handleEndpointDrag = (
|
|||
);
|
||||
};
|
||||
|
||||
const MAX_POS = 1e6;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export const updateElbowArrowPoints = (
|
||||
arrow: Readonly<ExcalidrawElbowArrowElement>,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
updates: {
|
||||
points?: readonly LocalPoint[];
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
startBinding?: FixedPointBinding | null;
|
||||
endBinding?: FixedPointBinding | null;
|
||||
},
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
|
@ -875,6 +899,50 @@ export const updateElbowArrowPoints = (
|
|||
return { points: updates.points ?? arrow.points };
|
||||
}
|
||||
|
||||
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
|
||||
// arrow size is valid. This check will be removed once the issue is identified
|
||||
if (
|
||||
arrow.x < -MAX_POS ||
|
||||
arrow.x > MAX_POS ||
|
||||
arrow.y < -MAX_POS ||
|
||||
arrow.y > MAX_POS ||
|
||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
|
||||
-MAX_POS ||
|
||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
|
||||
MAX_POS ||
|
||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
|
||||
-MAX_POS ||
|
||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
|
||||
MAX_POS ||
|
||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
|
||||
-MAX_POS ||
|
||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
|
||||
MAX_POS ||
|
||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
|
||||
-MAX_POS ||
|
||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
|
||||
) {
|
||||
console.error(
|
||||
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
|
||||
{
|
||||
arrow,
|
||||
updates,
|
||||
},
|
||||
);
|
||||
}
|
||||
// @ts-ignore See above note
|
||||
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
|
||||
// @ts-ignore See above note
|
||||
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
|
||||
if (updates.points) {
|
||||
updates.points = updates.points.map(([x, y]) =>
|
||||
pointFrom<LocalPoint>(
|
||||
clamp(x, -MAX_POS, MAX_POS),
|
||||
clamp(y, -MAX_POS, MAX_POS),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!import.meta.env.PROD) {
|
||||
invariant(
|
||||
!updates.points || updates.points.length >= 2,
|
||||
|
@ -909,19 +977,7 @@ export const updateElbowArrowPoints = (
|
|||
);
|
||||
}
|
||||
|
||||
// 0. During all element replacement in the scene, we just need to renormalize
|
||||
// the arrow
|
||||
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
||||
if (elementsMap.size === 0 && updates.points) {
|
||||
return normalizeArrowElementUpdate(
|
||||
updates.points.map((p) =>
|
||||
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
|
||||
),
|
||||
arrow.fixedSegments,
|
||||
arrow.startIsSpecial,
|
||||
arrow.endIsSpecial,
|
||||
);
|
||||
}
|
||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||
|
||||
const updatedPoints: readonly LocalPoint[] = updates.points
|
||||
? updates.points && updates.points.length === 2
|
||||
|
@ -932,8 +988,49 @@ export const updateElbowArrowPoints = (
|
|||
? updates.points![1]
|
||||
: p,
|
||||
)
|
||||
: structuredClone(updates.points)
|
||||
: structuredClone(arrow.points);
|
||||
: updates.points.slice()
|
||||
: arrow.points.slice();
|
||||
|
||||
// During all element replacement in the scene, we just need to renormalize
|
||||
// the arrow
|
||||
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
||||
const {
|
||||
startBinding: updatedStartBinding,
|
||||
endBinding: updatedEndBinding,
|
||||
...restOfTheUpdates
|
||||
} = updates;
|
||||
const startBinding =
|
||||
typeof updatedStartBinding !== "undefined"
|
||||
? updatedStartBinding
|
||||
: arrow.startBinding;
|
||||
const endBinding =
|
||||
typeof updatedEndBinding !== "undefined"
|
||||
? updatedEndBinding
|
||||
: arrow.endBinding;
|
||||
const startElement =
|
||||
startBinding &&
|
||||
getBindableElementForId(startBinding.elementId, elementsMap);
|
||||
const endElement =
|
||||
endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
|
||||
const areUpdatedPointsValid = validateElbowPoints(updatedPoints);
|
||||
|
||||
if (
|
||||
(startBinding && !startElement && areUpdatedPointsValid) ||
|
||||
(endBinding && !endElement && areUpdatedPointsValid) ||
|
||||
(elementsMap.size === 0 && areUpdatedPointsValid) ||
|
||||
(Object.keys(restOfTheUpdates).length === 0 &&
|
||||
(startElement?.id !== startBinding?.elementId ||
|
||||
endElement?.id !== endBinding?.elementId))
|
||||
) {
|
||||
return normalizeArrowElementUpdate(
|
||||
updatedPoints.map((p) =>
|
||||
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
|
||||
),
|
||||
arrow.fixedSegments,
|
||||
arrow.startIsSpecial,
|
||||
arrow.endIsSpecial,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
startHeading,
|
||||
|
@ -943,17 +1040,62 @@ export const updateElbowArrowPoints = (
|
|||
hoveredStartElement,
|
||||
hoveredEndElement,
|
||||
...rest
|
||||
} = getElbowArrowData(arrow, elementsMap, updatedPoints, options);
|
||||
} = getElbowArrowData(
|
||||
{
|
||||
x: arrow.x,
|
||||
y: arrow.y,
|
||||
startBinding,
|
||||
endBinding,
|
||||
startArrowhead: arrow.startArrowhead,
|
||||
endArrowhead: arrow.endArrowhead,
|
||||
points: arrow.points,
|
||||
},
|
||||
elementsMap,
|
||||
updatedPoints,
|
||||
options,
|
||||
);
|
||||
|
||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||
// 0. During all element replacement in the scene, we just need to renormalize
|
||||
// the arrow
|
||||
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
|
||||
if (elementsMap.size === 0 && areUpdatedPointsValid) {
|
||||
return normalizeArrowElementUpdate(
|
||||
updatedPoints.map((p) =>
|
||||
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
|
||||
),
|
||||
arrow.fixedSegments,
|
||||
arrow.startIsSpecial,
|
||||
arrow.endIsSpecial,
|
||||
);
|
||||
}
|
||||
|
||||
////
|
||||
// 1. Renormalize the arrow
|
||||
////
|
||||
if (!updates.points && !updates.fixedSegments) {
|
||||
if (
|
||||
!updates.points &&
|
||||
!updates.fixedSegments &&
|
||||
!updates.startBinding &&
|
||||
!updates.endBinding
|
||||
) {
|
||||
return handleSegmentRenormalization(arrow, elementsMap);
|
||||
}
|
||||
|
||||
// Short circuit on no-op to avoid huge performance hit
|
||||
if (
|
||||
updates.startBinding === arrow.startBinding &&
|
||||
updates.endBinding === arrow.endBinding &&
|
||||
(updates.points ?? []).every((p, i) =>
|
||||
pointsEqual(
|
||||
p,
|
||||
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
|
||||
),
|
||||
) &&
|
||||
areUpdatedPointsValid
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
////
|
||||
// 2. Just normal elbow arrow things
|
||||
////
|
||||
|
@ -1000,6 +1142,7 @@ export const updateElbowArrowPoints = (
|
|||
|
||||
////
|
||||
// 5. Handle resize
|
||||
////
|
||||
if (updates.points && updates.fixedSegments) {
|
||||
return updates;
|
||||
}
|
||||
|
@ -1055,8 +1198,9 @@ const getElbowArrowData = (
|
|||
endBinding: FixedPointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
points: readonly LocalPoint[];
|
||||
},
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
nextPoints: readonly LocalPoint[],
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
|
@ -1071,35 +1215,58 @@ const getElbowArrowData = (
|
|||
LocalPoint,
|
||||
GlobalPoint
|
||||
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
|
||||
const startElement =
|
||||
arrow.startBinding &&
|
||||
getBindableElementForId(arrow.startBinding.elementId, elementsMap);
|
||||
const endElement =
|
||||
arrow.endBinding &&
|
||||
getBindableElementForId(arrow.endBinding.elementId, elementsMap);
|
||||
const [hoveredStartElement, hoveredEndElement] = options?.isDragging
|
||||
? getHoveredElements(
|
||||
|
||||
let hoveredStartElement = null;
|
||||
let hoveredEndElement = null;
|
||||
if (options?.isDragging) {
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
getHoveredElement(
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
options?.zoom,
|
||||
) || null;
|
||||
hoveredEndElement =
|
||||
getHoveredElement(
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
options?.zoom,
|
||||
)
|
||||
: [startElement, endElement];
|
||||
) || null;
|
||||
} else {
|
||||
hoveredStartElement = arrow.startBinding
|
||||
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
|
||||
null
|
||||
: null;
|
||||
hoveredEndElement = arrow.endBinding
|
||||
? getBindableElementForId(arrow.endBinding.elementId, elementsMap) || null
|
||||
: null;
|
||||
}
|
||||
|
||||
const startGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
} as ExcalidrawElbowArrowElement,
|
||||
"start",
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
startElement,
|
||||
hoveredStartElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
} as ExcalidrawElbowArrowElement,
|
||||
"end",
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
endElement,
|
||||
hoveredEndElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
|
@ -1921,7 +2088,7 @@ const getBindableElementForId = (
|
|||
|
||||
const normalizeArrowElementUpdate = (
|
||||
global: GlobalPoint[],
|
||||
nextFixedSegments: FixedSegment[] | null,
|
||||
nextFixedSegments: readonly FixedSegment[] | null,
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
): {
|
||||
|
@ -1930,24 +2097,50 @@ const normalizeArrowElementUpdate = (
|
|||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fixedSegments: FixedSegment[] | null;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
} => {
|
||||
const offsetX = global[0][0];
|
||||
const offsetY = global[0][1];
|
||||
|
||||
const points = global.map((p) =>
|
||||
let points = global.map((p) =>
|
||||
pointTranslate<GlobalPoint, LocalPoint>(
|
||||
p,
|
||||
vectorScale(vectorFromPoint(global[0]), -1),
|
||||
),
|
||||
);
|
||||
|
||||
// NOTE (mtolmacs): This is a temporary check to see if the normalization
|
||||
// creates an overly large arrow. This should be removed once we have an answer.
|
||||
if (
|
||||
offsetX < -MAX_POS ||
|
||||
offsetX > MAX_POS ||
|
||||
offsetY < -MAX_POS ||
|
||||
offsetY > MAX_POS ||
|
||||
offsetX + points[points.length - 1][0] < -MAX_POS ||
|
||||
offsetY + points[points.length - 1][0] > MAX_POS ||
|
||||
offsetX + points[points.length - 1][1] < -MAX_POS ||
|
||||
offsetY + points[points.length - 1][1] > MAX_POS
|
||||
) {
|
||||
console.error(
|
||||
"Elbow arrow normalization is outside reasonable bounds (> 1e6)",
|
||||
{
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
points,
|
||||
...getSizeFromPoints(points),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
points = points.map(([x, y]) =>
|
||||
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
|
||||
);
|
||||
|
||||
return {
|
||||
points,
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
x: clamp(offsetX, -1e6, 1e6),
|
||||
y: clamp(offsetY, -1e6, 1e6),
|
||||
fixedSegments:
|
||||
(nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
|
||||
...getSizeFromPoints(points),
|
||||
|
@ -2015,60 +2208,45 @@ const neighborIndexToHeading = (idx: number): Heading => {
|
|||
};
|
||||
|
||||
const getGlobalPoint = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
otherPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
boundElement?: ExcalidrawBindableElement | null,
|
||||
hoveredElement?: ExcalidrawBindableElement | null,
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
isDragging?: boolean,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (hoveredElement) {
|
||||
const snapPoint = getSnapPoint(
|
||||
initialPoint,
|
||||
otherPoint,
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
if (element) {
|
||||
const snapPoint = bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
);
|
||||
|
||||
return snapToMid(hoveredElement, snapPoint);
|
||||
return snapToMid(element, snapPoint);
|
||||
}
|
||||
|
||||
return initialPoint;
|
||||
}
|
||||
|
||||
if (boundElement) {
|
||||
if (element) {
|
||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
||||
fixedPointRatio || [0, 0],
|
||||
boundElement,
|
||||
element,
|
||||
);
|
||||
|
||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
||||
return Math.abs(
|
||||
distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) -
|
||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
||||
FIXED_BINDING_DISTANCE,
|
||||
) > 0.01
|
||||
? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap)
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
||||
: fixedGlobalPoint;
|
||||
}
|
||||
|
||||
return initialPoint;
|
||||
};
|
||||
|
||||
const getSnapPoint = (
|
||||
p: GlobalPoint,
|
||||
otherPoint: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
bindPointToSnapToElementOutline(
|
||||
isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
|
||||
otherPoint,
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const getBindPointHeading = (
|
||||
p: GlobalPoint,
|
||||
otherPoint: GlobalPoint,
|
||||
|
@ -2083,43 +2261,45 @@ const getBindPointHeading = (
|
|||
hoveredElement &&
|
||||
aabbForElement(
|
||||
hoveredElement,
|
||||
Array(4).fill(
|
||||
distanceToBindableElement(hoveredElement, p, elementsMap),
|
||||
) as [number, number, number, number],
|
||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
),
|
||||
elementsMap,
|
||||
origPoint,
|
||||
);
|
||||
|
||||
const getHoveredElements = (
|
||||
origStartGlobalPoint: GlobalPoint,
|
||||
origEndGlobalPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
const getHoveredElement = (
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
// TODO: Might be a performance bottleneck and the Map type
|
||||
// remembers the insertion order anyway...
|
||||
const nonDeletedSceneElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
||||
new Map([...elementsMap].filter((el) => !el[1].isDeleted)),
|
||||
return getHoveredElementForBinding(
|
||||
tupleToCoors(origPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
const elements = Array.from(elementsMap.values());
|
||||
return [
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(origStartGlobalPoint),
|
||||
elements,
|
||||
nonDeletedSceneElementsMap,
|
||||
zoom,
|
||||
true,
|
||||
),
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(origEndGlobalPoint),
|
||||
elements,
|
||||
nonDeletedSceneElementsMap,
|
||||
zoom,
|
||||
true,
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
||||
a[0] === b[0] && a[1] === b[1];
|
||||
|
||||
export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
|
||||
points: readonly P[],
|
||||
tolerance: number = DEDUP_TRESHOLD,
|
||||
) =>
|
||||
points
|
||||
.slice(1)
|
||||
.map(
|
||||
(p, i) =>
|
||||
Math.abs(p[0] - points[i][0]) < tolerance ||
|
||||
Math.abs(p[1] - points[i][1]) < tolerance,
|
||||
)
|
||||
.every(Boolean);
|
|
@ -2,10 +2,12 @@
|
|||
* Create and link between shapes.
|
||||
*/
|
||||
|
||||
import { ELEMENT_LINK_KEY } from "../constants";
|
||||
import { normalizeLink } from "../data/url";
|
||||
import { elementsAreInSameGroup } from "../groups";
|
||||
import type { AppProps, AppState } from "../types";
|
||||
import { ELEMENT_LINK_KEY, normalizeLink } from "@excalidraw/common";
|
||||
|
||||
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { elementsAreInSameGroup } from "./groups";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export const defaultGetElementLinkFromSelection: Exclude<
|
|
@ -1,22 +1,22 @@
|
|||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
VERTICAL_ALIGN,
|
||||
escapeDoubleQuotes,
|
||||
getFontString,
|
||||
sanitizeHTMLAttribute,
|
||||
updateActiveTool,
|
||||
} from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawProps } from "@excalidraw/excalidraw/types";
|
||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { newTextElement } from "./newElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isIframeElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
IframeData,
|
||||
} from "./types";
|
||||
import type { MarkRequired } from "../utility-types";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
|
||||
|
@ -212,7 +212,7 @@ export const getEmbedLink = (
|
|||
// Note that we don't attempt to parse the username as it can consist of
|
||||
// non-latin1 characters, and the username in the url can be set to anything
|
||||
// without affecting the embed.
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://twitter.com/x/status/${postId}`,
|
||||
);
|
||||
|
||||
|
@ -231,7 +231,7 @@ export const getEmbedLink = (
|
|||
|
||||
if (RE_REDDIT.test(link)) {
|
||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
|
@ -249,7 +249,7 @@ export const getEmbedLink = (
|
|||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://gist.github.com/${user}/${gistId}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
|
@ -321,34 +321,6 @@ export const createPlaceholderEmbeddableLabel = (
|
|||
});
|
||||
};
|
||||
|
||||
export const actionSetEmbeddableAsActiveTool = register({
|
||||
name: "setEmbeddableAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
target: "Tool",
|
||||
label: "toolBar.embeddable",
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
}),
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const matchHostname = (
|
||||
url: string,
|
||||
/** using a Set assumes it already contains normalized bare domains */
|
|
@ -1,3 +1,14 @@
|
|||
import { KEYS, invariant, toBrandedType } from "@excalidraw/common";
|
||||
|
||||
import { type GlobalPoint, pointFrom, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
PendingExcalidrawElements,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
|
@ -7,30 +18,26 @@ import {
|
|||
headingForPointFromElement,
|
||||
type Heading,
|
||||
} from "./heading";
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { KEYS } from "../keys";
|
||||
import type { AppState, PendingExcalidrawElements } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
isElbowArrow,
|
||||
isFrameElement,
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import { invariant } from "../utils";
|
||||
import { pointFrom, type LocalPoint } from "../../math";
|
||||
import { aabbForElement } from "../shapes";
|
||||
import {
|
||||
type ElementsMap,
|
||||
type ExcalidrawBindableElement,
|
||||
type ExcalidrawElement,
|
||||
type ExcalidrawFlowchartNodeElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
type Ordered,
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
|
@ -89,7 +96,7 @@ const getNodeRelatives = (
|
|||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
] as Readonly<LocalPoint>);
|
||||
] as Readonly<GlobalPoint>);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
|
@ -467,7 +474,23 @@ const createBindingArrow = (
|
|||
},
|
||||
]);
|
||||
|
||||
return bindingArrow;
|
||||
const update = updateElbowArrowPoints(
|
||||
bindingArrow,
|
||||
toBrandedType<NonDeletedSceneElementsMap>(
|
||||
new Map([
|
||||
...elementsMap.entries(),
|
||||
[startBindingElement.id, startBindingElement],
|
||||
[endBindingElement.id, endBindingElement],
|
||||
[bindingArrow.id, bindingArrow],
|
||||
] as [string, Ordered<ExcalidrawElement>][]),
|
||||
),
|
||||
{ points: bindingArrow.points },
|
||||
);
|
||||
|
||||
return {
|
||||
...bindingArrow,
|
||||
...update,
|
||||
};
|
||||
};
|
||||
|
||||
export class FlowChartNavigator {
|
|
@ -1,14 +1,20 @@
|
|||
import { generateNKeysBetween } from "fractional-indexing";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
OrderedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { InvalidFractionalIndexError } from "./errors";
|
||||
import { hasBoundTextElement } from "./element/typeChecks";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
import { arrayToMap } from "./utils";
|
||||
} from "./types";
|
||||
|
||||
export class InvalidFractionalIndexError extends Error {
|
||||
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envisioned relation between array order and fractional indices:
|
|
@ -1,8 +1,34 @@
|
|||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
||||
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
|
||||
|
||||
import type { ExcalidrawElementsIncludingDeleted } from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
StaticCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ReadonlySetLike } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./selection";
|
||||
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
|
||||
|
||||
import {
|
||||
getElementLineSegments,
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
isTextElement,
|
||||
} from "./element";
|
||||
} from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
|
@ -10,26 +36,7 @@ import type {
|
|||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "./element/textElement";
|
||||
import { arrayToMap } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
StaticCanvasAppState,
|
||||
} from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
|
||||
import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
|
||||
import { getElementLineSegments } from "./element/bounds";
|
||||
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
|
||||
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
|
||||
import type { ReadonlySetLike } from "./utility-types";
|
||||
import { isPointWithinBounds, pointFrom } from "../math";
|
||||
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
|
@ -1,3 +1,14 @@
|
|||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||
|
||||
import type {
|
||||
GroupId,
|
||||
ExcalidrawElement,
|
||||
|
@ -5,16 +16,7 @@ import type {
|
|||
NonDeletedExcalidrawElement,
|
||||
ElementsMapOrArray,
|
||||
ElementsMap,
|
||||
} from "./element/types";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
} from "./types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
import { makeNextSelectedElementIds } from "./scene/selection";
|
||||
import type { Mutable } from "./utility-types";
|
||||
|
||||
export const selectGroup = (
|
||||
groupId: GroupId,
|
||||
|
@ -213,7 +215,10 @@ export const isSelectedViaGroup = (
|
|||
) => getSelectedGroupForElement(appState, element) != null;
|
||||
|
||||
export const getSelectedGroupForElement = (
|
||||
appState: InteractiveCanvasAppState,
|
||||
appState: Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"editingGroupId" | "selectedGroupIds"
|
||||
>,
|
||||
element: ExcalidrawElement,
|
||||
) =>
|
||||
element.groupIds
|
||||
|
@ -293,24 +298,6 @@ export const getSelectedGroupIdForElement = (
|
|||
selectedGroupIds: { [groupId: string]: boolean },
|
||||
) => element.groupIds.find((groupId) => selectedGroupIds[groupId]);
|
||||
|
||||
export const getNewGroupIdsForDuplication = (
|
||||
groupIds: ExcalidrawElement["groupIds"],
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
mapper: (groupId: GroupId) => GroupId,
|
||||
) => {
|
||||
const copy = [...groupIds];
|
||||
const positionOfEditingGroupId = editingGroupId
|
||||
? groupIds.indexOf(editingGroupId)
|
||||
: -1;
|
||||
const endIndex =
|
||||
positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
|
||||
for (let index = 0; index < endIndex; index++) {
|
||||
copy[index] = mapper(copy[index]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
};
|
||||
|
||||
export const addToGroup = (
|
||||
prevGroupIds: ExcalidrawElement["groupIds"],
|
||||
newGroupId: GroupId,
|
||||
|
@ -397,3 +384,21 @@ export const elementsAreInSameGroup = (
|
|||
export const isInGroup = (element: NonDeletedExcalidrawElement) => {
|
||||
return element.groupIds.length > 0;
|
||||
};
|
||||
|
||||
export const getNewGroupIdsForDuplication = (
|
||||
groupIds: ExcalidrawElement["groupIds"],
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
mapper: (groupId: GroupId) => GroupId,
|
||||
) => {
|
||||
const copy = [...groupIds];
|
||||
const positionOfEditingGroupId = editingGroupId
|
||||
? groupIds.indexOf(editingGroupId)
|
||||
: -1;
|
||||
const endIndex =
|
||||
positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
|
||||
for (let index = 0; index < endIndex; index++) {
|
||||
copy[index] = mapper(copy[index]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
};
|
|
@ -1,19 +1,23 @@
|
|||
import type {
|
||||
LocalPoint,
|
||||
GlobalPoint,
|
||||
Triangle,
|
||||
Vector,
|
||||
Radians,
|
||||
} from "../../math";
|
||||
import {
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
triangleIncludesPoint,
|
||||
vectorFromPoint,
|
||||
} from "../../math";
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
LocalPoint,
|
||||
GlobalPoint,
|
||||
Triangle,
|
||||
Vector,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
|
||||
export const HEADING_RIGHT = [1, 0] as Heading;
|
||||
|
@ -27,8 +31,9 @@ export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
|
|||
b: Point,
|
||||
) => {
|
||||
const angle = radiansToDegrees(
|
||||
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
|
||||
normalizeRadians(Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians),
|
||||
);
|
||||
|
||||
if (angle >= 315 || angle < 45) {
|
||||
return HEADING_UP;
|
||||
} else if (angle >= 45 && angle < 135) {
|
||||
|
@ -74,9 +79,7 @@ export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
|||
// Gets the heading for the point by creating a bounding box around the rotated
|
||||
// close fitting bounding box, then creating 4 search cones around the center of
|
||||
// the external bbox.
|
||||
export const headingForPointFromElement = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
export const headingForPointFromElement = <Point extends GlobalPoint>(
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
p: Readonly<Point>,
|
|
@ -2,9 +2,16 @@
|
|||
// ExcalidrawImageElement & related helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { MIME_TYPES, SVG_NS } from "../constants";
|
||||
import type { AppClassProperties, DataURL, BinaryFiles } from "../types";
|
||||
import { MIME_TYPES, SVG_NS } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
DataURL,
|
||||
BinaryFiles,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isInitializedImageElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
|
@ -1,60 +1,11 @@
|
|||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
export {
|
||||
newElement,
|
||||
newTextElement,
|
||||
refreshTextDimensions,
|
||||
newLinearElement,
|
||||
newArrowElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
export {
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
getCommonBounds,
|
||||
getDiamondPoints,
|
||||
getArrowheadPoints,
|
||||
getClosestElementBounds,
|
||||
} from "./bounds";
|
||||
|
||||
export {
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
} from "./transformHandles";
|
||||
export {
|
||||
resizeTest,
|
||||
getCursorForResizingElement,
|
||||
getElementWithTransformHandleType,
|
||||
getTransformHandleTypeFromCoords,
|
||||
} from "./resizeTest";
|
||||
export {
|
||||
transformElements,
|
||||
getResizeOffsetXY,
|
||||
getResizeArrowDirection,
|
||||
} from "./resizeElements";
|
||||
export {
|
||||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export { redrawTextBoundingBox, getTextFromElements } from "./textElement";
|
||||
export {
|
||||
getPerfectElementSize,
|
||||
getLockedLinearCursorAlignSize,
|
||||
isInvisiblySmallElement,
|
||||
resizePerfectLineForNWHandler,
|
||||
getNormalizedDimensions,
|
||||
} from "./sizeHelpers";
|
||||
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
||||
|
||||
/**
|
||||
* @deprecated unsafe, use hashElementsVersion instead
|
|
@ -1,3 +1,79 @@
|
|||
import {
|
||||
pointCenter,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
|
||||
import {
|
||||
DRAGGING_THRESHOLD,
|
||||
KEYS,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
getGridPoint,
|
||||
invariant,
|
||||
tupleToCoors,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { Store } from "@excalidraw/excalidraw/store";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
PointerCoords,
|
||||
InteractiveCanvasAppState,
|
||||
AppClassProperties,
|
||||
NullableGridSize,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getElementPointsCoords,
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||
import { bumpVersion, mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import {
|
||||
isPathALoop,
|
||||
getBezierCurveLength,
|
||||
getControlPointsForBezierCurve,
|
||||
mapIntervalToBezierT,
|
||||
getBezierXY,
|
||||
} from "./shapes";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
NonDeleted,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -12,63 +88,6 @@ import type {
|
|||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import type { Bounds } from "./bounds";
|
||||
import {
|
||||
getCurvePathOps,
|
||||
getElementPointsCoords,
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
import type {
|
||||
AppState,
|
||||
PointerCoords,
|
||||
InteractiveCanvasAppState,
|
||||
AppClassProperties,
|
||||
NullableGridSize,
|
||||
Zoom,
|
||||
} from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import { invariant, tupleToCoors } from "../utils";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
} from "./typeChecks";
|
||||
import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import type { Store } from "../store";
|
||||
import type Scene from "../scene/Scene";
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vector,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
pointTranslate,
|
||||
vectorFromPoint,
|
||||
} from "../../math";
|
||||
import {
|
||||
getBezierCurveLength,
|
||||
getBezierXY,
|
||||
getControlPointsForBezierCurve,
|
||||
isPathALoop,
|
||||
mapIntervalToBezierT,
|
||||
} from "../shapes";
|
||||
import { getGridPoint } from "../snapping";
|
||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
|
@ -219,7 +238,9 @@ export class LinearElementEditor {
|
|||
});
|
||||
}
|
||||
|
||||
/** @returns whether point was dragged */
|
||||
/**
|
||||
* @returns whether point was dragged
|
||||
*/
|
||||
static handlePointDragging(
|
||||
event: PointerEvent,
|
||||
app: AppClassProperties,
|
||||
|
@ -231,15 +252,15 @@ export class LinearElementEditor {
|
|||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
): boolean {
|
||||
): LinearElementEditor | null {
|
||||
if (!linearElementEditor) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -247,24 +268,18 @@ export class LinearElementEditor {
|
|||
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
||||
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
||||
) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedPointsIndices = isElbowArrow(element)
|
||||
? linearElementEditor.selectedPointsIndices
|
||||
?.reduce(
|
||||
(startEnd, index) =>
|
||||
(index === 0
|
||||
? [0, startEnd[1]]
|
||||
: [startEnd[0], element.points.length - 1]) as [
|
||||
boolean | number,
|
||||
boolean | number,
|
||||
],
|
||||
[false, false] as [number | boolean, number | boolean],
|
||||
)
|
||||
.filter(
|
||||
(idx: number | boolean): idx is number => typeof idx === "number",
|
||||
)
|
||||
? [
|
||||
!!linearElementEditor.selectedPointsIndices?.includes(0)
|
||||
? 0
|
||||
: undefined,
|
||||
!!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0)
|
||||
? element.points.length - 1
|
||||
: undefined,
|
||||
].filter((idx): idx is number => idx !== undefined)
|
||||
: linearElementEditor.selectedPointsIndices;
|
||||
const lastClickedPoint = isElbowArrow(element)
|
||||
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
||||
|
@ -273,9 +288,7 @@ export class LinearElementEditor {
|
|||
: linearElementEditor.pointerDownState.lastClickedPoint;
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[lastClickedPoint] as
|
||||
| [number, number]
|
||||
| undefined;
|
||||
const draggingPoint = element.points[lastClickedPoint];
|
||||
|
||||
if (selectedPointsIndices && draggingPoint) {
|
||||
if (
|
||||
|
@ -383,10 +396,28 @@ export class LinearElementEditor {
|
|||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices,
|
||||
segmentMidPointHoveredCoords:
|
||||
lastClickedPoint !== 0 &&
|
||||
lastClickedPoint !== element.points.length - 1
|
||||
? this.getPointGlobalCoordinates(
|
||||
element,
|
||||
draggingPoint,
|
||||
elementsMap,
|
||||
)
|
||||
: null,
|
||||
hoverPointIndex:
|
||||
lastClickedPoint === 0 ||
|
||||
lastClickedPoint === element.points.length - 1
|
||||
? lastClickedPoint
|
||||
: -1,
|
||||
isDragging: true,
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
static handlePointerUp(
|
||||
|
@ -444,6 +475,8 @@ export class LinearElementEditor {
|
|||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
isElbowArrow(element),
|
||||
isElbowArrow(element),
|
||||
)
|
||||
: null;
|
||||
|
||||
|
@ -796,6 +829,7 @@ export class LinearElementEditor {
|
|||
elements,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
linearElementEditor.elbowed,
|
||||
),
|
||||
};
|
||||
|
||||
|
@ -1260,6 +1294,7 @@ export class LinearElementEditor {
|
|||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
sceneElementsMap?: NonDeletedSceneElementsMap,
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
|
@ -1268,34 +1303,28 @@ export class LinearElementEditor {
|
|||
// all the other points in the opposite direction by delta to
|
||||
// offset it. We do the same with actual element.x/y position, so
|
||||
// this hacks are completely transparent to the user.
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
const [deltaX, deltaY] =
|
||||
targetPoints.find(({ index }) => index === 0)?.point ??
|
||||
pointFrom<LocalPoint>(0, 0);
|
||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||
deltaX - points[0][0],
|
||||
deltaY - points[0][1],
|
||||
);
|
||||
|
||||
const selectedOriginPoint = targetPoints.find(({ index }) => index === 0);
|
||||
const nextPoints = isElbowArrow(element)
|
||||
? [
|
||||
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
|
||||
targetPoints.find((t) => t.index === points.length - 1)?.point ??
|
||||
points[points.length - 1],
|
||||
]
|
||||
: points.map((p, idx) => {
|
||||
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
|
||||
|
||||
if (selectedOriginPoint) {
|
||||
offsetX =
|
||||
selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
|
||||
offsetY =
|
||||
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||
}
|
||||
|
||||
const nextPoints: LocalPoint[] = points.map((p, idx) => {
|
||||
const selectedPointData = targetPoints.find((t) => t.index === idx);
|
||||
if (selectedPointData) {
|
||||
if (selectedPointData.index === 0) {
|
||||
return p;
|
||||
}
|
||||
|
||||
const deltaX =
|
||||
selectedPointData.point[0] - points[selectedPointData.index][0];
|
||||
const deltaY =
|
||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||
|
||||
return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||
}
|
||||
return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
|
||||
});
|
||||
return pointFrom<LocalPoint>(
|
||||
current[0] - offsetX,
|
||||
current[1] - offsetY,
|
||||
);
|
||||
});
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
|
@ -1309,6 +1338,7 @@ export class LinearElementEditor {
|
|||
dragging || targetPoint.isDragging === true,
|
||||
false,
|
||||
),
|
||||
sceneElementsMap,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1422,6 +1452,7 @@ export class LinearElementEditor {
|
|||
options?: {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
sceneElementsMap?: NonDeletedSceneElementsMap;
|
||||
},
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
|
@ -1446,18 +1477,29 @@ export class LinearElementEditor {
|
|||
}
|
||||
|
||||
updates.points = Array.from(nextPoints);
|
||||
updates.points[0] = pointTranslate(
|
||||
updates.points[0],
|
||||
vector(offsetX, offsetY),
|
||||
);
|
||||
updates.points[updates.points.length - 1] = pointTranslate(
|
||||
updates.points[updates.points.length - 1],
|
||||
vector(offsetX, offsetY),
|
||||
);
|
||||
|
||||
mutateElement(element, updates, true, {
|
||||
isDragging: options?.isDragging,
|
||||
});
|
||||
if (!options?.sceneElementsMap || Scene.getScene(element)) {
|
||||
mutateElement(element, updates, true, {
|
||||
isDragging: options?.isDragging,
|
||||
});
|
||||
} else {
|
||||
// The element is not in the scene, so we need to use the provided
|
||||
// scene map.
|
||||
Object.assign(element, {
|
||||
...updates,
|
||||
angle: 0 as Radians,
|
||||
|
||||
...updateElbowArrowPoints(
|
||||
element,
|
||||
options.sceneElementsMap,
|
||||
updates,
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
bumpVersion(element);
|
||||
} else {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
const prevCoords = getElementPointsCoords(element, element.points);
|
|
@ -1,13 +1,24 @@
|
|||
import type { ExcalidrawElement, SceneElementsMap } from "./types";
|
||||
import Scene from "../scene/Scene";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { getUpdatedTimestamp, toBrandedType } from "../utils";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { isElbowArrow } from "./typeChecks";
|
||||
import {
|
||||
getSizeFromPoints,
|
||||
randomInteger,
|
||||
getUpdatedTimestamp,
|
||||
toBrandedType,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import type { Radians } from "../../math";
|
||||
import { isElbowArrow } from "./typeChecks";
|
||||
|
||||
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
|
||||
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
|
@ -33,15 +44,18 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fixedSegments, fileId } = updates as any;
|
||||
const { points, fixedSegments, fileId, startBinding, endBinding } =
|
||||
updates as any;
|
||||
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
(Object.keys(updates).length === 0 || // normalization case
|
||||
typeof points !== "undefined" || // repositioning
|
||||
typeof fixedSegments !== "undefined") // segment fixing
|
||||
typeof fixedSegments !== "undefined" || // segment fixing
|
||||
typeof startBinding !== "undefined" ||
|
||||
typeof endBinding !== "undefined") // manual binding to element
|
||||
) {
|
||||
const elementsMap = toBrandedType<SceneElementsMap>(
|
||||
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
||||
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
|
||||
);
|
||||
|
||||
|
@ -58,6 +72,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||
{
|
||||
fixedSegments,
|
||||
points,
|
||||
startBinding,
|
||||
endBinding,
|
||||
},
|
||||
{
|
||||
isDragging: options?.isDragging,
|
|
@ -1,3 +1,30 @@
|
|||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
randomInteger,
|
||||
randomId,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
getLineHeight,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { MarkOptional, Merge } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
|
@ -6,7 +33,6 @@ import type {
|
|||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
GroupId,
|
||||
VerticalAlign,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
|
@ -21,35 +47,6 @@ import type {
|
|||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import {
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import type { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
measureText,
|
||||
normalizeText,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import type { Radians } from "../../math";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
|
@ -101,6 +98,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||
// element position or sizing
|
||||
if (
|
||||
x < -1e6 ||
|
||||
x > 1e6 ||
|
||||
y < -1e6 ||
|
||||
y > 1e6 ||
|
||||
width < -1e6 ||
|
||||
width > 1e6 ||
|
||||
height < -1e6 ||
|
||||
height > 1e6
|
||||
) {
|
||||
console.error("New element size or position is too large", {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
// @ts-ignore
|
||||
points: rest.points,
|
||||
});
|
||||
}
|
||||
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
id: rest.id || randomId(),
|
||||
|
@ -514,261 +533,3 @@ export const newImageElement = (
|
|||
crop: opts.crop ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
||||
//
|
||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
// Typed arrays and other non-null objects.
|
||||
//
|
||||
// Adapted from https://github.com/lukeed/klona
|
||||
//
|
||||
// The reason for `deepCopyElement()` wrapper is type safety (only allow
|
||||
// passing ExcalidrawElement as the top-level argument).
|
||||
const _deepCopyElement = (val: any, depth: number = 0) => {
|
||||
// only clone non-primitives
|
||||
if (val == null || typeof val !== "object") {
|
||||
return val;
|
||||
}
|
||||
|
||||
const objectType = Object.prototype.toString.call(val);
|
||||
|
||||
if (objectType === "[object Object]") {
|
||||
const tmp =
|
||||
typeof val.constructor === "function"
|
||||
? Object.create(Object.getPrototypeOf(val))
|
||||
: {};
|
||||
for (const key in val) {
|
||||
if (val.hasOwnProperty(key)) {
|
||||
// don't copy non-serializable objects like these caches. They'll be
|
||||
// populated when the element is rendered.
|
||||
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
||||
continue;
|
||||
}
|
||||
tmp[key] = _deepCopyElement(val[key], depth + 1);
|
||||
}
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
let k = val.length;
|
||||
const arr = new Array(k);
|
||||
while (k--) {
|
||||
arr[k] = _deepCopyElement(val[k], depth + 1);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// we're not cloning non-array & non-plain-object objects because we
|
||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
||||
// sure we start cloning them, so let's warn about it.
|
||||
if (import.meta.env.DEV) {
|
||||
if (
|
||||
objectType !== "[object Object]" &&
|
||||
objectType !== "[object Array]" &&
|
||||
objectType.startsWith("[object ")
|
||||
) {
|
||||
console.warn(
|
||||
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
|
||||
* any value. The purpose is to to break object references for immutability
|
||||
* reasons, whenever we want to keep the original element, but ensure it's not
|
||||
* mutated.
|
||||
*
|
||||
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
* Typed arrays and other non-null objects.
|
||||
*/
|
||||
export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
val: T,
|
||||
): Mutable<T> => {
|
||||
return _deepCopyElement(val);
|
||||
};
|
||||
|
||||
/**
|
||||
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||
* for test assertions.
|
||||
*/
|
||||
export const regenerateId = (
|
||||
/** supply null if no previous id exists */
|
||||
previousId: string | null,
|
||||
) => {
|
||||
if (isTestEnv() && previousId) {
|
||||
let nextId = `${previousId}_copy`;
|
||||
// `window.h` may not be defined in some unit tests
|
||||
if (
|
||||
window.h?.app
|
||||
?.getSceneElementsIncludingDeleted()
|
||||
.find((el: ExcalidrawElement) => el.id === nextId)
|
||||
) {
|
||||
nextId += "_copy";
|
||||
}
|
||||
return nextId;
|
||||
}
|
||||
return randomId();
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate an element, often used in the alt-drag operation.
|
||||
* Note that this method has gotten a bit complicated since the
|
||||
* introduction of gruoping/ungrouping elements.
|
||||
* @param editingGroupId The current group being edited. The new
|
||||
* element will inherit this group and its
|
||||
* parents.
|
||||
* @param groupIdMapForOperation A Map that maps old group IDs to
|
||||
* duplicated ones. If you are duplicating
|
||||
* multiple elements at once, share this map
|
||||
* amongst all of them
|
||||
* @param element Element to duplicate
|
||||
* @param overrides Any element properties to override
|
||||
*/
|
||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
): Readonly<TElement> => {
|
||||
let copy = deepCopyElement(element);
|
||||
|
||||
copy.id = regenerateId(copy.id);
|
||||
copy.boundElements = null;
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.seed = randomInteger();
|
||||
copy.groupIds = getNewGroupIdsForDuplication(
|
||||
copy.groupIds,
|
||||
editingGroupId,
|
||||
(groupId) => {
|
||||
if (!groupIdMapForOperation.has(groupId)) {
|
||||
groupIdMapForOperation.set(groupId, regenerateId(groupId));
|
||||
}
|
||||
return groupIdMapForOperation.get(groupId)!;
|
||||
},
|
||||
);
|
||||
if (overrides) {
|
||||
copy = Object.assign(copy, overrides);
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones elements, regenerating their ids (including bindings) and group ids.
|
||||
*
|
||||
* If bindings don't exist in the elements array, they are removed. Therefore,
|
||||
* it's advised to supply the whole elements array, or sets of elements that
|
||||
* are encapsulated (such as library items), if the purpose is to retain
|
||||
* bindings to the cloned elements intact.
|
||||
*
|
||||
* NOTE by default does not randomize or regenerate anything except the id.
|
||||
*/
|
||||
export const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
opts?: {
|
||||
/** NOTE also updates version flags and `updated` */
|
||||
randomizeSeed: boolean;
|
||||
},
|
||||
) => {
|
||||
const clonedElements: ExcalidrawElement[] = [];
|
||||
|
||||
const origElementsMap = arrayToMap(elements);
|
||||
|
||||
// used for for migrating old ids to new ids
|
||||
const elementNewIdsMap = new Map<
|
||||
/* orig */ ExcalidrawElement["id"],
|
||||
/* new */ ExcalidrawElement["id"]
|
||||
>();
|
||||
|
||||
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
|
||||
// if we've already migrated the element id, return the new one directly
|
||||
if (elementNewIdsMap.has(id)) {
|
||||
return elementNewIdsMap.get(id)!;
|
||||
}
|
||||
// if we haven't migrated the element id, but an old element with the same
|
||||
// id exists, generate a new id for it and return it
|
||||
if (origElementsMap.has(id)) {
|
||||
const newId = regenerateId(id);
|
||||
elementNewIdsMap.set(id, newId);
|
||||
return newId;
|
||||
}
|
||||
// if old element doesn't exist, return null to mark it for removal
|
||||
return null;
|
||||
};
|
||||
|
||||
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
|
||||
|
||||
for (const element of elements) {
|
||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
||||
|
||||
clonedElement.id = maybeGetNewId(element.id)!;
|
||||
|
||||
if (opts?.randomizeSeed) {
|
||||
clonedElement.seed = randomInteger();
|
||||
bumpVersion(clonedElement);
|
||||
}
|
||||
|
||||
if (clonedElement.groupIds) {
|
||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||
if (!groupNewIdsMap.has(groupId)) {
|
||||
groupNewIdsMap.set(groupId, regenerateId(groupId));
|
||||
}
|
||||
return groupNewIdsMap.get(groupId)!;
|
||||
});
|
||||
}
|
||||
|
||||
if ("containerId" in clonedElement && clonedElement.containerId) {
|
||||
const newContainerId = maybeGetNewId(clonedElement.containerId);
|
||||
clonedElement.containerId = newContainerId;
|
||||
}
|
||||
|
||||
if ("boundElements" in clonedElement && clonedElement.boundElements) {
|
||||
clonedElement.boundElements = clonedElement.boundElements.reduce(
|
||||
(
|
||||
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
|
||||
binding,
|
||||
) => {
|
||||
const newBindingId = maybeGetNewId(binding.id);
|
||||
if (newBindingId) {
|
||||
acc.push({ ...binding, id: newBindingId });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
if ("endBinding" in clonedElement && clonedElement.endBinding) {
|
||||
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
|
||||
clonedElement.endBinding = newEndBindingId
|
||||
? {
|
||||
...clonedElement.endBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if ("startBinding" in clonedElement && clonedElement.startBinding) {
|
||||
const newEndBindingId = maybeGetNewId(
|
||||
clonedElement.startBinding.elementId,
|
||||
);
|
||||
clonedElement.startBinding = newEndBindingId
|
||||
? {
|
||||
...clonedElement.startBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (clonedElement.frameId) {
|
||||
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
||||
}
|
||||
|
||||
clonedElements.push(clonedElement);
|
||||
}
|
||||
|
||||
return clonedElements;
|
||||
};
|
|
@ -1,3 +1,63 @@
|
|||
import rough from "roughjs/bin/rough";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import { isRightAngleRads } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_REDUCED_GLOBAL_ALPHA,
|
||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||
FRAME_STYLE,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
distance,
|
||||
getFontString,
|
||||
isRTL,
|
||||
getVerticalOffset,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
StaticCanvasAppState,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
PendingExcalidrawElements,
|
||||
NormalizedZoomValue,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type {
|
||||
StaticCanvasRenderConfig,
|
||||
RenderableElementsMap,
|
||||
InteractiveCanvasRenderConfig,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { getUncroppedImageElement } from "./cropElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import { getLineHeightInPx } from "./textMeasurements";
|
||||
import {
|
||||
isTextElement,
|
||||
isLinearElement,
|
||||
isFreeDrawElement,
|
||||
isInitializedImageElement,
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
|
@ -8,62 +68,10 @@ import type {
|
|||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
ElementsMap,
|
||||
} from "../element/types";
|
||||
import {
|
||||
isTextElement,
|
||||
isLinearElement,
|
||||
isFreeDrawElement,
|
||||
isInitializedImageElement,
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
isImageElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
} from "./types";
|
||||
|
||||
import type {
|
||||
StaticCanvasRenderConfig,
|
||||
RenderableElementsMap,
|
||||
InteractiveCanvasRenderConfig,
|
||||
} from "../scene/types";
|
||||
import { distance, getFontString, isRTL } from "../utils";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import type {
|
||||
AppState,
|
||||
StaticCanvasAppState,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
PendingExcalidrawElements,
|
||||
} from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_REDUCED_GLOBAL_ALPHA,
|
||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||
FRAME_STYLE,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
} from "../constants";
|
||||
import type { StrokeOptions } from "perfect-freehand";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getLineHeightInPx,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
import { getContainingFrame } from "../frame";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { getVerticalOffset } from "../fonts";
|
||||
import { isRightAngleRads } from "../../math";
|
||||
import { getCornerRadius } from "../shapes";
|
||||
import { getUncroppedImageElement } from "../element/cropElement";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
|
@ -72,8 +80,6 @@ import { getUncroppedImageElement } from "../element/cropElement";
|
|||
export const IMAGE_INVERT_FILTER =
|
||||
"invert(100%) hue-rotate(180deg) saturate(1.25)";
|
||||
|
||||
const defaultAppState = getDefaultAppState();
|
||||
|
||||
const isPendingImageElement = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
|
@ -533,7 +539,11 @@ const generateElementWithCanvas = (
|
|||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom;
|
||||
const zoom: Zoom = renderConfig
|
||||
? appState.zoom
|
||||
: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
};
|
||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||
const shouldRegenerateBecauseZoom =
|
||||
prevElementWithCanvas &&
|
|
@ -1,5 +1,70 @@
|
|||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
MIN_FONT_SIZE,
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
rescalePoints,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
getCommonBoundingBox,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import {
|
||||
getMinTextElementWidth,
|
||||
measureText,
|
||||
getApproxMinLineWidth,
|
||||
getApproxMinLineHeight,
|
||||
} from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { isInGroup } from "./groups";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type {
|
||||
MaybeTransformHandleType,
|
||||
TransformHandleDirection,
|
||||
} from "./transformHandles";
|
||||
import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
|
@ -12,58 +77,6 @@ import type {
|
|||
SceneElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
getCommonBoundingBox,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getFontString } from "../utils";
|
||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
||||
import type {
|
||||
MaybeTransformHandleType,
|
||||
TransformHandleDirection,
|
||||
} from "./transformHandles";
|
||||
import type { PointerDownState } from "../types";
|
||||
import type Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
getApproxMinLineHeight,
|
||||
measureText,
|
||||
getMinTextElementWidth,
|
||||
} from "./textElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isInGroup } from "../groups";
|
||||
import type { GlobalPoint } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
} from "../../math";
|
||||
|
||||
// Returns true when transform (resizing/rotation) happened
|
||||
export const transformElements = (
|
||||
|
@ -771,8 +784,8 @@ const getResizedOrigin = (
|
|||
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
||||
y:
|
||||
y +
|
||||
(newHeight - prevHeight) / 2 +
|
||||
((prevWidth - newWidth) / 2) * Math.sin(angle),
|
||||
((prevWidth - newWidth) / 2) * Math.sin(angle) +
|
||||
(prevHeight - newHeight) / 2,
|
||||
};
|
||||
case "west-side":
|
||||
return {
|
|
@ -1,33 +1,37 @@
|
|||
import type {
|
||||
ExcalidrawElement,
|
||||
PointerType,
|
||||
NonDeletedExcalidrawElement,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
import {
|
||||
pointFrom,
|
||||
pointOnLineSegment,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
TransformHandleType,
|
||||
TransformHandle,
|
||||
MaybeTransformHandleType,
|
||||
} from "./transformHandles";
|
||||
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
getOmitSidesForDevice,
|
||||
canResizeFromSides,
|
||||
} from "./transformHandles";
|
||||
import type { AppState, Device, Zoom } from "../types";
|
||||
import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { SIDE_RESIZING_THRESHOLD } from "../constants";
|
||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
|
||||
import {
|
||||
pointFrom,
|
||||
pointOnLineSegment,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "../../math";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
TransformHandleType,
|
||||
TransformHandle,
|
||||
MaybeTransformHandleType,
|
||||
} from "./transformHandles";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
PointerType,
|
||||
NonDeletedExcalidrawElement,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
|
||||
const isInsideTransformHandle = (
|
||||
transformHandle: TransformHandle,
|
|
@ -1,19 +1,25 @@
|
|||
import { isShallowEqual } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
} from "./frame";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
||||
import type { AppState, InteractiveCanvasAppState } from "../types";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
} from "../frame";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { isElementInViewport } from "../element/sizeHelpers";
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Frames and their containing elements are not to be selected at the same time.
|
|
@ -1,3 +1,10 @@
|
|||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
isPoint,
|
||||
pointFrom,
|
||||
|
@ -7,7 +14,7 @@ import {
|
|||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "../math";
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
getClosedCurveShape,
|
||||
getCurvePathOps,
|
||||
|
@ -16,129 +23,26 @@ import {
|
|||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
type GeometricShape,
|
||||
} from "../utils/geometry/shape";
|
||||
import {
|
||||
ArrowIcon,
|
||||
DiamondIcon,
|
||||
EllipseIcon,
|
||||
EraserIcon,
|
||||
FreedrawIcon,
|
||||
ImageIcon,
|
||||
LineIcon,
|
||||
RectangleIcon,
|
||||
SelectionIcon,
|
||||
TextIcon,
|
||||
} from "./components/icons";
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "./constants";
|
||||
import { getElementAbsoluteCoords } from "./element";
|
||||
import type { Bounds } from "./element/bounds";
|
||||
import { shouldTestInside } from "./element/collision";
|
||||
import { LinearElementEditor } from "./element/linearElementEditor";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
} 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 type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
} from "./element/types";
|
||||
import { KEYS } from "./keys";
|
||||
import { ShapeCache } from "./scene/ShapeCache";
|
||||
import type { NormalizedZoomValue, Zoom } from "./types";
|
||||
import { invariant } from "./utils";
|
||||
|
||||
export const SHAPES = [
|
||||
{
|
||||
icon: SelectionIcon,
|
||||
value: "selection",
|
||||
key: KEYS.V,
|
||||
numericKey: KEYS["1"],
|
||||
fillable: true,
|
||||
},
|
||||
{
|
||||
icon: RectangleIcon,
|
||||
value: "rectangle",
|
||||
key: KEYS.R,
|
||||
numericKey: KEYS["2"],
|
||||
fillable: true,
|
||||
},
|
||||
{
|
||||
icon: DiamondIcon,
|
||||
value: "diamond",
|
||||
key: KEYS.D,
|
||||
numericKey: KEYS["3"],
|
||||
fillable: true,
|
||||
},
|
||||
{
|
||||
icon: EllipseIcon,
|
||||
value: "ellipse",
|
||||
key: KEYS.O,
|
||||
numericKey: KEYS["4"],
|
||||
fillable: true,
|
||||
},
|
||||
{
|
||||
icon: ArrowIcon,
|
||||
value: "arrow",
|
||||
key: KEYS.A,
|
||||
numericKey: KEYS["5"],
|
||||
fillable: true,
|
||||
},
|
||||
{
|
||||
icon: LineIcon,
|
||||
value: "line",
|
||||
key: KEYS.L,
|
||||
numericKey: KEYS["6"],
|
||||
fillable: true,
|
||||
},
|
||||
{
|
||||
icon: FreedrawIcon,
|
||||
value: "freedraw",
|
||||
key: [KEYS.P, KEYS.X],
|
||||
numericKey: KEYS["7"],
|
||||
fillable: false,
|
||||
},
|
||||
{
|
||||
icon: TextIcon,
|
||||
value: "text",
|
||||
key: KEYS.T,
|
||||
numericKey: KEYS["8"],
|
||||
fillable: false,
|
||||
},
|
||||
{
|
||||
icon: ImageIcon,
|
||||
value: "image",
|
||||
key: null,
|
||||
numericKey: KEYS["9"],
|
||||
fillable: false,
|
||||
},
|
||||
{
|
||||
icon: EraserIcon,
|
||||
value: "eraser",
|
||||
key: KEYS.E,
|
||||
numericKey: KEYS["0"],
|
||||
fillable: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
const shape = SHAPES.find((shape, index) => {
|
||||
return (
|
||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||
(shape.key &&
|
||||
(typeof shape.key === "string"
|
||||
? shape.key === key
|
||||
: (shape.key as readonly string[]).includes(key)))
|
||||
);
|
||||
});
|
||||
return shape?.value || null;
|
||||
};
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* get the pure geometric shape of an excalidraw element
|
||||
* get the pure geometric shape of an excalidraw elementw
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|
@ -1,6 +1,8 @@
|
|||
import type { UIAppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "./types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import type { UIAppState } from "../types";
|
||||
|
||||
export const showSelectedShapeActions = (
|
||||
appState: UIAppState,
|
|
@ -1,10 +1,15 @@
|
|||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
import {
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import type { AppState, Offsets, Zoom } from "../types";
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import { viewportCoordsToSceneCoords } from "../utils";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
|
@ -1,4 +1,5 @@
|
|||
import { arrayToMapWithIndex } from "../utils";
|
||||
import { arrayToMapWithIndex } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
|
@ -116,8 +117,5 @@ const normalizeBoundElementsOrder = (
|
|||
export const normalizeElementOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
// console.time();
|
||||
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||
// console.timeEnd();
|
||||
return ret;
|
||||
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||
};
|
|
@ -1,4 +1,32 @@
|
|||
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
||||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isArrowElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
|
@ -6,42 +34,8 @@ import type {
|
|||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
import { isTextElement } from ".";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import type { AppState } from "../types";
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import type { ExtractSetType } from "../utility-types";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
normalizeEOL(text)
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.replace(/\t/g, " ")
|
||||
);
|
||||
};
|
||||
|
||||
const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
|
@ -122,48 +116,6 @@ export const redrawTextBoundingBox = (
|
|||
mutateElement(textElement, boundTextUpdates, informMutation);
|
||||
};
|
||||
|
||||
export const bindTextToShapeAfterDuplication = (
|
||||
newElements: ExcalidrawElement[],
|
||||
oldElements: ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
): void => {
|
||||
const newElementsMap = arrayToMap(newElements) as Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
oldElements.forEach((element) => {
|
||||
const newElementId = oldIdToDuplicatedId.get(element.id) as string;
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
if (boundTextElementId) {
|
||||
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
|
||||
if (newTextElementId) {
|
||||
const newContainer = newElementsMap.get(newElementId);
|
||||
if (newContainer) {
|
||||
mutateElement(newContainer, {
|
||||
boundElements: (element.boundElements || [])
|
||||
.filter(
|
||||
(boundElement) =>
|
||||
boundElement.id !== newTextElementId &&
|
||||
boundElement.id !== boundTextElementId,
|
||||
)
|
||||
.concat({
|
||||
type: "text",
|
||||
id: newTextElementId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const newTextElement = newElementsMap.get(newTextElementId);
|
||||
if (newTextElement && isTextElement(newTextElement)) {
|
||||
mutateElement(newTextElement, {
|
||||
containerId: newContainer ? newElementId : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
|
@ -281,201 +233,6 @@ export const computeBoundTextPosition = (
|
|||
return { x, y };
|
||||
};
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
const _text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
/**
|
||||
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
|
||||
*
|
||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||
*
|
||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||
* - text wrapping
|
||||
* - wysiwyg editor (+padding)
|
||||
*
|
||||
* Everything else should be based on the actual bounding box width.
|
||||
*
|
||||
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
|
||||
*/
|
||||
export const getLineWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const metrics = canvas2dContext.measureText(text);
|
||||
|
||||
const advanceWidth = metrics.width;
|
||||
|
||||
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
|
||||
if (
|
||||
!forceAdvanceWidth &&
|
||||
window.TextMetrics &&
|
||||
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
|
||||
"actualBoundingBoxRight" in window.TextMetrics.prototype
|
||||
) {
|
||||
// could be negative, therefore getting the absolute value
|
||||
const actualWidth =
|
||||
Math.abs(metrics.actualBoundingBoxLeft) +
|
||||
Math.abs(metrics.actualBoundingBoxRight);
|
||||
|
||||
// fallback to advance width if the actual width is zero, i.e. on text editing start
|
||||
// or when actual width does not respect whitespace chars, i.e. spaces
|
||||
// otherwise actual width should always be bigger
|
||||
return Math.max(actualWidth, advanceWidth);
|
||||
}
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return advanceWidth * 10;
|
||||
}
|
||||
|
||||
return advanceWidth;
|
||||
};
|
||||
|
||||
export const getTextWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
|
||||
});
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const unicode = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][unicode]) {
|
||||
const width = getLineWidth(char, font, true);
|
||||
cachedCharWidth[font][unicode] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][unicode];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
|
||||
const clearCache = (font: FontString) => {
|
||||
cachedCharWidth[font] = [];
|
||||
};
|
||||
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
clearCache,
|
||||
};
|
||||
})();
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
// FIXME rename to getApproxMinContainerWidth
|
||||
export const getApproxMinLineWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMinCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||
|
@ -712,24 +469,6 @@ export const getBoundTextMaxHeight = (
|
|||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
export const getMinTextElementWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
/** retrieves text from text elements and concatenates to a single string */
|
||||
export const getTextFromElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
227
packages/element/src/textMeasurements.ts
Normal file
227
packages/element/src/textMeasurements.ts
Normal file
|
@ -0,0 +1,227 @@
|
|||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
getFontString,
|
||||
isTestEnv,
|
||||
normalizeEOL,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { FontString, ExcalidrawTextElement } from "./types";
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const _text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||
const width = getTextWidth(_text, font);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
// FIXME rename to getApproxMinContainerWidth
|
||||
export const getApproxMinLineWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMinTextElementWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
normalizeEOL(text)
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.replace(/\t/g, " ")
|
||||
);
|
||||
};
|
||||
|
||||
const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
let textMetricsProvider: TextMetricsProvider | undefined;
|
||||
|
||||
/**
|
||||
* Set a custom text metrics provider.
|
||||
*
|
||||
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
|
||||
*/
|
||||
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
|
||||
textMetricsProvider = provider;
|
||||
};
|
||||
|
||||
export interface TextMetricsProvider {
|
||||
getLineWidth(text: string, fontString: FontString): number;
|
||||
}
|
||||
|
||||
class CanvasTextMetricsProvider implements TextMetricsProvider {
|
||||
private canvas: HTMLCanvasElement;
|
||||
|
||||
constructor() {
|
||||
this.canvas = document.createElement("canvas");
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||
* - text wrapping
|
||||
* - wysiwyg editor (+padding)
|
||||
*
|
||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||
*/
|
||||
public getLineWidth(text: string, fontString: FontString): number {
|
||||
const context = this.canvas.getContext("2d")!;
|
||||
context.font = fontString;
|
||||
const metrics = context.measureText(text);
|
||||
const advanceWidth = metrics.width;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return advanceWidth * 10;
|
||||
}
|
||||
|
||||
return advanceWidth;
|
||||
}
|
||||
}
|
||||
|
||||
export const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!textMetricsProvider) {
|
||||
textMetricsProvider = new CanvasTextMetricsProvider();
|
||||
}
|
||||
|
||||
return textMetricsProvider.getLineWidth(text, font);
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const unicode = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][unicode]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][unicode] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][unicode];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
|
||||
const clearCache = (font: FontString) => {
|
||||
cachedCharWidth[font] = [];
|
||||
};
|
||||
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
clearCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getMinCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
|
@ -1,5 +1,7 @@
|
|||
import { ENV } from "../constants";
|
||||
import { charWidth, getLineWidth } from "./textElement";
|
||||
import { isDevEnv, isTestEnv } from "@excalidraw/common";
|
||||
|
||||
import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
|
||||
import type { FontString } from "./types";
|
||||
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
|
@ -385,7 +387,7 @@ export const wrapText = (
|
|||
const originalLines = text.split("\n");
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
|
@ -423,7 +425,7 @@ const wrapLine = (
|
|||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||
const testLineWidth = isSingleCharacter(token)
|
||||
? currentLineWidth + charWidth.calculate(token, font)
|
||||
: getLineWidth(testLine, font, true);
|
||||
: getLineWidth(testLine, font);
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||
|
@ -443,7 +445,7 @@ const wrapLine = (
|
|||
|
||||
// trailing line of the wrapped word might still be joined with next token/s
|
||||
currentLine = trailingLine;
|
||||
currentLineWidth = getLineWidth(trailingLine, font, true);
|
||||
currentLineWidth = getLineWidth(trailingLine, font);
|
||||
iterator = tokenIterator.next();
|
||||
} else {
|
||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||
|
@ -514,7 +516,7 @@ const wrapWord = (
|
|||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||
*/
|
||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
return line;
|
||||
|
@ -527,7 +529,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
|||
"",
|
||||
];
|
||||
|
||||
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
|
||||
let trimmedLineWidth = getLineWidth(trimmedLine, font);
|
||||
|
||||
for (const whitespace of Array.from(whitespaces)) {
|
||||
const _charWidth = charWidth.calculate(whitespace, font);
|
||||
|
@ -560,7 +562,7 @@ const isSingleCharacter = (maybeSingleCharacter: string) => {
|
|||
* Invariant for the word wrapping algorithm.
|
||||
*/
|
||||
const satisfiesWordInvariant = (word: string) => {
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
if (/\s/.test(word)) {
|
||||
throw new Error("Word should not contain any whitespaces!");
|
||||
}
|
|
@ -1,26 +1,34 @@
|
|||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointerType,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
Device,
|
||||
InteractiveCanvasAppState,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
} from "../constants";
|
||||
import type { Radians } from "../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../math";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointerType,
|
||||
} from "./types";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
|
@ -1,7 +1,9 @@
|
|||
import { ROUNDNESS } from "../constants";
|
||||
import type { ElementOrToolType } from "../types";
|
||||
import type { MarkNonNullable } from "../utility-types";
|
||||
import { assertNever } from "../utils";
|
||||
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
||||
|
||||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
|
@ -1,17 +1,19 @@
|
|||
import type { LocalPoint, Radians } from "../../math";
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
TEXT_ALIGN,
|
||||
THEME,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
MakeBrand,
|
||||
MarkNonNullable,
|
||||
Merge,
|
||||
ValueOf,
|
||||
} from "../utility-types";
|
||||
} from "@excalidraw/common/utility-types";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
|
@ -337,7 +339,7 @@ export type ExcalidrawElbowArrowElement = Merge<
|
|||
elbowed: true;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
fixedSegments: FixedSegment[] | null;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
/**
|
||||
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
||||
* order to temporarily hide the first segment of the arrow without losing
|
359
packages/element/src/utils.ts
Normal file
359
packages/element/src/utils.ts
Normal file
|
@ -0,0 +1,359 @@
|
|||
import {
|
||||
curve,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves.
|
||||
*
|
||||
* @param element Target rectanguloid element
|
||||
* @param offset Optional offset to expand the rectanguloid shape
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const roundness = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
if (roundness <= 0) {
|
||||
const r = rectangle(
|
||||
pointFrom(element.x - offset, element.y - offset),
|
||||
pointFrom(
|
||||
element.x + element.width + offset,
|
||||
element.y + element.height + offset,
|
||||
),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
const sides = [top, right, bottom, left];
|
||||
|
||||
return [sides, []];
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const r = rectangle(
|
||||
pointFrom(element.x, element.y),
|
||||
pointFrom(element.x + element.width, element.y + element.height),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
|
||||
const offsets = [
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), // TOP LEFT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), //TOP RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const corners = [
|
||||
curve(
|
||||
pointFromVector(offsets[0], left[1]),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[0], top[0]),
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
pointFromVector(offsets[1], top[1]),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], right[0]),
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[2], right[1]),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], bottom[1]),
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[3], bottom[0]),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], left[0]),
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the 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)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
||||
|
||||
if (element.roundness?.type == null) {
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY - offset),
|
||||
pointFrom(element.x + rightX + offset, element.y + rightY),
|
||||
pointFrom(element.x + bottomX, element.y + bottomY + offset),
|
||||
pointFrom(element.x + leftX - offset, element.y + leftY),
|
||||
];
|
||||
|
||||
// Create the line segment parts of the diamond
|
||||
// NOTE: Horizontal and vertical seems to be flipped here
|
||||
const topRight = lineSegment<GlobalPoint>(
|
||||
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
|
||||
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
|
||||
);
|
||||
const bottomRight = lineSegment<GlobalPoint>(
|
||||
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
|
||||
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
|
||||
);
|
||||
const bottomLeft = lineSegment<GlobalPoint>(
|
||||
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
|
||||
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
|
||||
);
|
||||
const topLeft = lineSegment<GlobalPoint>(
|
||||
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
|
||||
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
|
||||
);
|
||||
|
||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
pointFrom(element.x + rightX, element.y + rightY),
|
||||
pointFrom(element.x + bottomX, element.y + bottomY),
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const offsets = [
|
||||
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
|
||||
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
|
||||
];
|
||||
|
||||
const corners = [
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // RIGHT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // BOTTOM
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // LEFT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
}
|
|
@ -1,14 +1,18 @@
|
|||
import { isFrameLikeElement } from "./element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
} from "./element/types";
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import { isFrameLikeElement } from "./typeChecks";
|
||||
|
||||
import { getElementsInGroup } from "./groups";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import Scene from "./scene/Scene";
|
||||
import type { AppState } from "./types";
|
||||
import { arrayToMap, findIndex, findLastIndex } from "./utils";
|
||||
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
|
||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||
return element.frameId === frameId || element.id === frameId;
|
||||
|
@ -78,11 +82,11 @@ const getTargetIndexAccountingForBinding = (
|
|||
nextElement: ExcalidrawElement,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
direction: "left" | "right",
|
||||
scene: Scene,
|
||||
) => {
|
||||
if ("containerId" in nextElement && nextElement.containerId) {
|
||||
const containerElement = Scene.getScene(nextElement)!.getElement(
|
||||
nextElement.containerId,
|
||||
);
|
||||
// TODO: why not to get the container from the nextElements?
|
||||
const containerElement = scene.getElement(nextElement.containerId);
|
||||
if (containerElement) {
|
||||
return direction === "left"
|
||||
? Math.min(
|
||||
|
@ -99,8 +103,7 @@ const getTargetIndexAccountingForBinding = (
|
|||
(binding) => binding.type !== "arrow",
|
||||
)?.id;
|
||||
if (boundElementId) {
|
||||
const boundTextElement =
|
||||
Scene.getScene(nextElement)!.getElement(boundElementId);
|
||||
const boundTextElement = scene.getElement(boundElementId);
|
||||
if (boundTextElement) {
|
||||
return direction === "left"
|
||||
? Math.min(
|
||||
|
@ -150,6 +153,7 @@ const getTargetIndex = (
|
|||
* If whole frame (including all children) is being moved, supply `null`.
|
||||
*/
|
||||
containingFrame: ExcalidrawFrameLikeElement["id"] | null,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const sourceElement = elements[boundaryIndex];
|
||||
|
||||
|
@ -189,8 +193,12 @@ const getTargetIndex = (
|
|||
sourceElement?.groupIds.join("") === nextElement?.groupIds.join("")
|
||||
) {
|
||||
return (
|
||||
getTargetIndexAccountingForBinding(nextElement, elements, direction) ??
|
||||
candidateIndex
|
||||
getTargetIndexAccountingForBinding(
|
||||
nextElement,
|
||||
elements,
|
||||
direction,
|
||||
scene,
|
||||
) ?? candidateIndex
|
||||
);
|
||||
} else if (!nextElement?.groupIds.includes(appState.editingGroupId)) {
|
||||
// candidate element is outside current editing group → prevent
|
||||
|
@ -213,8 +221,12 @@ const getTargetIndex = (
|
|||
|
||||
if (!nextElement.groupIds.length) {
|
||||
return (
|
||||
getTargetIndexAccountingForBinding(nextElement, elements, direction) ??
|
||||
candidateIndex
|
||||
getTargetIndexAccountingForBinding(
|
||||
nextElement,
|
||||
elements,
|
||||
direction,
|
||||
scene,
|
||||
) ?? candidateIndex
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -254,6 +266,7 @@ const shiftElementsByOne = (
|
|||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
direction: "left" | "right",
|
||||
scene: Scene,
|
||||
) => {
|
||||
const indicesToMove = getIndicesToMove(elements, appState);
|
||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||
|
@ -288,6 +301,7 @@ const shiftElementsByOne = (
|
|||
boundaryIndex,
|
||||
direction,
|
||||
containingFrame,
|
||||
scene,
|
||||
);
|
||||
|
||||
if (targetIndex === -1 || boundaryIndex === targetIndex) {
|
||||
|
@ -501,15 +515,17 @@ function shiftElementsAccountingForFrames(
|
|||
export const moveOneLeft = (
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
return shiftElementsByOne(allElements, appState, "left");
|
||||
return shiftElementsByOne(allElements, appState, "left", scene);
|
||||
};
|
||||
|
||||
export const moveOneRight = (
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
return shiftElementsByOne(allElements, appState, "right");
|
||||
return shiftElementsByOne(allElements, appState, "right", scene);
|
||||
};
|
||||
|
||||
export const moveAllLeft = (
|
|
@ -1,11 +1,5 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { act, render } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { defaultLang, setLanguage } from "../i18n";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
actionAlignVerticallyCentered,
|
||||
actionAlignHorizontallyCentered,
|
||||
|
@ -14,7 +8,17 @@ import {
|
|||
actionAlignBottom,
|
||||
actionAlignLeft,
|
||||
actionAlignRight,
|
||||
} from "../actions";
|
||||
} 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");
|
||||
|
||||
|
@ -54,8 +58,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
|||
|
||||
describe("aligning", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
unmountComponent();
|
||||
mouse.reset();
|
||||
|
||||
await act(() => {
|
|
@ -1,13 +1,16 @@
|
|||
import React from "react";
|
||||
import { fireEvent, render } from "./test-utils";
|
||||
import { Excalidraw, isLinearElement } from "../index";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { pointFrom } from "../../math";
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { actionWrapTextInContainer } from "@excalidraw/excalidraw/actions/actionBoundText";
|
||||
|
||||
import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { getTransformHandles } from "../src/transformHandles";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
|
@ -64,7 +67,6 @@ describe("element binding", () => {
|
|||
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
fixedPoint: null,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
|
@ -77,13 +79,11 @@ describe("element binding", () => {
|
|||
// Both the start and the end points should be bound
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
fixedPoint: null,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
expect(arrow.endBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
fixedPoint: null,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
|
@ -1,9 +1,12 @@
|
|||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import type { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
|
||||
|
||||
const _ce = ({
|
||||
x,
|
|
@ -1,11 +1,37 @@
|
|||
import { duplicateElement, duplicateElements } from "./newElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
||||
import { isPrimitive } from "../utils";
|
||||
import type { ExcalidrawLinearElement } from "./types";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import React from "react";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
ORIG_ID,
|
||||
ROUNDNESS,
|
||||
isPrimitive,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
assertElements,
|
||||
getCloneByOrigId,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
|
@ -41,7 +67,7 @@ describe("duplicating single elements", () => {
|
|||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
const copy = duplicateElement(null, new Map(), element, undefined, true);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
|
@ -60,6 +86,8 @@ describe("duplicating single elements", () => {
|
|||
...element,
|
||||
id: copy.id,
|
||||
seed: copy.seed,
|
||||
version: copy.version,
|
||||
versionNonce: copy.versionNonce,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -145,7 +173,10 @@ describe("duplicating multiple elements", () => {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||
const clonedElements = duplicateElements(origElements);
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
});
|
||||
|
||||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
|
@ -202,6 +233,7 @@ describe("duplicating multiple elements", () => {
|
|||
type: clonedText1.type,
|
||||
}),
|
||||
);
|
||||
expect(clonedRectangle.type).toBe("rectangle");
|
||||
|
||||
clonedArrows.forEach((arrow) => {
|
||||
expect(
|
||||
|
@ -277,7 +309,7 @@ describe("duplicating multiple elements", () => {
|
|||
|
||||
const arrow3 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
id: "arrow3",
|
||||
startBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
|
@ -295,9 +327,11 @@ describe("duplicating multiple elements", () => {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}) as any as { newElements: typeof origElements };
|
||||
|
||||
const [
|
||||
clonedRectangle,
|
||||
clonedText1,
|
||||
|
@ -317,7 +351,6 @@ describe("duplicating multiple elements", () => {
|
|||
elementId: clonedRectangle.id,
|
||||
});
|
||||
expect(clonedArrow2.endBinding).toBe(null);
|
||||
|
||||
expect(clonedArrow3.startBinding).toBe(null);
|
||||
expect(clonedArrow3.endBinding).toEqual({
|
||||
...arrow3.endBinding,
|
||||
|
@ -341,9 +374,10 @@ describe("duplicating multiple elements", () => {
|
|||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}) as any as { newElements: typeof origElements };
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
clonedElements;
|
||||
|
||||
|
@ -364,10 +398,305 @@ describe("duplicating multiple elements", () => {
|
|||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
const [clonedRectangle1] = duplicateElements([rectangle1]);
|
||||
const {
|
||||
newElements: [clonedRectangle1],
|
||||
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
||||
|
||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplication z-order", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("duplication z order with Cmd+D for the lowest z-ordered element should be +1 for the clone", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||
API.setSelectedElements([rectangle1]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplication z order with Cmd+D for the highest z-ordered element should be +1 for the clone", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||
API.setSelectedElements([rectangle3]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||
|
||||
mouse.select(rectangle1);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
||||
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplication z order with alt+drag for the highest z-ordered element should be +1 for the clone", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||
|
||||
mouse.select(rectangle3);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle3.x + 5, rectangle3.y + 5);
|
||||
mouse.up(rectangle3.x + 5, rectangle3.y + 5);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle3.id },
|
||||
{ id: rectangle3.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||
|
||||
mouse.select(rectangle1);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
||||
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplication z order with alt+drag with grouped elements should consider the group together when determining z-index", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
||||
|
||||
mouse.select(rectangle1);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
||||
mouse.up(rectangle1.x + 15, rectangle1.y + 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle3.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id, selected: true },
|
||||
{ id: rectangle3.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverse-duplicating text container (in-order)", async () => {
|
||||
const [rectangle, text] = API.createTextContainer();
|
||||
API.setElements([rectangle, text]);
|
||||
API.setSelectedElements([rectangle, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
},
|
||||
{ id: rectangle.id, selected: true },
|
||||
{ id: text.id, containerId: rectangle.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverse-duplicating text container (out-of-order)", async () => {
|
||||
const [rectangle, text] = API.createTextContainer();
|
||||
API.setElements([text, rectangle]);
|
||||
API.setSelectedElements([rectangle, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
},
|
||||
{ id: rectangle.id, selected: true },
|
||||
{ id: text.id, containerId: rectangle.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverse-duplicating labeled arrows (in-order)", async () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([arrow, text]);
|
||||
API.setSelectedElements([arrow, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
mouse.up(arrow.x + 15, arrow.y + 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: arrow.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||
},
|
||||
{ id: arrow.id, selected: true },
|
||||
{ id: text.id, containerId: arrow.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverse-duplicating labeled arrows (out-of-order)", async () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([text, arrow]);
|
||||
API.setSelectedElements([arrow, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
mouse.up(arrow.x + 15, arrow.y + 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: arrow.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||
},
|
||||
{ id: arrow.id, selected: true },
|
||||
{ id: text.id, containerId: arrow.id, selected: true },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -1,23 +1,33 @@
|
|||
import React from "react";
|
||||
import Scene from "../scene/Scene";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Pointer, UI } from "../tests/helpers/ui";
|
||||
import { ARROW_TYPE } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
queryByTestId,
|
||||
render,
|
||||
} from "../tests/test-utils";
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import "@excalidraw/utils/test-utils";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { bindLinearElement } from "../src/binding";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { ARROW_TYPE } from "../constants";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
} from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
|
@ -291,4 +301,114 @@ describe("elbow arrow ui", () => {
|
|||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(6);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[2] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(4);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -1,12 +1,15 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { reseed } from "../random";
|
||||
import { UI, Keyboard, Pointer } from "../tests/helpers/ui";
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { KEYS, reseed } from "@excalidraw/common";
|
||||
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
|
@ -1,15 +1,24 @@
|
|||
/* eslint-disable no-lone-blocks */
|
||||
import { generateKeyBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
} from "../fractionalIndex";
|
||||
import { API } from "./helpers/api";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { InvalidFractionalIndexError } from "../errors";
|
||||
import type { ExcalidrawElement, FractionalIndex } from "../element/types";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { generateKeyBetween } from "fractional-indexing";
|
||||
} from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { InvalidFractionalIndexError } from "../src/fractionalIndex";
|
||||
|
||||
describe("sync invalid indices with array order", () => {
|
||||
describe("should NOT sync empty array", () => {
|
|
@ -1,9 +1,16 @@
|
|||
import React from "react";
|
||||
import type { ExcalidrawElement } from "./element/types";
|
||||
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
||||
import { API } from "./tests/helpers/api";
|
||||
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
||||
import { render } from "./tests/test-utils";
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
Excalidraw,
|
||||
} from "@excalidraw/excalidraw";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
getCloneByOrigId,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
@ -413,10 +420,10 @@ describe("adding elements to frames", () => {
|
|||
|
||||
dragElementIntoFrame(frame, rect2);
|
||||
|
||||
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||
|
||||
selectElementAndDuplicate(rect2);
|
||||
|
||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
||||
|
||||
expect(rect2_copy.frameId).toBe(frame.id);
|
||||
expect(rect2.frameId).toBe(frame.id);
|
||||
expectEqualIds([rect2_copy, rect2, frame]);
|
||||
|
@ -427,11 +434,11 @@ describe("adding elements to frames", () => {
|
|||
|
||||
dragElementIntoFrame(frame, rect2);
|
||||
|
||||
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||
|
||||
// move the rect2 outside the frame
|
||||
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
||||
|
||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
||||
|
||||
expect(rect2_copy.frameId).toBe(frame.id);
|
||||
expect(rect2.frameId).toBe(null);
|
||||
expectEqualIds([rect2_copy, frame, rect2]);
|
|
@ -1,27 +1,35 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { render } from "./test-utils";
|
||||
import { reseed } from "../random";
|
||||
import { UI, Keyboard, Pointer } from "./helpers/ui";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import {
|
||||
KEYS,
|
||||
getSizeFromPoints,
|
||||
reseed,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isLinearElement } from "../src/typeChecks";
|
||||
import { resizeSingleElement } from "../src/resizeElements";
|
||||
import { LinearElementEditor } from "../src/linearElementEditor";
|
||||
import { getElementPointsCoords } from "../src/bounds";
|
||||
|
||||
import type { Bounds } from "../src/bounds";
|
||||
import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
} from "../element/types";
|
||||
import type { Bounds } from "../element/bounds";
|
||||
import { getElementPointsCoords } from "../element/bounds";
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { arrayToMap } from "../utils";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import { resizeSingleElement } from "../element/resizeElements";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
} from "../src/types";
|
||||
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
@ -182,12 +190,12 @@ describe("generic element", () => {
|
|||
|
||||
UI.resize(rectangle, "e", [40, 0]);
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
|
||||
UI.resize(rectangle, "w", [50, 0]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
||||
});
|
||||
|
||||
it("resizes with a label", async () => {
|
||||
|
@ -502,12 +510,12 @@ describe("arrow element", () => {
|
|||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize(rectangle, "se", [-200, -150]);
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
|
@ -530,12 +538,11 @@ describe("arrow element", () => {
|
|||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
|
@ -812,15 +819,16 @@ describe("image element", () => {
|
|||
|
||||
UI.resize(image, "ne", [40, 0]);
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
|
||||
|
||||
const imageWidth = image.width;
|
||||
const scale = 20 / image.height;
|
||||
UI.resize(image, "nw", [50, 20]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(
|
||||
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
||||
30 + imageWidth * scale,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -1025,7 +1033,7 @@ describe("multiple selection", () => {
|
|||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
|
@ -1047,7 +1055,9 @@ describe("multiple selection", () => {
|
|||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||
rightArrowBinding.elementId,
|
||||
);
|
||||
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
|
||||
expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
|
||||
rightArrowBinding.focus!,
|
||||
);
|
||||
});
|
||||
|
||||
it("resizes with labeled arrows", async () => {
|
|
@ -1,4 +1,4 @@
|
|||
import { makeNextSelectedElementIds } from "./selection";
|
||||
import { makeNextSelectedElementIds } from "../src/selection";
|
||||
|
||||
describe("makeNextSelectedElementIds", () => {
|
||||
const _makeNextSelectedElementIds = (
|
|
@ -1,13 +1,15 @@
|
|||
import { vi } from "vitest";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import * as constants from "../constants";
|
||||
|
||||
import * as constants from "@excalidraw/common";
|
||||
|
||||
import { getPerfectElementSize } from "../src/sizeHelpers";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
// Needed so that we can mock the value of constants which is done in
|
||||
// below tests. In Jest this wasn't needed as global override was possible
|
||||
// but vite doesn't allow that hence we need to mock
|
||||
vi.mock(
|
||||
"../constants.ts",
|
||||
"@excalidraw/common",
|
||||
//@ts-ignore
|
||||
async (importOriginal) => {
|
||||
const module: any = await importOriginal();
|
|
@ -1,7 +1,9 @@
|
|||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { normalizeElementOrder } from "./sortElements";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { normalizeElementOrder } from "../src/sortElements";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
|
||||
const assertOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
|
@ -1,15 +1,17 @@
|
|||
import { FONT_FAMILY } from "../constants";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { getLineHeight } from "@excalidraw/common";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
} from "./textElement";
|
||||
import type { ExcalidrawTextElementWithContainer } from "./types";
|
||||
} from "../src/textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||
|
||||
import type { ExcalidrawTextElementWithContainer } from "../src/types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
describe("Test getContainerCoords", () => {
|
|
@ -1,5 +1,6 @@
|
|||
import { wrapText, parseTokens } from "./textWrapping";
|
||||
import type { FontString } from "./types";
|
||||
import { wrapText, parseTokens } from "../src/textWrapping";
|
||||
|
||||
import type { FontString } from "../src/types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
// font is irrelevant as jsdom does not support FontFace API
|
|
@ -1,5 +1,6 @@
|
|||
import { API } from "../tests/helpers/api";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { hasBoundTextElement } from "../src/typeChecks";
|
||||
|
||||
describe("Test TypeChecks", () => {
|
||||
describe("Test hasBoundTextElement", () => {
|
|
@ -1,26 +1,34 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { act, render } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { reseed } from "../random";
|
||||
import { reseed } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
actionSendBackward,
|
||||
actionBringForward,
|
||||
actionBringToFront,
|
||||
actionSendToBack,
|
||||
actionDuplicateSelection,
|
||||
} from "../actions";
|
||||
import type { AppState } from "../types";
|
||||
import { API } from "./helpers/api";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import {
|
||||
act,
|
||||
getCloneByOrigId,
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { selectGroupsForSelectedElements } from "../src/groups";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawSelectionElement,
|
||||
} from "../element/types";
|
||||
} from "../src/types";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
unmountComponent();
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
@ -916,9 +924,9 @@ describe("z-index manipulation", () => {
|
|||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements).toMatchObject([
|
||||
{ id: "A" },
|
||||
{ id: "A_copy" },
|
||||
{ id: getCloneByOrigId("A").id },
|
||||
{ id: "B" },
|
||||
{ id: "B_copy" },
|
||||
{ id: getCloneByOrigId("B").id },
|
||||
]);
|
||||
|
||||
populateElements([
|
||||
|
@ -930,12 +938,12 @@ describe("z-index manipulation", () => {
|
|||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{
|
||||
id: "A_copy",
|
||||
id: getCloneByOrigId("A").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
{
|
||||
id: "B_copy",
|
||||
id: getCloneByOrigId("B").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
|
@ -951,12 +959,12 @@ describe("z-index manipulation", () => {
|
|||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{
|
||||
id: "A_copy",
|
||||
id: getCloneByOrigId("A").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
{
|
||||
id: "B_copy",
|
||||
id: getCloneByOrigId("B").id,
|
||||
|
||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||
},
|
||||
|
@ -972,10 +980,10 @@ describe("z-index manipulation", () => {
|
|||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
"C_copy",
|
||||
getCloneByOrigId("C").id,
|
||||
]);
|
||||
|
||||
populateElements([
|
||||
|
@ -988,12 +996,12 @@ describe("z-index manipulation", () => {
|
|||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
"D",
|
||||
"C_copy",
|
||||
"D_copy",
|
||||
getCloneByOrigId("C").id,
|
||||
getCloneByOrigId("D").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
|
@ -1010,10 +1018,10 @@ describe("z-index manipulation", () => {
|
|||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
"C_copy",
|
||||
getCloneByOrigId("C").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
|
@ -1031,9 +1039,9 @@ describe("z-index manipulation", () => {
|
|||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
|
@ -1054,15 +1062,15 @@ describe("z-index manipulation", () => {
|
|||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"D_copy",
|
||||
"E_copy",
|
||||
"F_copy",
|
||||
getCloneByOrigId("D").id,
|
||||
getCloneByOrigId("E").id,
|
||||
getCloneByOrigId("F").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
|
@ -1076,7 +1084,7 @@ describe("z-index manipulation", () => {
|
|||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"A_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
"B",
|
||||
"C",
|
||||
]);
|
||||
|
@ -1093,7 +1101,7 @@ describe("z-index manipulation", () => {
|
|||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"B_copy",
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
]);
|
||||
|
||||
|
@ -1108,9 +1116,9 @@ describe("z-index manipulation", () => {
|
|||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"A_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
"B",
|
||||
"B_copy",
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
]);
|
||||
});
|
||||
|
@ -1125,8 +1133,8 @@ describe("z-index manipulation", () => {
|
|||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"C",
|
||||
"A_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"B",
|
||||
]);
|
||||
});
|
||||
|
@ -1144,9 +1152,9 @@ describe("z-index manipulation", () => {
|
|||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"D",
|
||||
]);
|
||||
});
|
8
packages/element/tsconfig.json
Normal file
8
packages/element/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
21
packages/eslintrc.base.json
Normal file
21
packages/eslintrc.base.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["../../excalidraw", "../../../packages/excalidraw", "@excalidraw/excalidraw"],
|
||||
"message": "Do not import from '@excalidraw/excalidraw' package anything but types, as this package must be independent.",
|
||||
"allowTypeImports": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -11,85 +11,741 @@ The change should be grouped under one of the below section and must contain PR
|
|||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
## Excalidraw Library
|
||||
|
||||
## 0.18.0 (2025-03-11)
|
||||
|
||||
### Highlights
|
||||
|
||||
- Command palette [#7804](https://github.com/excalidraw/excalidraw/pull/7804)
|
||||
|
||||
- Multiplayer undo / redo [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
|
||||
- Editable element stats [#6382](https://github.com/excalidraw/excalidraw/pull/6382)
|
||||
|
||||
- Text element wrapping [#7999](https://github.com/excalidraw/excalidraw/pull/7999)
|
||||
|
||||
- Font picker with more fonts [#8012](https://github.com/excalidraw/excalidraw/pull/8012)
|
||||
|
||||
- Font for Chinese, Japanese and Korean [#8530](https://github.com/excalidraw/excalidraw/pull/8530)
|
||||
|
||||
- Font subsetting for SVG export [#8384](https://github.com/excalidraw/excalidraw/pull/8384)
|
||||
|
||||
- Elbow arrows [#8299](https://github.com/excalidraw/excalidraw/pull/8299), [#8952](https://github.com/excalidraw/excalidraw/pull/8952)
|
||||
|
||||
- Flowcharts [#8329](https://github.com/excalidraw/excalidraw/pull/8329)
|
||||
|
||||
- Scene search [#8438](https://github.com/excalidraw/excalidraw/pull/8438)
|
||||
|
||||
- Image cropping [#8613](https://github.com/excalidraw/excalidraw/pull/8613)
|
||||
|
||||
- Element linking [#8812](https://github.com/excalidraw/excalidraw/pull/8812)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
#### Deprecated UMD bundle in favor of ES modules [#7441](https://github.com/excalidraw/excalidraw/pull/7441), [#9127](https://github.com/excalidraw/excalidraw/pull/9127)
|
||||
|
||||
We've transitioned from `UMD` to `ESM` bundle format. Our new `dist` folder inside `@excalidraw/excalidraw` package now contains only bundled source files, making any dependencies tree-shakable. The package comes with the following structure:
|
||||
|
||||
> **Note**: The structure is simplified for the sake of brevity, omitting lazy-loadable modules, including locales (previously treated as JSON assets) and source maps in the development bundle.
|
||||
|
||||
```
|
||||
@excalidraw/excalidraw/
|
||||
├── dist/
|
||||
│ ├── dev/
|
||||
│ │ ├── fonts/
|
||||
│ │ ├── index.css
|
||||
│ │ ├── index.js
|
||||
│ │ ├── index.js.map
|
||||
│ ├── prod/
|
||||
│ │ ├── fonts/
|
||||
│ │ ├── index.css
|
||||
│ │ ├── index.js
|
||||
│ └── types/
|
||||
```
|
||||
|
||||
Make sure that your JavaScript environment supports ES modules. You _may_ need to define `"type": "module"` in your `package.json` file or as part of the `<script type="module" />` attribute.
|
||||
|
||||
##### Typescript: deprecated "moduleResolution": `"node"` or `"node10"`
|
||||
|
||||
Since `"node"` and `"node10"` do not support `package.json` `"exports"` fields, having these values in your `tsconfig.json` will not work. Instead, use `"bundler"`, `"node16"` or `"nodenext"` values. For more information, see [Typescript's documentation](https://www.typescriptlang.org/tsconfig/#moduleResolution).
|
||||
|
||||
##### ESM strict resolution
|
||||
|
||||
Due to ESM's strict resolution, if you're using Webpack or other bundler that expects import paths to be fully specified, you'll need to disable this feature explicitly.
|
||||
|
||||
For example in Webpack, you should set [`resolve.fullySpecified`](https://webpack.js.org/configuration/resolve/#resolvefullyspecified) to `false`.
|
||||
|
||||
For this reason, CRA will no longer work unless you eject or use a workaround such as [craco](https://stackoverflow.com/a/75109686).
|
||||
|
||||
##### New structure of the imports
|
||||
|
||||
Depending on the environment, this is how imports should look like with the `ESM`:
|
||||
|
||||
**With bundler (Vite, Next.js, etc.)**
|
||||
|
||||
```ts
|
||||
// excalidraw library with public API
|
||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||
// excalidraw react component
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
// excalidraw styles, usually auto-processed by the build tool (i.e. vite, next, etc.)
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
// excalidraw types (optional)
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
```
|
||||
|
||||
**Without bundler (Browser)**
|
||||
|
||||
```html
|
||||
<!-- Environment: browser with a script tag and no bundler -->
|
||||
|
||||
<!-- excalidraw styles -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/dev/index.css"
|
||||
/>
|
||||
<!-- import maps used for deduplicating react & react-dom versions -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@19.0.0",
|
||||
"react/jsx-runtime": "https://esm.sh/react@19.0.0/jsx-runtime",
|
||||
"react-dom": "https://esm.sh/react-dom@19.0.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import React from "https://esm.sh/react@19.0.0";
|
||||
import ReactDOM from "https://esm.sh/react-dom@19.0.0";
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/dev/index.js?external=react,react-dom";
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Deprecated `excalidraw-assets` and `excalidraw-assets-dev` folders [#8012](https://github.com/excalidraw/excalidraw/pull/8012), [#9127](https://github.com/excalidraw/excalidraw/pull/9127)
|
||||
|
||||
The `excalidraw-assets` and `excalidraw-assets-dev` folders, which contained locales and fonts, are no longer used and have been deprecated.
|
||||
|
||||
##### Locales
|
||||
|
||||
Locales are no longer treated as static `.json` assets but are transpiled with `esbuild` directly to the `.js` as ES modules. Note that some build tools (i.e. Vite) may require setting `es2022` as a build target, in order to support "Arbitrary module namespace identifier names", e.g. `export { english as "en-us" } )`.
|
||||
|
||||
```js
|
||||
// vite.config.js
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
// Bumping to 2022 due to "Arbitrary module namespace identifier names" not being
|
||||
// supported in Vite's default browser target https://github.com/vitejs/vite/issues/13556
|
||||
target: "es2022",
|
||||
// Tree shaking is optional, but recommended
|
||||
treeShaking: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
##### Fonts
|
||||
|
||||
All fonts are automatically loaded from the [esm.run](https://esm.run/) CDN. For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
|
||||
|
||||
```js
|
||||
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
|
||||
```
|
||||
|
||||
or, if you serve your assets from the root of your CDN, you would do:
|
||||
|
||||
```js
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "https://cdn.domain.com/subpath/";
|
||||
</script>
|
||||
```
|
||||
|
||||
or, if you prefer the path to be dynamically set based on the `location.origin`, you could do the following:
|
||||
|
||||
```jsx
|
||||
// Next.js
|
||||
<Script id="load-env-variables" strategy="beforeInteractive">
|
||||
{`window["EXCALIDRAW_ASSET_PATH"] = location.origin;`} // or use just "/"!
|
||||
</Script>
|
||||
```
|
||||
|
||||
#### Deprecated `commitToHistory` in favor of `captureUpdate` in `updateScene` API [#7348](https://github.com/excalidraw/excalidraw/pull/7348), [#7898](https://github.com/excalidraw/excalidraw/pull//7898)
|
||||
|
||||
```js
|
||||
// before
|
||||
updateScene({ elements, appState, commitToHistory: true }); // A
|
||||
updateScene({ elements, appState, commitToHistory: false }); // B
|
||||
|
||||
// after
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
updateScene({
|
||||
elements,
|
||||
appState,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
}); // A
|
||||
updateScene({
|
||||
elements,
|
||||
appState,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
}); // B
|
||||
```
|
||||
|
||||
The `updateScene` API has changed due to the added `Store` component, as part of the multiplayer undo / redo initiative. Specifically, optional `sceneData` parameter `commitToHistory: boolean` was replaced with optional `captureUpdate: CaptureUpdateActionType` parameter. Therefore, make sure to update all instances of `updateScene`, which use `commitToHistory` parameter according to the _before / after_ table below.
|
||||
|
||||
> **Note**: Some updates are not observed by the store / history - i.e. updates to `collaborators` object or parts of `AppState` which are not observed (not `ObservedAppState`). Such updates will never make it to the undo / redo stacks, regardless of the passed `captureUpdate` value.
|
||||
|
||||
| Undo behaviour | `commitToHistory` (before) | `captureUpdate` (after) | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| _Immediately undoable_ | `true` | `CaptureUpdateAction.IMMEDIATELY` | Use for updates which should be captured. Should be used for most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||
| _Eventually undoable_ | `false` (default) | `CaptureUpdateAction.EVENTUALLY` (default) | Use for updates which should not be captured immediately - likely exceptions which are part of some async multi-step process. Otherwise, all such updates would end up being captured with the next `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||
| _Never undoable_ | n/a | `CaptureUpdateAction.NEVER` | **NEW**: Previously there was no equivalent for this value. Now, it's recommended to use `CaptureUpdateAction.NEVER` for updates which should never be recorded, such as remote updates or scene initialization. These updates will _never_ make it to the local undo / redo stacks. |
|
||||
|
||||
#### Other
|
||||
|
||||
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties. [#7693](https://github.com/excalidraw/excalidraw/pull/7693)
|
||||
|
||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to the private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||
|
||||
- Stats container CSS has changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout. [#8361](https://github.com/excalidraw/excalidraw/pull/8361)
|
||||
|
||||
- `<DefaultSidebar />` triggers are now always merged with host app triggers, rendered through `<DefaultSidebar.Triggers/>`. `<DefaultSidebar.Triggers/>` no longer accepts any props other than children. [#8498](https://github.com/excalidraw/excalidraw/pull/8498)
|
||||
|
||||
### Features
|
||||
|
||||
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
|
||||
|
||||
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
|
||||
|
||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>` [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||
|
||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself) [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||
|
||||
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
|
||||
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB) [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
||||
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638)
|
||||
|
||||
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||
|
||||
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
||||
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
||||
- Extended `window.EXCALIDRAW_ASSET_PATH` to accept array of paths `string[]` as a value, allowing to specify multiple base `URL` fallbacks. [#8286](https://github.com/excalidraw/excalidraw/pull/8286)
|
||||
|
||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||
- Custom text metrics provider [#9121](https://github.com/excalidraw/excalidraw/pull/9121)
|
||||
|
||||
- Add `props.onDuplicate` [#9117](https://github.com/excalidraw/excalidraw/pull/9117)
|
||||
|
||||
- Change empty arrowhead icon [#9100](https://github.com/excalidraw/excalidraw/pull/9100)
|
||||
|
||||
- Tweak slider colors to be more muted [#9076](https://github.com/excalidraw/excalidraw/pull/9076)
|
||||
|
||||
- Improve library sidebar performance [#9060](https://github.com/excalidraw/excalidraw/pull/9060)
|
||||
|
||||
- Implement custom Range component for opacity control [#9009](https://github.com/excalidraw/excalidraw/pull/9009)
|
||||
|
||||
- Box select frame & children to allow resizing at the same time [#9031](https://github.com/excalidraw/excalidraw/pull/9031)
|
||||
|
||||
- Allow installing libs from excal github [#9041](https://github.com/excalidraw/excalidraw/pull/9041)
|
||||
|
||||
- Update jotai [#9015](https://github.com/excalidraw/excalidraw/pull/9015)
|
||||
|
||||
- Do not delete frame children on frame delete [#9011](https://github.com/excalidraw/excalidraw/pull/9011)
|
||||
|
||||
- Add action to wrap selected items in a frame [#9005](https://github.com/excalidraw/excalidraw/pull/9005)
|
||||
|
||||
- Reintroduce `.excalidraw.png` default when embedding scene [#8979](https://github.com/excalidraw/excalidraw/pull/8979)
|
||||
|
||||
- Add mimeTypes on file save [#8946](https://github.com/excalidraw/excalidraw/pull/8946)
|
||||
|
||||
- Add crowfoot to arrowheads [#8942](https://github.com/excalidraw/excalidraw/pull/8942)
|
||||
|
||||
- Make HTML attribute sanitization stricter [#8977](https://github.com/excalidraw/excalidraw/pull/8977)
|
||||
|
||||
- Validate library install urls [#8976](https://github.com/excalidraw/excalidraw/pull/8976)
|
||||
|
||||
- Cleanup svg export and move payload to `<metadata>` [#8975](https://github.com/excalidraw/excalidraw/pull/8975)
|
||||
|
||||
- Use stats panel to crop [#8848](https://github.com/excalidraw/excalidraw/pull/8848)
|
||||
|
||||
- Snap when cropping as well [#8831](https://github.com/excalidraw/excalidraw/pull/8831)
|
||||
|
||||
- Update blog url [#8767](https://github.com/excalidraw/excalidraw/pull/8767)
|
||||
|
||||
- Export scene to e+ on workspace creation/redemption [#8514](https://github.com/excalidraw/excalidraw/pull/8514)
|
||||
|
||||
- Added sitemap & fixed robot txt [#8699](https://github.com/excalidraw/excalidraw/pull/8699)
|
||||
|
||||
- Do not strip unknown element properties on restore [#8682](https://github.com/excalidraw/excalidraw/pull/8682)
|
||||
|
||||
- Added reddit links as embeddable [#8099](https://github.com/excalidraw/excalidraw/pull/8099)
|
||||
|
||||
- Self-hosting existing google fonts [#8540](https://github.com/excalidraw/excalidraw/pull/8540)
|
||||
|
||||
- Flip arrowheads if only arrow(s) selected [#8525](https://github.com/excalidraw/excalidraw/pull/8525)
|
||||
|
||||
- Common elbow mid segments [#8440](https://github.com/excalidraw/excalidraw/pull/8440)
|
||||
|
||||
- Merge search sidebar back to default sidebar [#8497](https://github.com/excalidraw/excalidraw/pull/8497)
|
||||
|
||||
- Smarter zooming when scrolling to match & only match on search/switch [#8488](https://github.com/excalidraw/excalidraw/pull/8488)
|
||||
|
||||
- Reset copyStatus on export dialog settings change [#8443](https://github.com/excalidraw/excalidraw/pull/8443)
|
||||
|
||||
- Tweak copy button success animation [#8441](https://github.com/excalidraw/excalidraw/pull/8441)
|
||||
|
||||
- Enable panning/zoom while in wysiwyg [#8437](https://github.com/excalidraw/excalidraw/pull/8437)
|
||||
|
||||
- Visual debugger [#8344](https://github.com/excalidraw/excalidraw/pull/8344)
|
||||
|
||||
- Improve elbow arrow keyboard move [#8392](https://github.com/excalidraw/excalidraw/pull/8392)
|
||||
|
||||
- Rewrite d2c to not require token [#8269](https://github.com/excalidraw/excalidraw/pull/8269)
|
||||
|
||||
- Split `gridSize` from enabled state & support custom `gridStep` [#8364](https://github.com/excalidraw/excalidraw/pull/8364)
|
||||
|
||||
- Improve zoom-to-content when creating flowchart [#8368](https://github.com/excalidraw/excalidraw/pull/8368)
|
||||
|
||||
- Stats popup style tweaks [#8361](https://github.com/excalidraw/excalidraw/pull/8361)
|
||||
|
||||
- Remove automatic frame naming [#8302](https://github.com/excalidraw/excalidraw/pull/8302)
|
||||
|
||||
- Ability to debug the state of fractional indices [#8235](https://github.com/excalidraw/excalidraw/pull/8235)
|
||||
|
||||
- Improve mermaid detection on paste [#8287](https://github.com/excalidraw/excalidraw/pull/8287)
|
||||
|
||||
- Upgrade mermaid-to-excalidraw to v1.1.0 [#8226](https://github.com/excalidraw/excalidraw/pull/8226)
|
||||
|
||||
- Bump max file size [#8220](https://github.com/excalidraw/excalidraw/pull/8220)
|
||||
|
||||
- Smarter preferred lang detection [#8205](https://github.com/excalidraw/excalidraw/pull/8205)
|
||||
|
||||
- Support Stats bound text `fontSize` editing [#8187](https://github.com/excalidraw/excalidraw/pull/8187)
|
||||
|
||||
- Paste as mermaid if applicable [#8116](https://github.com/excalidraw/excalidraw/pull/8116)
|
||||
|
||||
- Stop autoselecting text on text edit on mobile [#8076](https://github.com/excalidraw/excalidraw/pull/8076)
|
||||
|
||||
- Create new text with width [#8038](https://github.com/excalidraw/excalidraw/pull/8038)
|
||||
|
||||
- Wrap long text when pasting [#8026](https://github.com/excalidraw/excalidraw/pull/8026)
|
||||
|
||||
- Upgrade to mermaid-to-excalidraw v1 🚀 [#8022](https://github.com/excalidraw/excalidraw/pull/8022)
|
||||
|
||||
- Rerender canvas on focus [#8035](https://github.com/excalidraw/excalidraw/pull/8035)
|
||||
|
||||
- Add missing `type="button"` [#8030](https://github.com/excalidraw/excalidraw/pull/8030)
|
||||
|
||||
- Add install-PWA to command palette [#7935](https://github.com/excalidraw/excalidraw/pull/7935)
|
||||
|
||||
- Tweak a few icons & add line editor button to side panel [#7990](https://github.com/excalidraw/excalidraw/pull/7990)
|
||||
|
||||
- Allow binding only via linear element ends [#7946](https://github.com/excalidraw/excalidraw/pull/7946)
|
||||
|
||||
- Resize elements from the sides [#7855](https://github.com/excalidraw/excalidraw/pull/7855)
|
||||
|
||||
- Record freedraw tool selection to history [#7949](https://github.com/excalidraw/excalidraw/pull/7949)
|
||||
|
||||
- Export reconciliation [#7917](https://github.com/excalidraw/excalidraw/pull/7917)
|
||||
|
||||
- Add "toggle grid" to command palette [#7887](https://github.com/excalidraw/excalidraw/pull/7887)
|
||||
|
||||
- Fractional indexing [#7359](https://github.com/excalidraw/excalidraw/pull/7359)
|
||||
|
||||
- Show firefox-compatible command palette shortcut alias [#7825](https://github.com/excalidraw/excalidraw/pull/7825)
|
||||
|
||||
- Upgrade mermaid-to-excalidraw to 0.3.0 [#7819](https://github.com/excalidraw/excalidraw/pull/7819)
|
||||
|
||||
- Support to not render remote cursor & username [#7130](https://github.com/excalidraw/excalidraw/pull/7130)
|
||||
|
||||
- Expose more collaborator status icons [#7777](https://github.com/excalidraw/excalidraw/pull/7777)
|
||||
|
||||
- Close dropdown on escape [#7750](https://github.com/excalidraw/excalidraw/pull/7750)
|
||||
|
||||
- Text measurements based on font metrics [#7693](https://github.com/excalidraw/excalidraw/pull/7693)
|
||||
|
||||
- Improve collab error notification [#7741](https://github.com/excalidraw/excalidraw/pull/7741)
|
||||
|
||||
- Grouped together Undo and Redo buttons on mobile [#9109](https://github.com/excalidraw/excalidraw/pull/9109)
|
||||
|
||||
- Remove GA code from binding [#9042](https://github.com/excalidraw/excalidraw/pull/9042)
|
||||
|
||||
- Load old library if migration fails
|
||||
|
||||
- Change LibraryPersistenceAdapter `load()` `source` -> `priority`
|
||||
|
||||
### Fixes
|
||||
|
||||
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||
- Fix inconsistency in resizing while maintaining aspect ratio [#9116](https://github.com/excalidraw/excalidraw/pull/9116)
|
||||
|
||||
### Breaking Changes
|
||||
- IFrame and elbow arrow interaction fix [#9101](https://github.com/excalidraw/excalidraw/pull/9101)
|
||||
|
||||
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
|
||||
- Duplicating/removing frame while children selected [#9079](https://github.com/excalidraw/excalidraw/pull/9079)
|
||||
|
||||
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
||||
- Elbow arrow z-index binding [#9067](https://github.com/excalidraw/excalidraw/pull/9067)
|
||||
|
||||
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be captured by the store & history. Should be used for the most of the local updates (excluding ephemeral updates such as dragging or resizing). These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be captured immediately (likely exceptions which are part of some async multi-step process) or those not meant to be captured at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being captured with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
|
||||
- Library item checkbox style regression [#9080](https://github.com/excalidraw/excalidraw/pull/9080)
|
||||
|
||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||
- Elbow arrow orthogonality [#9073](https://github.com/excalidraw/excalidraw/pull/9073)
|
||||
|
||||
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
|
||||
- Button bg CSS variable leaking into other styles [#9075](https://github.com/excalidraw/excalidraw/pull/9075)
|
||||
|
||||
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
||||
- Fonts not loading on export (again) [#9064](https://github.com/excalidraw/excalidraw/pull/9064)
|
||||
|
||||
#### Bundler
|
||||
- Merge server-side fonts with liberation sans [#9052](https://github.com/excalidraw/excalidraw/pull/9052)
|
||||
|
||||
- CSS needs to be imported so you will need to import the css along with the excalidraw component
|
||||
- Hyperlinks html entities [#9063](https://github.com/excalidraw/excalidraw/pull/9063)
|
||||
|
||||
```js
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
```
|
||||
- Remove flushSync to fix flickering [#9057](https://github.com/excalidraw/excalidraw/pull/9057)
|
||||
|
||||
- The `types` path is updated
|
||||
- Excalidraw issue #9045 flowcharts: align attributes of new node [#9047](https://github.com/excalidraw/excalidraw/pull/9047)
|
||||
|
||||
Instead of importing from `@excalidraw/excalidraw/types/`, you will need to import from `@excalidraw/excalidraw/dist/excalidraw` or `@excalidraw/excalidraw/dist/utils` depending on the types you are using.
|
||||
- Align arrows bound to elements excalidraw#8833 [#8998](https://github.com/excalidraw/excalidraw/pull/8998)
|
||||
|
||||
However this we will be fixing before stable release, so in case you want to try it out you will need to update the types for now.
|
||||
- Update elbow arrow on font size change #8798 [#9002](https://github.com/excalidraw/excalidraw/pull/9002)
|
||||
|
||||
#### Browser
|
||||
- Undo for elbow arrows create incorrect routing [#9046](https://github.com/excalidraw/excalidraw/pull/9046)
|
||||
|
||||
- Since its `ESM` so now script type `module` can be used to load it and css needs to be loaded as well.
|
||||
- Flowchart clones the current arrowhead [#8581](https://github.com/excalidraw/excalidraw/pull/8581)
|
||||
|
||||
```html
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.css"
|
||||
/>
|
||||
<script type="module">
|
||||
import * as ExcalidrawLib from "https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.js";
|
||||
window.ExcalidrawLib = ExcalidrawLib;
|
||||
</script>
|
||||
```
|
||||
- Adding partial group to frame [#9014](https://github.com/excalidraw/excalidraw/pull/9014)
|
||||
|
||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||
- Do not refocus element link input on unrelated updates [#9037](https://github.com/excalidraw/excalidraw/pull/9037)
|
||||
|
||||
- Arrow binding behaving unexpectedly on pointerup [#9010](https://github.com/excalidraw/excalidraw/pull/9010)
|
||||
|
||||
- Change cursor by tool change immediately [#8212](https://github.com/excalidraw/excalidraw/pull/8212)
|
||||
|
||||
- Package build fails on worker chunks [#8990](https://github.com/excalidraw/excalidraw/pull/8990)
|
||||
|
||||
- Z-index clash in mobile UI [#8985](https://github.com/excalidraw/excalidraw/pull/8985)
|
||||
|
||||
- Elbow arrows do not work within frames (issue: #8964) [#8969](https://github.com/excalidraw/excalidraw/pull/8969)
|
||||
|
||||
- NormalizeSVG width and height from viewbox when size includes decimal points [#8939](https://github.com/excalidraw/excalidraw/pull/8939)
|
||||
|
||||
- Make arrow binding area adapt to zoom levels [#8927](https://github.com/excalidraw/excalidraw/pull/8927)
|
||||
|
||||
- Robust `state.editingFrame` teardown [#8941](https://github.com/excalidraw/excalidraw/pull/8941)
|
||||
|
||||
- Regression on dragging a selected frame by its name [#8924](https://github.com/excalidraw/excalidraw/pull/8924)
|
||||
|
||||
- Right-click paste for images in clipboard (Issue #8826) [#8845](https://github.com/excalidraw/excalidraw/pull/8845)
|
||||
|
||||
- Fixed image transparency by adding alpha option to preserve image alpha channel [#8895](https://github.com/excalidraw/excalidraw/pull/8895)
|
||||
|
||||
- Flush pending DOM updates before .focus() [#8901](https://github.com/excalidraw/excalidraw/pull/8901)
|
||||
|
||||
- Normalize svg using only absolute sizing [#8854](https://github.com/excalidraw/excalidraw/pull/8854)
|
||||
|
||||
- Element link selector dialog z-index & positioning [#8853](https://github.com/excalidraw/excalidraw/pull/8853)
|
||||
|
||||
- Update old blog links & add canonical url [#8846](https://github.com/excalidraw/excalidraw/pull/8846)
|
||||
|
||||
- Optimize frameToHighlight state change and snapLines state change [#8763](https://github.com/excalidraw/excalidraw/pull/8763)
|
||||
|
||||
- Make some events expllicitly active to avoid console warnings [#8757](https://github.com/excalidraw/excalidraw/pull/8757)
|
||||
|
||||
- Unify binding update options for `updateBoundElements()` [#8832](https://github.com/excalidraw/excalidraw/pull/8832)
|
||||
|
||||
- Cleanup scripts and support upto node 22 [#8794](https://github.com/excalidraw/excalidraw/pull/8794)
|
||||
|
||||
- Usage of `node12 which is deprecated` [#8791](https://github.com/excalidraw/excalidraw/pull/8791)
|
||||
|
||||
- Remove manifest.json [#8783](https://github.com/excalidraw/excalidraw/pull/8783)
|
||||
|
||||
- Load env vars correctly and set debug and linter flags to false explicitly in prod mode [#8770](https://github.com/excalidraw/excalidraw/pull/8770)
|
||||
|
||||
- Console error in dev mode due to missing font path in non-prod [#8756](https://github.com/excalidraw/excalidraw/pull/8756)
|
||||
|
||||
- Text pushes UI due to padding [#8745](https://github.com/excalidraw/excalidraw/pull/8745)
|
||||
|
||||
- Fix trailing line whitespaces layout shift [#8714](https://github.com/excalidraw/excalidraw/pull/8714)
|
||||
|
||||
- Load font faces in Safari manually [#8693](https://github.com/excalidraw/excalidraw/pull/8693)
|
||||
|
||||
- Restore svg image DataURL dimensions [#8730](https://github.com/excalidraw/excalidraw/pull/8730)
|
||||
|
||||
- Image cropping svg + compat mode [#8710](https://github.com/excalidraw/excalidraw/pull/8710)
|
||||
|
||||
- Usage of `node12 which is deprecated` [#8709](https://github.com/excalidraw/excalidraw/pull/8709)
|
||||
|
||||
- Image render perf [#8697](https://github.com/excalidraw/excalidraw/pull/8697)
|
||||
|
||||
- Undo/redo action for international keyboard layouts [#8649](https://github.com/excalidraw/excalidraw/pull/8649)
|
||||
|
||||
- Comic Shanns issues, new fonts structure [#8641](https://github.com/excalidraw/excalidraw/pull/8641)
|
||||
|
||||
- Remove export-to-clip-as-svg shortcut for now [#8660](https://github.com/excalidraw/excalidraw/pull/8660)
|
||||
|
||||
- Text disappearing on edit [#8558](https://github.com/excalidraw/excalidraw/pull/8558) (#8624)
|
||||
|
||||
- Elbow arrow fixedpoint flipping now properly flips on inverted resize and flip action [#8324](https://github.com/excalidraw/excalidraw/pull/8324)
|
||||
|
||||
- Svg and png frame clipping cases [#8515](https://github.com/excalidraw/excalidraw/pull/8515)
|
||||
|
||||
- Re-route elbow arrows when pasted [#8448](https://github.com/excalidraw/excalidraw/pull/8448)
|
||||
|
||||
- Buffer dependency [#8474](https://github.com/excalidraw/excalidraw/pull/8474)
|
||||
|
||||
- Linear element complete button disabled [#8492](https://github.com/excalidraw/excalidraw/pull/8492)
|
||||
|
||||
- Aspect ratios of distorted images are not preserved in SVG exports [#8061](https://github.com/excalidraw/excalidraw/pull/8061)
|
||||
|
||||
- WYSIWYG editor padding is not normalized with zoom.value [#8481](https://github.com/excalidraw/excalidraw/pull/8481)
|
||||
|
||||
- Improve canvas search scroll behavior further [#8491](https://github.com/excalidraw/excalidraw/pull/8491)
|
||||
|
||||
- AddFiles clears the whole image cache when each file is added - regression from #8471 [#8490](https://github.com/excalidraw/excalidraw/pull/8490)
|
||||
|
||||
- `select` instead of `focus` search input [#8483](https://github.com/excalidraw/excalidraw/pull/8483)
|
||||
|
||||
- Image rendering issue when passed in `initialData` [#8471](https://github.com/excalidraw/excalidraw/pull/8471)
|
||||
|
||||
- Add partial mocking [#8473](https://github.com/excalidraw/excalidraw/pull/8473)
|
||||
|
||||
- PropertiesPopover maxWidth changing fixed units to relative units [#8456](https://github.com/excalidraw/excalidraw/pull/8456)
|
||||
|
||||
- View mode wheel zooming does not work [#8452](https://github.com/excalidraw/excalidraw/pull/8452)
|
||||
|
||||
- Fixed copy to clipboard button [#8426](https://github.com/excalidraw/excalidraw/pull/8426)
|
||||
|
||||
- Context menu does not work after after dragging on StatsDragInput [#8386](https://github.com/excalidraw/excalidraw/pull/8386)
|
||||
|
||||
- Perf regression in `getCommonBounds` [#8429](https://github.com/excalidraw/excalidraw/pull/8429)
|
||||
|
||||
- Object snapping not working [#8381](https://github.com/excalidraw/excalidraw/pull/8381)
|
||||
|
||||
- Reimplement rectangle intersection [#8367](https://github.com/excalidraw/excalidraw/pull/8367)
|
||||
|
||||
- Round coordinates and sizes for rectangle intersection [#8366](https://github.com/excalidraw/excalidraw/pull/8366)
|
||||
|
||||
- Text content with tab characters act differently in view/edit [#8336](https://github.com/excalidraw/excalidraw/pull/8336)
|
||||
|
||||
- Drawing from 0-dimension canvas [#8356](https://github.com/excalidraw/excalidraw/pull/8356)
|
||||
|
||||
- Disable flowchart keybindings inside inputs [#8353](https://github.com/excalidraw/excalidraw/pull/8353)
|
||||
|
||||
- Yet more patching of intersect code [#8352](https://github.com/excalidraw/excalidraw/pull/8352)
|
||||
|
||||
- Missing `act()` in flowchart tests [#8354](https://github.com/excalidraw/excalidraw/pull/8354)
|
||||
|
||||
- Z-index change by one causes app to freeze [#8314](https://github.com/excalidraw/excalidraw/pull/8314)
|
||||
|
||||
- Patch over intersection calculation issue [#8350](https://github.com/excalidraw/excalidraw/pull/8350)
|
||||
|
||||
- Point duplication in LEE on ALT+click [#8347](https://github.com/excalidraw/excalidraw/pull/8347)
|
||||
|
||||
- Do not allow resizing unbound elbow arrows either [#8333](https://github.com/excalidraw/excalidraw/pull/8333)
|
||||
|
||||
- Docker build in CI [#8312](https://github.com/excalidraw/excalidraw/pull/8312)
|
||||
|
||||
- Duplicating arrow without bound elements throws error [#8316](https://github.com/excalidraw/excalidraw/pull/8316)
|
||||
|
||||
- CVE-2023-45133 [#7988](https://github.com/excalidraw/excalidraw/pull/7988)
|
||||
|
||||
- Throttle fractional indices validation [#8306](https://github.com/excalidraw/excalidraw/pull/8306)
|
||||
|
||||
- Allow binding elbow arrows to frame children [#8309](https://github.com/excalidraw/excalidraw/pull/8309)
|
||||
|
||||
- Skip registering font faces for local fonts [#8303](https://github.com/excalidraw/excalidraw/pull/8303)
|
||||
|
||||
- Load fonts for `exportToCanvas` [#8298](https://github.com/excalidraw/excalidraw/pull/8298)
|
||||
|
||||
- Re-add Cascadia Code with ligatures [#8291](https://github.com/excalidraw/excalidraw/pull/8291)
|
||||
|
||||
- Linear elements not selected on pointer up from hitting its bound text [#8285](https://github.com/excalidraw/excalidraw/pull/8285)
|
||||
|
||||
- Revert default element canvas padding change [#8266](https://github.com/excalidraw/excalidraw/pull/8266)
|
||||
|
||||
- Freedraw jittering [#8238](https://github.com/excalidraw/excalidraw/pull/8238)
|
||||
|
||||
- Messed up env variable [#8231](https://github.com/excalidraw/excalidraw/pull/8231)
|
||||
|
||||
- Log allowed events [#8224](https://github.com/excalidraw/excalidraw/pull/8224)
|
||||
|
||||
- Memory leak - scene.destroy() and window.launchQueue [#8198](https://github.com/excalidraw/excalidraw/pull/8198)
|
||||
|
||||
- Stop updating text versions on init [#8191](https://github.com/excalidraw/excalidraw/pull/8191)
|
||||
|
||||
- Add binding update to manual stat changes [#8183](https://github.com/excalidraw/excalidraw/pull/8183)
|
||||
|
||||
- Binding after duplicating is now applied for both the old and duplicate shapes [#8185](https://github.com/excalidraw/excalidraw/pull/8185)
|
||||
|
||||
- Incorrect point offsetting in LinearElementEditor.movePoints() [#8145](https://github.com/excalidraw/excalidraw/pull/8145)
|
||||
|
||||
- Stats state leaking & race conds [#8177](https://github.com/excalidraw/excalidraw/pull/8177)
|
||||
|
||||
- Only bind arrow [#8152](https://github.com/excalidraw/excalidraw/pull/8152)
|
||||
|
||||
- Repair invalid binding on restore & fix type check [#8133](https://github.com/excalidraw/excalidraw/pull/8133)
|
||||
|
||||
- Wysiwyg blur-submit on mobile [#8075](https://github.com/excalidraw/excalidraw/pull/8075)
|
||||
|
||||
- Restore linear dimensions from points [#8062](https://github.com/excalidraw/excalidraw/pull/8062)
|
||||
|
||||
- Lp plus url [#8056](https://github.com/excalidraw/excalidraw/pull/8056)
|
||||
|
||||
- Fix twitter og image [#8050](https://github.com/excalidraw/excalidraw/pull/8050)
|
||||
|
||||
- Flaky snapshot tests with floating point precision issues [#8049](https://github.com/excalidraw/excalidraw/pull/8049)
|
||||
|
||||
- Always re-generate index of defined moved elements [#8040](https://github.com/excalidraw/excalidraw/pull/8040)
|
||||
|
||||
- Undo/redo when exiting view mode [#8024](https://github.com/excalidraw/excalidraw/pull/8024)
|
||||
|
||||
- Two finger panning is slow [#7849](https://github.com/excalidraw/excalidraw/pull/7849)
|
||||
|
||||
- Compatible safari layers button svg [#8020](https://github.com/excalidraw/excalidraw/pull/8020)
|
||||
|
||||
- Correctly resolve the package version [#8016](https://github.com/excalidraw/excalidraw/pull/8016)
|
||||
|
||||
- Re-introduce wysiwyg width offset [#8014](https://github.com/excalidraw/excalidraw/pull/8014)
|
||||
|
||||
- Font not rendered correctly on init [#8002](https://github.com/excalidraw/excalidraw/pull/8002)
|
||||
|
||||
- Command palette filter [#7981](https://github.com/excalidraw/excalidraw/pull/7981)
|
||||
|
||||
- Remove unused param from drawImagePlaceholder [#7991](https://github.com/excalidraw/excalidraw/pull/7991)
|
||||
|
||||
- Docker build of Excalidraw app [#7430](https://github.com/excalidraw/excalidraw/pull/7430)
|
||||
|
||||
- Typo in doc api [#7466](https://github.com/excalidraw/excalidraw/pull/7466)
|
||||
|
||||
- Use Reflect API instead of Object.hasOwn [#7958](https://github.com/excalidraw/excalidraw/pull/7958)
|
||||
|
||||
- CTRL/CMD & arrow point drag unbinds both sides [#6459](https://github.com/excalidraw/excalidraw/pull/6459) (#7877)
|
||||
|
||||
- Z-index for laser pointer to be able to draw on embeds and such [#7918](https://github.com/excalidraw/excalidraw/pull/7918)
|
||||
|
||||
- Double text rendering on edit [#7904](https://github.com/excalidraw/excalidraw/pull/7904)
|
||||
|
||||
- Collision regressions from vector geometry rewrite [#7902](https://github.com/excalidraw/excalidraw/pull/7902)
|
||||
|
||||
- Correct unit from 'eg' to 'deg' [#7891](https://github.com/excalidraw/excalidraw/pull/7891)
|
||||
|
||||
- Allow same origin for all necessary domains [#7889](https://github.com/excalidraw/excalidraw/pull/7889)
|
||||
|
||||
- Always make sure we render bound text above containers [#7880](https://github.com/excalidraw/excalidraw/pull/7880)
|
||||
|
||||
- Parse embeddable srcdoc urls strictly [#7884](https://github.com/excalidraw/excalidraw/pull/7884)
|
||||
|
||||
- Hit test for closed sharp curves [#7881](https://github.com/excalidraw/excalidraw/pull/7881)
|
||||
|
||||
- Gist embed allowing unsafe html [#7883](https://github.com/excalidraw/excalidraw/pull/7883)
|
||||
|
||||
- Command palette tweaks and fixes [#7876](https://github.com/excalidraw/excalidraw/pull/7876)
|
||||
|
||||
- Include borders when testing insides of a shape [#7865](https://github.com/excalidraw/excalidraw/pull/7865)
|
||||
|
||||
- External link not opening [#7859](https://github.com/excalidraw/excalidraw/pull/7859)
|
||||
|
||||
- Add safe check for arrow points length in tranformToExcalidrawElements [#7863](https://github.com/excalidraw/excalidraw/pull/7863)
|
||||
|
||||
- Import [#7869](https://github.com/excalidraw/excalidraw/pull/7869)
|
||||
|
||||
- Theme toggle shortcut `event.code` [#7868](https://github.com/excalidraw/excalidraw/pull/7868)
|
||||
|
||||
- Remove incorrect check from index.html [#7867](https://github.com/excalidraw/excalidraw/pull/7867)
|
||||
|
||||
- Stop using lookbehind for backwards compat [#7824](https://github.com/excalidraw/excalidraw/pull/7824)
|
||||
|
||||
- Ejs support in html files [#7822](https://github.com/excalidraw/excalidraw/pull/7822)
|
||||
|
||||
- `excalidrawAPI.toggleSidebar` not switching between tabs correctly [#7821](https://github.com/excalidraw/excalidraw/pull/7821)
|
||||
|
||||
- Correcting Assistant metrics [#7758](https://github.com/excalidraw/excalidraw/pull/7758)
|
||||
|
||||
- Add missing font metrics for Assistant [#7752](https://github.com/excalidraw/excalidraw/pull/7752)
|
||||
|
||||
- Export utils from excalidraw package in excalidraw library [#7731](https://github.com/excalidraw/excalidraw/pull/7731)
|
||||
|
||||
- Split renderScene so that locales aren't imported unnecessarily [#7718](https://github.com/excalidraw/excalidraw/pull/7718)
|
||||
|
||||
- Remove dependency of t in blob.ts [#7717](https://github.com/excalidraw/excalidraw/pull/7717)
|
||||
|
||||
- Remove dependency of t from clipboard and image [#7712](https://github.com/excalidraw/excalidraw/pull/7712)
|
||||
|
||||
- Remove scene hack from export.ts & remove pass elementsMap to getContainingFrame [#7713](https://github.com/excalidraw/excalidraw/pull/7713)
|
||||
|
||||
- Decouple pure functions from hyperlink to prevent mermaid bundling [#7710](https://github.com/excalidraw/excalidraw/pull/7710)
|
||||
|
||||
- Make bounds independent of scene [#7679](https://github.com/excalidraw/excalidraw/pull/7679)
|
||||
|
||||
- Make LinearElementEditor independent of scene [#7670](https://github.com/excalidraw/excalidraw/pull/7670)
|
||||
|
||||
- Remove scene from getElementAbsoluteCoords and dependent functions and use elementsMap [#7663](https://github.com/excalidraw/excalidraw/pull/7663)
|
||||
|
||||
- Remove t from getDefaultAppState and allow name to be nullable [#7666](https://github.com/excalidraw/excalidraw/pull/7666)
|
||||
|
||||
- Stop using structuredClone [#9128](https://github.com/excalidraw/excalidraw/pull/9128)
|
||||
|
||||
- Fix elbow arrow fixed binding on restore [#9197](https://github.com/excalidraw/excalidraw/pull/9197)
|
||||
|
||||
- Cleanup legacy `element.rawText` (obsidian) [#9203](https://github.com/excalidraw/excalidraw/pull/9203)
|
||||
|
||||
- React 18 element.ref was accessed error [#9208](https://github.com/excalidraw/excalidraw/pull/9208)
|
||||
|
||||
- Docked sidebar width [#9213](https://github.com/excalidraw/excalidraw/pull/9213)
|
||||
|
||||
- Arrow updated on both sides [#8593](https://github.com/excalidraw/excalidraw/pull/8593)
|
||||
|
||||
- Package env vars [#9221](https://github.com/excalidraw/excalidraw/pull/9221)
|
||||
|
||||
- Bound elbow arrow on duplication does not route correctly [#9236](https://github.com/excalidraw/excalidraw/pull/9236)
|
||||
|
||||
- Do not rebind undragged elbow arrow endpoint [#9191](https://github.com/excalidraw/excalidraw/pull/9191)
|
||||
|
||||
- Logging and fixing extremely large scenes [#9225](https://github.com/excalidraw/excalidraw/pull/9225)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove `defaultProps` [#9035](https://github.com/excalidraw/excalidraw/pull/9035)
|
||||
|
||||
- Separate resizing logic from pointer [#8155](https://github.com/excalidraw/excalidraw/pull/8155)
|
||||
|
||||
- `point()` -> `pointFrom()` to fix compiler issue [#8578](https://github.com/excalidraw/excalidraw/pull/8578)
|
||||
|
||||
- Rename example `App.tsx` -> `ExampleApp.tsx` [#8501](https://github.com/excalidraw/excalidraw/pull/8501)
|
||||
|
||||
- Remove unused env variable [#8457](https://github.com/excalidraw/excalidraw/pull/8457)
|
||||
|
||||
- Rename `draggingElement` -> `newElement` [#8294](https://github.com/excalidraw/excalidraw/pull/8294)
|
||||
|
||||
- Update collision from ga to vector geometry [#7636](https://github.com/excalidraw/excalidraw/pull/7636)
|
||||
|
||||
### Performance
|
||||
|
||||
- Improved pointer events related performance when the sidebar is docked with a large library open [#9086](https://github.com/excalidraw/excalidraw/pull/9086)
|
||||
|
||||
- Reduce unnecessary frame clippings [#8980](https://github.com/excalidraw/excalidraw/pull/8980)
|
||||
|
||||
- Improve new element drawing [#8340](https://github.com/excalidraw/excalidraw/pull/8340)
|
||||
|
||||
- Cache the temp canvas created for labeled arrows [#8267](https://github.com/excalidraw/excalidraw/pull/8267)
|
||||
|
||||
### Build
|
||||
|
||||
- Set PWA flag in dev to false [#8788](https://github.com/excalidraw/excalidraw/pull/8788)
|
||||
|
||||
- Add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment [#8784](https://github.com/excalidraw/excalidraw/pull/8784)
|
||||
|
||||
- Upgrade vite to 5.4.x, vitest to 2.x and related vite packages [#8459](https://github.com/excalidraw/excalidraw/pull/8459)
|
||||
|
||||
- Add example apps `public` and vite `dev-dist` to eslintignore [#8326](https://github.com/excalidraw/excalidraw/pull/8326)
|
||||
|
||||
- Add `rm:build`, `rm:node_modules` & `clean-install` scripts [#8323](https://github.com/excalidraw/excalidraw/pull/8323)
|
||||
|
||||
- Update release script to build esm [#8308](https://github.com/excalidraw/excalidraw/pull/8308)
|
||||
|
||||
- Run tests on master branch [#8072](https://github.com/excalidraw/excalidraw/pull/8072)
|
||||
|
||||
- Specify `packageManager` field [#8010](https://github.com/excalidraw/excalidraw/pull/8010)
|
||||
|
||||
- Enable consistent type imports eslint rule [#7992](https://github.com/excalidraw/excalidraw/pull/7992)
|
||||
|
||||
- Export types for @excalidraw/utils [#7736](https://github.com/excalidraw/excalidraw/pull/7736)
|
||||
|
||||
- Create ESM build for utils package 🥳 [#7500](https://github.com/excalidraw/excalidraw/pull/7500)
|
||||
|
||||
- Upgrade to react@19 [#9182](https://github.com/excalidraw/excalidraw/pull/9182)
|
||||
|
||||
## 0.17.3 (2024-02-09)
|
||||
|
||||
|
@ -215,6 +871,8 @@ define: {
|
|||
|
||||
### Fixes
|
||||
|
||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||
|
||||
- Image insertion bugs [#7278](https://github.com/excalidraw/excalidraw/pull/7278)
|
||||
|
||||
- ExportToSvg to honor frameRendering also for name not only for frame itself [#7270](https://github.com/excalidraw/excalidraw/pull/7270)
|
||||
|
|
|
@ -1,49 +1,45 @@
|
|||
# Excalidraw
|
||||
|
||||
**Excalidraw** is exported as a component to directly embed in your projects.
|
||||
**Excalidraw** is exported as a component to be directly embedded in your project.
|
||||
|
||||
## Installation
|
||||
|
||||
You can use `npm`
|
||||
Use `npm` or `yarn` to install the package.
|
||||
|
||||
```bash
|
||||
npm install react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
or via `yarn`
|
||||
|
||||
```bash
|
||||
# or
|
||||
yarn add react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
After installation you will see a folder `excalidraw-assets` and `excalidraw-assets-dev` in `dist` directory which contains the assets needed for this app in prod and dev mode respectively.
|
||||
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
|
||||
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
|
||||
#### Self-hosting fonts
|
||||
|
||||
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist)
|
||||
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
|
||||
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
||||
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
|
||||
|
||||
#### Note
|
||||
```js
|
||||
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
|
||||
```
|
||||
|
||||
**If you don't want to wait for the next stable release and try out the unreleased changes you can use `@excalidraw/excalidraw@next`.**
|
||||
|
||||
## Dimensions of Excalidraw
|
||||
### Dimensions of Excalidraw
|
||||
|
||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||
|
||||
### Demo
|
||||
## Demo
|
||||
|
||||
[Try here](https://codesandbox.io/s/excalidraw-ehlz3).
|
||||
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
|
||||
|
||||
## Integration
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration)
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
|
||||
## API
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api)
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
|
||||
## Contributing
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing)
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { register } from "./register";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
import { StoreAction } from "../store";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
|
@ -18,7 +19,7 @@ export const actionAddToLibrary = register({
|
|||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
|
@ -42,7 +43,7 @@ export const actionAddToLibrary = register({
|
|||
})
|
||||
.then(() => {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.addedToLibrary") },
|
||||
|
@ -51,7 +52,7 @@ export const actionAddToLibrary = register({
|
|||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
|
|
|
@ -1,5 +1,18 @@
|
|||
import type { Alignment } from "../align";
|
||||
import { alignElements } from "../align";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
|
||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { alignElements } from "@excalidraw/element/align";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element/align";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
|
@ -8,19 +21,16 @@ import {
|
|||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
|
||||
export const alignActionsPredicate = (
|
||||
appState: UIAppState,
|
||||
app: AppClassProperties,
|
||||
|
@ -72,7 +82,7 @@ export const actionAlignTop = register({
|
|||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -106,7 +116,7 @@ export const actionAlignBottom = register({
|
|||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -140,7 +150,7 @@ export const actionAlignLeft = register({
|
|||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -174,7 +184,7 @@ export const actionAlignRight = register({
|
|||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -208,7 +218,7 @@ export const actionAlignVerticallyCentered = register({
|
|||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
@ -238,7 +248,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
|
|
@ -3,38 +3,50 @@ import {
|
|||
ROUNDNESS,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "../element/containerCache";
|
||||
} from "@excalidraw/element/containerCache";
|
||||
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import { newElement } from "@excalidraw/element/newElement";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import type { AppState } from "../types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { arrayToMap, getFontString } from "../utils";
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
|
@ -86,7 +98,7 @@ export const actionUnbindText = register({
|
|||
return {
|
||||
elements,
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -163,7 +175,7 @@ export const actionBindText = register({
|
|||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -323,7 +335,7 @@ export const actionWrapTextInContainer = register({
|
|||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,32 @@
|
|||
import { clamp, roundToStep } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
DEFAULT_CANVAS_BACKGROUND_PICKS,
|
||||
CURSOR_TYPE,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
THEME,
|
||||
ZOOM_STEP,
|
||||
getShortcutKey,
|
||||
updateActiveTool,
|
||||
CODES,
|
||||
KEYS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import {
|
||||
handIcon,
|
||||
MoonIcon,
|
||||
|
@ -9,36 +37,17 @@ import {
|
|||
ZoomOutIcon,
|
||||
ZoomResetIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
THEME,
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { setCursor } from "../cursor";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import type { AppState, Offsets } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { clamp, roundToStep } from "../../math";
|
||||
|
||||
import type { AppState, Offsets } from "../types";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
|
@ -54,9 +63,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
storeAction: !!value.viewBackgroundColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
captureUpdate: !!value.viewBackgroundColor
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
|
@ -115,7 +124,7 @@ export const actionClearCanvas = register({
|
|||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -140,7 +149,7 @@ export const actionZoomIn = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
|
@ -181,7 +190,7 @@ export const actionZoomOut = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
|
@ -222,7 +231,7 @@ export const actionResetZoom = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
|
@ -341,7 +350,7 @@ export const zoomToFitBounds = ({
|
|||
scrollY: centerScroll.scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -472,7 +481,7 @@ export const actionToggleTheme = register({
|
|||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
|
@ -510,7 +519,7 @@ export const actionToggleEraserTool = register({
|
|||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
|
@ -549,7 +558,7 @@ export const actionToggleHandTool = register({
|
|||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||
import { getTextFromElements } from "@excalidraw/element/textElement";
|
||||
|
||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
|
@ -8,13 +11,14 @@ import {
|
|||
probablySupportsClipboardWriteText,
|
||||
readSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { getTextFromElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
|
@ -32,7 +36,7 @@ export const actionCopy = register({
|
|||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
|
@ -41,7 +45,7 @@ export const actionCopy = register({
|
|||
}
|
||||
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
|
@ -67,7 +71,7 @@ export const actionPaste = register({
|
|||
|
||||
if (isFirefox) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
|
@ -76,7 +80,7 @@ export const actionPaste = register({
|
|||
}
|
||||
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
|
@ -89,7 +93,7 @@ export const actionPaste = register({
|
|||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
|
@ -98,7 +102,7 @@ export const actionPaste = register({
|
|||
}
|
||||
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
|
@ -125,7 +129,7 @@ export const actionCopyAsSvg = register({
|
|||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -167,7 +171,7 @@ export const actionCopyAsSvg = register({
|
|||
}),
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -175,7 +179,7 @@ export const actionCopyAsSvg = register({
|
|||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -193,7 +197,7 @@ export const actionCopyAsPng = register({
|
|||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
|
@ -227,7 +231,7 @@ export const actionCopyAsPng = register({
|
|||
}),
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -236,7 +240,7 @@ export const actionCopyAsPng = register({
|
|||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -263,7 +267,7 @@ export const copyText = register({
|
|||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { register } from "./register";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { isImageElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { isImageElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawImageElement } from "../element/types";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleCropEditor = register({
|
||||
name: "cropEditor",
|
||||
|
@ -25,7 +28,7 @@ export const actionToggleCropEditor = register({
|
|||
isCropping: false,
|
||||
croppingElementId: selectedElement.id,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
|
|
213
packages/excalidraw/actions/actionDeleteSelected.test.tsx
Normal file
213
packages/excalidraw/actions/actionDeleteSelected.test.tsx
Normal file
|
@ -0,0 +1,213 @@
|
|||
import React from "react";
|
||||
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { act, assertElements, render } from "../tests/test-utils";
|
||||
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("deleting selected elements when frame selected should keep children + select them", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("frame only", async () => {
|
||||
const f1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const r1 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: f1.id,
|
||||
});
|
||||
|
||||
API.setElements([f1, r1]);
|
||||
|
||||
API.setSelectedElements([f1]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: f1.id, isDeleted: true },
|
||||
{ id: r1.id, isDeleted: false, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame + text container (text's frameId set)", async () => {
|
||||
const f1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const r1 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: f1.id,
|
||||
});
|
||||
|
||||
const t1 = API.createElement({
|
||||
type: "text",
|
||||
width: 200,
|
||||
height: 100,
|
||||
fontSize: 20,
|
||||
containerId: r1.id,
|
||||
frameId: f1.id,
|
||||
});
|
||||
|
||||
mutateElement(r1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
API.setElements([f1, r1, t1]);
|
||||
|
||||
API.setSelectedElements([f1]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: f1.id, isDeleted: true },
|
||||
{ id: r1.id, isDeleted: false, selected: true },
|
||||
{ id: t1.id, isDeleted: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame + text container (text's frameId not set)", async () => {
|
||||
const f1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const r1 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: f1.id,
|
||||
});
|
||||
|
||||
const t1 = API.createElement({
|
||||
type: "text",
|
||||
width: 200,
|
||||
height: 100,
|
||||
fontSize: 20,
|
||||
containerId: r1.id,
|
||||
frameId: null,
|
||||
});
|
||||
|
||||
mutateElement(r1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
API.setElements([f1, r1, t1]);
|
||||
|
||||
API.setSelectedElements([f1]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: f1.id, isDeleted: true },
|
||||
{ id: r1.id, isDeleted: false, selected: true },
|
||||
{ id: t1.id, isDeleted: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame + text container (text selected too)", async () => {
|
||||
const f1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const r1 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: f1.id,
|
||||
});
|
||||
|
||||
const t1 = API.createElement({
|
||||
type: "text",
|
||||
width: 200,
|
||||
height: 100,
|
||||
fontSize: 20,
|
||||
containerId: r1.id,
|
||||
frameId: null,
|
||||
});
|
||||
|
||||
mutateElement(r1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
API.setElements([f1, r1, t1]);
|
||||
|
||||
API.setSelectedElements([f1, t1]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: f1.id, isDeleted: true },
|
||||
{ id: r1.id, isDeleted: false, selected: true },
|
||||
{ id: t1.id, isDeleted: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame + labeled arrow", async () => {
|
||||
const f1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const a1 = API.createElement({
|
||||
type: "arrow",
|
||||
frameId: f1.id,
|
||||
});
|
||||
|
||||
const t1 = API.createElement({
|
||||
type: "text",
|
||||
width: 200,
|
||||
height: 100,
|
||||
fontSize: 20,
|
||||
containerId: a1.id,
|
||||
frameId: null,
|
||||
});
|
||||
|
||||
mutateElement(a1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
API.setElements([f1, a1, t1]);
|
||||
|
||||
API.setSelectedElements([f1, t1]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: f1.id, isDeleted: true },
|
||||
{ id: a1.id, isDeleted: false, selected: true },
|
||||
{ id: t1.id, isDeleted: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame + children selected", async () => {
|
||||
const f1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
const r1 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: f1.id,
|
||||
});
|
||||
API.setElements([f1, r1]);
|
||||
|
||||
API.setSelectedElements([f1, r1]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: f1.id, isDeleted: true },
|
||||
{ id: r1.id, isDeleted: false, selected: true },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -1,23 +1,36 @@
|
|||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import {
|
||||
mutateElement,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element/mutateElement";
|
||||
import { getContainerElement } from "@excalidraw/element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
} from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||
|
||||
import {
|
||||
getElementsInGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element/groups";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -33,10 +46,50 @@ const deleteSelectedElements = (
|
|||
|
||||
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
||||
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
const processedElements = new Set<ExcalidrawElement["id"]>();
|
||||
|
||||
for (const frameId of framesToBeDeleted) {
|
||||
const frameChildren = getFrameChildren(elements, frameId);
|
||||
for (const el of frameChildren) {
|
||||
if (processedElements.has(el.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBoundToContainer(el)) {
|
||||
const containerElement = getContainerElement(el, elementsMap);
|
||||
if (containerElement) {
|
||||
selectedElementIds[containerElement.id] = true;
|
||||
}
|
||||
} else {
|
||||
selectedElementIds[el.id] = true;
|
||||
}
|
||||
processedElements.add(el.id);
|
||||
}
|
||||
}
|
||||
|
||||
let shouldSelectEditingGroup = true;
|
||||
|
||||
const nextElements = elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
const boundElement = isBoundToContainer(el)
|
||||
? getContainerElement(el, elementsMap)
|
||||
: null;
|
||||
|
||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||
shouldSelectEditingGroup = false;
|
||||
selectedElementIds[el.id] = true;
|
||||
return el;
|
||||
}
|
||||
|
||||
if (
|
||||
boundElement?.frameId &&
|
||||
framesToBeDeleted.has(boundElement?.frameId)
|
||||
) {
|
||||
return el;
|
||||
}
|
||||
|
||||
if (el.boundElements) {
|
||||
el.boundElements.forEach((candidate) => {
|
||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||
|
@ -59,7 +112,9 @@ const deleteSelectedElements = (
|
|||
// if deleting a frame, remove the children from it and select them
|
||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||
shouldSelectEditingGroup = false;
|
||||
selectedElementIds[el.id] = true;
|
||||
if (!isBoundToContainer(el)) {
|
||||
selectedElementIds[el.id] = true;
|
||||
}
|
||||
return newElementWith(el, { frameId: null });
|
||||
}
|
||||
|
||||
|
@ -189,7 +244,7 @@ export const actionDeleteSelected = register({
|
|||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -221,14 +276,16 @@ export const actionDeleteSelected = register({
|
|||
: [0],
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState, app);
|
||||
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
nextElements.filter((el) => el.isDeleted),
|
||||
);
|
||||
|
||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||
|
@ -240,13 +297,14 @@ export const actionDeleteSelected = register({
|
|||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
storeAction: isSomeElementSelected(
|
||||
captureUpdate: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState, elements) =>
|
||||
|
|
|
@ -1,22 +1,32 @@
|
|||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
|
||||
import { distributeElements } from "@excalidraw/element/distribute";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Distribution } from "@excalidraw/element/distribute";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import type { Distribution } from "../distribute";
|
||||
import { distributeElements } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
|
@ -60,7 +70,7 @@ export const distributeHorizontally = register({
|
|||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -91,7 +101,7 @@ export const distributeVertically = register({
|
|||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
531
packages/excalidraw/actions/actionDuplicateSelection.test.tsx
Normal file
531
packages/excalidraw/actions/actionDuplicateSelection.test.tsx
Normal file
|
@ -0,0 +1,531 @@
|
|||
import { ORIG_ID } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
act,
|
||||
assertElements,
|
||||
getCloneByOrigId,
|
||||
render,
|
||||
} from "../tests/test-utils";
|
||||
|
||||
import { actionDuplicateSelection } from "./actionDuplicateSelection";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("actionDuplicateSelection", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
describe("duplicating frames", () => {
|
||||
it("frame selected only", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle]);
|
||||
API.setSelectedElements([frame]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||
{ [ORIG_ID]: frame.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame selected only (with text container)", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||
|
||||
API.setElements([frame, rectangle, text]);
|
||||
API.setSelectedElements([frame]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
frameId: getCloneByOrigId(frame.id)?.id,
|
||||
},
|
||||
{ [ORIG_ID]: frame.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame + text container selected (order A)", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||
|
||||
API.setElements([frame, rectangle, text]);
|
||||
API.setSelectedElements([frame, rectangle]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||
{
|
||||
[ORIG_ID]: rectangle.id,
|
||||
frameId: getCloneByOrigId(frame.id)?.id,
|
||||
},
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
frameId: getCloneByOrigId(frame.id)?.id,
|
||||
},
|
||||
{
|
||||
[ORIG_ID]: frame.id,
|
||||
selected: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame + text container selected (order B)", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||
|
||||
API.setElements([text, rectangle, frame]);
|
||||
API.setSelectedElements([rectangle, frame]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||
{ id: frame.id },
|
||||
{
|
||||
type: "rectangle",
|
||||
[ORIG_ID]: `${rectangle.id}`,
|
||||
},
|
||||
{
|
||||
[ORIG_ID]: `${text.id}`,
|
||||
type: "text",
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
frameId: getCloneByOrigId(frame.id)?.id,
|
||||
},
|
||||
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicating frame children", () => {
|
||||
it("frame child selected", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame text container selected (rectangle selected)", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||
|
||||
API.setElements([frame, rectangle, text]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id).id,
|
||||
frameId: frame.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame bound text selected (container not selected)", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||
|
||||
API.setElements([frame, rectangle, text]);
|
||||
API.setSelectedElements([text]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id).id,
|
||||
frameId: frame.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame text container selected (text not exists)", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [rectangle] = API.createTextContainer({ frameId: frame.id });
|
||||
|
||||
API.setElements([frame, rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
// shouldn't happen
|
||||
it("frame bound text selected (container not exists)", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [, text] = API.createTextContainer({ frameId: frame.id });
|
||||
|
||||
API.setElements([frame, text]);
|
||||
API.setSelectedElements([text]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: text.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: text.id, frameId: frame.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frame bound container selected (text has no frameId)", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({
|
||||
frameId: frame.id,
|
||||
label: { frameId: null },
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle, text]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, containerId: rectangle.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id).id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicating multiple frames", () => {
|
||||
it("multiple frames selected (no children)", () => {
|
||||
const frame1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: frame1.id,
|
||||
});
|
||||
|
||||
const frame2 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: frame2.id,
|
||||
});
|
||||
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
});
|
||||
|
||||
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
||||
API.setSelectedElements([frame1, frame2]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rect1.id, frameId: frame1.id },
|
||||
{ id: frame1.id },
|
||||
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
||||
{ [ORIG_ID]: frame1.id, selected: true },
|
||||
{ id: ellipse.id },
|
||||
{ id: rect2.id, frameId: frame2.id },
|
||||
{ id: frame2.id },
|
||||
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
||||
{ [ORIG_ID]: frame2.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple frames selected (no children) + unrelated element", () => {
|
||||
const frame1 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: frame1.id,
|
||||
});
|
||||
|
||||
const frame2 = API.createElement({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: frame2.id,
|
||||
});
|
||||
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
});
|
||||
|
||||
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
||||
API.setSelectedElements([frame1, ellipse, frame2]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rect1.id, frameId: frame1.id },
|
||||
{ id: frame1.id },
|
||||
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
||||
{ [ORIG_ID]: frame1.id, selected: true },
|
||||
{ id: ellipse.id },
|
||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
||||
{ id: rect2.id, frameId: frame2.id },
|
||||
{ id: frame2.id },
|
||||
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
||||
{ [ORIG_ID]: frame2.id, selected: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicating containers/bound elements", () => {
|
||||
it("labeled arrow (arrow selected)", () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([arrow, text]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: arrow.id },
|
||||
{ id: text.id, containerId: arrow.id },
|
||||
{ [ORIG_ID]: arrow.id, selected: true },
|
||||
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
||||
]);
|
||||
});
|
||||
|
||||
// shouldn't happen
|
||||
it("labeled arrow (text selected)", () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([arrow, text]);
|
||||
API.setSelectedElements([text]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: arrow.id },
|
||||
{ id: text.id, containerId: arrow.id },
|
||||
{ [ORIG_ID]: arrow.id, selected: true },
|
||||
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicating groups", () => {
|
||||
it("duplicate group containing frame (children don't have groupIds set)", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
groupIds: ["A"],
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
groupIds: ["A"],
|
||||
});
|
||||
|
||||
API.setElements([rectangle, text, frame, ellipse]);
|
||||
API.setSelectedElements([frame, ellipse]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, frameId: frame.id },
|
||||
{ id: frame.id },
|
||||
{ id: ellipse.id },
|
||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||
{ [ORIG_ID]: frame.id, selected: true },
|
||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplicate group containing frame (children have groupIds)", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
groupIds: ["A"],
|
||||
});
|
||||
|
||||
const [rectangle, text] = API.createTextContainer({
|
||||
frameId: frame.id,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
groupIds: ["A"],
|
||||
});
|
||||
|
||||
API.setElements([rectangle, text, frame, ellipse]);
|
||||
API.setSelectedElements([frame, ellipse]);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle.id, frameId: frame.id },
|
||||
{ id: text.id, frameId: frame.id },
|
||||
{ id: frame.id },
|
||||
{ id: ellipse.id },
|
||||
{
|
||||
[ORIG_ID]: rectangle.id,
|
||||
frameId: getCloneByOrigId(frame.id)?.id,
|
||||
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
frameId: getCloneByOrigId(frame.id)?.id,
|
||||
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
||||
selected: true,
|
||||
},
|
||||
{ [ORIG_ID]: frame.id, selected: true },
|
||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplicating element nested in group", () => {
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
groupIds: ["B"],
|
||||
});
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["A", "B"],
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["A", "B"],
|
||||
});
|
||||
|
||||
API.setElements([ellipse, rect1, rect2]);
|
||||
API.setSelectedElements([ellipse], "B");
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: ellipse.id },
|
||||
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
|
||||
{ id: rect1.id, groupIds: ["A", "B"] },
|
||||
{ id: rect2.id, groupIds: ["A", "B"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,38 +1,40 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
selectGroupsForSelectedElements,
|
||||
getSelectedGroupForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import type { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import type { ActionResult } from "./types";
|
||||
import { DEFAULT_GRID_SIZE } from "../constants";
|
||||
DEFAULT_GRID_SIZE,
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
getShortcutKey,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
} from "../element/textElement";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameChildren,
|
||||
} from "../frame";
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
||||
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
} from "@excalidraw/element/selection";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import { duplicateElements } from "@excalidraw/element/duplicate";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
|
@ -40,6 +42,10 @@ export const actionDuplicateSelection = register({
|
|||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.selectedElementsAreBeingDragged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||
|
@ -52,16 +58,62 @@ export const actionDuplicateSelection = register({
|
|||
return {
|
||||
elements,
|
||||
appState: newAppState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let { newElements: duplicatedElements, elementsWithClones: nextElements } =
|
||||
duplicateElements({
|
||||
type: "in-place",
|
||||
elements,
|
||||
idsOfElementsToDuplicate: arrayToMap(
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
),
|
||||
appState,
|
||||
randomizeSeed: true,
|
||||
overrides: (element) => ({
|
||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||
}),
|
||||
reverseOrder: false,
|
||||
});
|
||||
|
||||
if (app.props.onDuplicate && nextElements) {
|
||||
const mappedElements = app.props.onDuplicate(nextElements, elements);
|
||||
if (mappedElements) {
|
||||
nextElements = mappedElements;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...duplicateElements(elements, appState),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)),
|
||||
appState: {
|
||||
...appState,
|
||||
...updateLinearElementEditors(duplicatedElements),
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: excludeElementsInFramesFromSelection(
|
||||
duplicatedElements,
|
||||
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||
|
@ -79,216 +131,23 @@ export const actionDuplicateSelection = register({
|
|||
),
|
||||
});
|
||||
|
||||
const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): Partial<ActionResult> => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// step (1)
|
||||
|
||||
const sortedElements = normalizeElementOrder(elements);
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
|
||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||
},
|
||||
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
|
||||
const linears = clonedElements.filter(isLinearElement);
|
||||
if (linears.length === 1) {
|
||||
const linear = linears[0];
|
||||
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
||||
const onlySingleLinearSelected = clonedElements.every(
|
||||
(el) => el.id === linear.id || boundElements.includes(el.id),
|
||||
);
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
return newElement;
|
||||
};
|
||||
|
||||
const idsOfElementsToDuplicate = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
// into the array twice if we end up backtracking when retrieving
|
||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||
// cases such as a group containing deleted elements which were not selected).
|
||||
//
|
||||
// This is not enough to prevent duplicates, so we do a second loop afterwards
|
||||
// to remove them.
|
||||
//
|
||||
// For convenience we mark even the newly created ones even though we don't
|
||||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
|
||||
const markAsProcessed = (elements: ExcalidrawElement[]) => {
|
||||
for (const element of elements) {
|
||||
processedIds.set(element.id, true);
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
const elementsWithClones: ExcalidrawElement[] = [];
|
||||
|
||||
let index = -1;
|
||||
|
||||
while (++index < sortedElements.length) {
|
||||
const element = sortedElements[index];
|
||||
|
||||
if (processedIds.get(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||
const isElementAFrameLike = isFrameLikeElement(element);
|
||||
|
||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||
// if a group or a container/bound-text or frame, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
// TODO:
|
||||
// remove `.flatMap...`
|
||||
// if the elements in a frame are grouped when the frame is grouped
|
||||
const groupElements = getElementsInGroup(
|
||||
sortedElements,
|
||||
groupId,
|
||||
).flatMap((element) =>
|
||||
isFrameLikeElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...groupElements,
|
||||
...groupElements.map((element) =>
|
||||
duplicateAndOffsetElement(element),
|
||||
),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (boundTextElement) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
element,
|
||||
boundTextElement,
|
||||
duplicateAndOffsetElement(element),
|
||||
duplicateAndOffsetElement(boundTextElement),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrameLike) {
|
||||
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...elementsInFrame,
|
||||
element,
|
||||
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||
duplicateAndOffsetElement(element),
|
||||
]),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// since elements in frames have a lower z-index than the frame itself,
|
||||
// they will be looped first and if their frames are selected as well,
|
||||
// they will have been copied along with the frame atomically in the
|
||||
// above branch, so we must skip those elements here
|
||||
//
|
||||
// now, for elements do not belong any frames or elements whose frames
|
||||
// are selected (or elements that are left out from the above
|
||||
// steps for whatever reason) we (should at least) duplicate them here
|
||||
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
elementsWithClones.push(...markAsProcessed([element]));
|
||||
if (onlySingleLinearSelected) {
|
||||
return {
|
||||
selectedLinearElement: new LinearElementEditor(linear),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// step (2)
|
||||
|
||||
// second pass to remove duplicates. We loop from the end as it's likelier
|
||||
// that the last elements are in the correct order (contiguous or otherwise).
|
||||
// Thus we need to reverse as the last step (3).
|
||||
|
||||
const finalElementsReversed: ExcalidrawElement[] = [];
|
||||
|
||||
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
|
||||
index = elementsWithClones.length;
|
||||
|
||||
while (--index >= 0) {
|
||||
const element = elementsWithClones[index];
|
||||
if (!finalElementIds.get(element.id)) {
|
||||
finalElementIds.set(element.id, true);
|
||||
finalElementsReversed.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
// step (3)
|
||||
const finalElements = syncMovedIndices(
|
||||
finalElementsReversed.reverse(),
|
||||
arrayToMap(newElements),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bindTextToShapeAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
fixBindingsAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
finalElements,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
};
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue