mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-05 06:32:56 +00:00
use submodule
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["../eslintrc.base.json"]
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.18.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": "./dist/types/common/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
}
|
||||
},
|
||||
"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": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export class BinaryHeap<T> {
|
||||
private content: T[] = [];
|
||||
|
||||
constructor(private scoreFunction: (node: T) => number) {}
|
||||
|
||||
sinkDown(idx: number) {
|
||||
const node = this.content[idx];
|
||||
while (idx > 0) {
|
||||
const parentN = ((idx + 1) >> 1) - 1;
|
||||
const parent = this.content[parentN];
|
||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||
this.content[parentN] = node;
|
||||
this.content[idx] = parent;
|
||||
idx = parentN; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bubbleUp(idx: number) {
|
||||
const length = this.content.length;
|
||||
const node = this.content[idx];
|
||||
const score = this.scoreFunction(node);
|
||||
|
||||
while (true) {
|
||||
const child2N = (idx + 1) << 1;
|
||||
const child1N = child2N - 1;
|
||||
let swap = null;
|
||||
let child1Score = 0;
|
||||
|
||||
if (child1N < length) {
|
||||
const child1 = this.content[child1N];
|
||||
child1Score = this.scoreFunction(child1);
|
||||
if (child1Score < score) {
|
||||
swap = child1N;
|
||||
}
|
||||
}
|
||||
|
||||
if (child2N < length) {
|
||||
const child2 = this.content[child2N];
|
||||
const child2Score = this.scoreFunction(child2);
|
||||
if (child2Score < (swap === null ? score : child1Score)) {
|
||||
swap = child2N;
|
||||
}
|
||||
}
|
||||
|
||||
if (swap !== null) {
|
||||
this.content[idx] = this.content[swap];
|
||||
this.content[swap] = node;
|
||||
idx = swap; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push(node: T) {
|
||||
this.content.push(node);
|
||||
this.sinkDown(this.content.length - 1);
|
||||
}
|
||||
|
||||
pop(): T | null {
|
||||
if (this.content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = this.content[0];
|
||||
const end = this.content.pop()!;
|
||||
|
||||
if (this.content.length > 0) {
|
||||
this.content[0] = end;
|
||||
this.bubbleUp(0);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
remove(node: T) {
|
||||
if (this.content.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i = this.content.indexOf(node);
|
||||
const end = this.content.pop()!;
|
||||
|
||||
if (i < this.content.length) {
|
||||
this.content[i] = end;
|
||||
|
||||
if (this.scoreFunction(end) < this.scoreFunction(node)) {
|
||||
this.sinkDown(i);
|
||||
} else {
|
||||
this.bubbleUp(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.content.length;
|
||||
}
|
||||
|
||||
rescoreElement(node: T) {
|
||||
this.sinkDown(this.content.indexOf(node));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import oc from "open-color";
|
||||
import { Merge } from "./utility-types";
|
||||
|
||||
import type { Merge } from "./utility-types";
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
+138
-27
@@ -1,11 +1,16 @@
|
||||
import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
import { 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);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
typeof window !== "undefined" &&
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
@@ -20,18 +25,19 @@ export const isIOS =
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
// distance when creating text before it's considered `autoResize: false`
|
||||
// we're using higher threshold so that clicks that end up being drags
|
||||
// don't unintentionally create text elements that are wrapped to a few chars
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
||||
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
|
||||
export const DEFAULT_LASER_COLOR = "red";
|
||||
export const CURSOR_TYPE = {
|
||||
TEXT: "text",
|
||||
CROSSHAIR: "crosshair",
|
||||
@@ -103,18 +109,84 @@ export const YOUTUBE_STATES = {
|
||||
export const ENV = {
|
||||
TEST: "test",
|
||||
DEVELOPMENT: "development",
|
||||
PRODUCTION: "production",
|
||||
};
|
||||
|
||||
export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||
};
|
||||
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
/**
|
||||
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
|
||||
*
|
||||
* Let's think this through and consider:
|
||||
* - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
|
||||
* - https://drafts.csswg.org/css-fonts-4/#font-family-prop
|
||||
* - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
|
||||
*/
|
||||
export const FONT_FAMILY = {
|
||||
Virgil: 1,
|
||||
Helvetica: 2,
|
||||
Cascadia: 3,
|
||||
Assistant: 4,
|
||||
// leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
|
||||
Excalifont: 5,
|
||||
Nunito: 6,
|
||||
"Lilita One": 7,
|
||||
"Comic Shanns": 8,
|
||||
"Liberation Sans": 9,
|
||||
Assistant: 10,
|
||||
};
|
||||
|
||||
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
|
||||
// so we need to have generic font fallback before it
|
||||
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
|
||||
export const MONOSPACE_GENERIC_FONT = "monospace";
|
||||
|
||||
export const FONT_FAMILY_GENERIC_FALLBACKS = {
|
||||
[SANS_SERIF_GENERIC_FONT]: 998,
|
||||
[MONOSPACE_GENERIC_FONT]: 999,
|
||||
};
|
||||
|
||||
export const FONT_FAMILY_FALLBACKS = {
|
||||
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
||||
...FONT_FAMILY_GENERIC_FALLBACKS,
|
||||
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
||||
};
|
||||
|
||||
export function getGenericFontFamilyFallback(
|
||||
fontFamily: number,
|
||||
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
|
||||
switch (fontFamily) {
|
||||
case FONT_FAMILY.Cascadia:
|
||||
case FONT_FAMILY["Comic Shanns"]:
|
||||
return MONOSPACE_GENERIC_FONT;
|
||||
|
||||
default:
|
||||
return SANS_SERIF_GENERIC_FONT;
|
||||
}
|
||||
}
|
||||
|
||||
export const getFontFamilyFallbacks = (
|
||||
fontFamily: number,
|
||||
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
||||
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
|
||||
|
||||
switch (fontFamily) {
|
||||
case FONT_FAMILY.Excalifont:
|
||||
return [
|
||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||
genericFallbackFont,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
];
|
||||
default:
|
||||
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
}
|
||||
};
|
||||
|
||||
export const THEME = {
|
||||
@@ -138,16 +210,21 @@ export const FRAME_STYLE = {
|
||||
nameLineHeight: 1.25,
|
||||
};
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
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)
|
||||
export const EPSILON = 0.00001;
|
||||
export const DEFAULT_COLLISION_THRESHOLD =
|
||||
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
|
||||
|
||||
export const COLOR_WHITE = "#ffffff";
|
||||
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
|
||||
// keep this in sync with CSS
|
||||
@@ -155,7 +232,8 @@ export const COLOR_VOICE_CALL = "#a2f1a6";
|
||||
|
||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||
|
||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||
export const DEFAULT_GRID_SIZE = 20;
|
||||
export const DEFAULT_GRID_STEP = 5;
|
||||
|
||||
export const IMAGE_MIME_TYPES = {
|
||||
svg: "image/svg+xml",
|
||||
@@ -169,9 +247,9 @@ export const IMAGE_MIME_TYPES = {
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
text: "text/plain",
|
||||
html: "text/html",
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
@@ -185,6 +263,12 @@ export const MIME_TYPES = {
|
||||
...IMAGE_MIME_TYPES,
|
||||
} as const;
|
||||
|
||||
export const ALLOWED_PASTE_MIME_TYPES = [
|
||||
MIME_TYPES.text,
|
||||
MIME_TYPES.html,
|
||||
...Object.values(IMAGE_MIME_TYPES),
|
||||
] as const;
|
||||
|
||||
export const EXPORT_IMAGE_TYPES = {
|
||||
png: "png",
|
||||
svg: "svg",
|
||||
@@ -198,7 +282,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
export const getExportSource = () =>
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
@@ -210,6 +294,7 @@ export const VERSION_TIMEOUT = 30000;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
export const MIN_ZOOM = 0.1;
|
||||
export const MAX_ZOOM = 30;
|
||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||
|
||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||
@@ -217,7 +302,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",
|
||||
@@ -252,8 +338,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];
|
||||
@@ -261,9 +345,12 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
`;
|
||||
|
||||
export const ENCRYPTION_KEY_BITS = 128;
|
||||
|
||||
@@ -316,10 +403,6 @@ export const ROUNDNESS = {
|
||||
ADAPTIVE_RADIUS: 3,
|
||||
} as const;
|
||||
|
||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
||||
export const ROUGHNESS = {
|
||||
architect: 0,
|
||||
artist: 1,
|
||||
@@ -353,6 +436,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
};
|
||||
|
||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||
export const CANVAS_SEARCH_TAB = "search";
|
||||
|
||||
export const DEFAULT_SIDEBAR = {
|
||||
name: "default",
|
||||
@@ -368,6 +452,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
|
||||
// use these constants to easily identify reference sites
|
||||
export const TOOL_TYPE = {
|
||||
selection: "selection",
|
||||
lasso: "lasso",
|
||||
rectangle: "rectangle",
|
||||
diamond: "diamond",
|
||||
ellipse: "ellipse",
|
||||
@@ -386,9 +471,6 @@ export const TOOL_TYPE = {
|
||||
|
||||
export const EDITOR_LS_KEYS = {
|
||||
OAI_API_KEY: "excalidraw-oai-api-key",
|
||||
OAI_BASE_URL: "excalidraw-oai-base-url",
|
||||
OAI_MODEL_NAME: "excalidraw-oai-model-name",
|
||||
OAI_API_KEY_IS_PERSISTED: "excalidraw-oai-api-key-is-persisted",
|
||||
// legacy naming (non)scheme
|
||||
MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw",
|
||||
PUBLISH_LIBRARY: "publish-library-data",
|
||||
@@ -399,3 +481,32 @@ export const EDITOR_LS_KEYS = {
|
||||
* where filename is optional and we can't retrieve name from app state
|
||||
*/
|
||||
export const DEFAULT_FILENAME = "Untitled";
|
||||
|
||||
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
|
||||
|
||||
export const MIN_WIDTH_OR_HEIGHT = 1;
|
||||
|
||||
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
|
||||
sharp: "sharp",
|
||||
round: "round",
|
||||
elbow: "elbow",
|
||||
};
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
/**
|
||||
* distance at which we merge points instead of adding a new merge-point
|
||||
* when converting a line to a polygon (merge currently means overlaping
|
||||
* the start and end points)
|
||||
*/
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { UnsubscribeCallback } from "./types";
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "./constants";
|
||||
|
||||
/**
|
||||
* Encapsulates font metrics with additional font metadata.
|
||||
* */
|
||||
export interface FontMetadata {
|
||||
/** for head & hhea metrics read the woff2 with https://fontdrop.info/ */
|
||||
metrics: {
|
||||
/** head.unitsPerEm metric */
|
||||
unitsPerEm: 1000 | 1024 | 2048;
|
||||
/** hhea.ascender metric */
|
||||
ascender: number;
|
||||
/** hhea.descender metric */
|
||||
descender: number;
|
||||
/** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */
|
||||
lineHeight: number;
|
||||
};
|
||||
/** flag to indicate a deprecated font */
|
||||
deprecated?: true;
|
||||
/**
|
||||
* whether this is a font that users can use (= shown in font picker)
|
||||
*/
|
||||
private?: true;
|
||||
/** flag to indiccate a local-only font */
|
||||
local?: true;
|
||||
/** flag to indicate a fallback font */
|
||||
fallback?: true;
|
||||
}
|
||||
|
||||
export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
[FONT_FAMILY.Excalifont]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 886,
|
||||
descender: -374,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY.Nunito]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 1011,
|
||||
descender: -353,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY["Lilita One"]]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 923,
|
||||
descender: -220,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY["Comic Shanns"]]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 750,
|
||||
descender: -250,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY.Virgil]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 886,
|
||||
descender: -374,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
[FONT_FAMILY.Helvetica]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1577,
|
||||
descender: -471,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
deprecated: true,
|
||||
local: true,
|
||||
},
|
||||
[FONT_FAMILY.Cascadia]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1900,
|
||||
descender: -480,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
deprecated: true,
|
||||
},
|
||||
[FONT_FAMILY["Liberation Sans"]]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1854,
|
||||
descender: -434,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
private: true,
|
||||
},
|
||||
[FONT_FAMILY.Assistant]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1021,
|
||||
descender: -287,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
private: true,
|
||||
},
|
||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 880,
|
||||
descender: -144,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
fallback: true,
|
||||
},
|
||||
[FONT_FAMILY_FALLBACKS["Segoe UI Emoji"]]: {
|
||||
metrics: {
|
||||
// reusing Excalifont metrics
|
||||
unitsPerEm: 1000,
|
||||
ascender: 886,
|
||||
descender: -374,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
local: true,
|
||||
fallback: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Unicode ranges defined by google fonts */
|
||||
export const GOOGLE_FONTS_RANGES = {
|
||||
LATIN:
|
||||
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
|
||||
LATIN_EXT:
|
||||
"U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF",
|
||||
CYRILIC_EXT:
|
||||
"U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F",
|
||||
CYRILIC: "U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116",
|
||||
VIETNAMESE:
|
||||
"U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB",
|
||||
};
|
||||
|
||||
/** 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"];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
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";
|
||||
export * from "./emitter";
|
||||
@@ -0,0 +1,151 @@
|
||||
import { isDarwin } from "./constants";
|
||||
|
||||
import type { ValueOf } from "./utility-types";
|
||||
|
||||
export const CODES = {
|
||||
EQUAL: "Equal",
|
||||
MINUS: "Minus",
|
||||
NUM_ADD: "NumpadAdd",
|
||||
NUM_SUBTRACT: "NumpadSubtract",
|
||||
NUM_ZERO: "Numpad0",
|
||||
BRACKET_RIGHT: "BracketRight",
|
||||
BRACKET_LEFT: "BracketLeft",
|
||||
ONE: "Digit1",
|
||||
TWO: "Digit2",
|
||||
THREE: "Digit3",
|
||||
NINE: "Digit9",
|
||||
QUOTE: "Quote",
|
||||
ZERO: "Digit0",
|
||||
SLASH: "Slash",
|
||||
C: "KeyC",
|
||||
D: "KeyD",
|
||||
H: "KeyH",
|
||||
V: "KeyV",
|
||||
Z: "KeyZ",
|
||||
Y: "KeyY",
|
||||
R: "KeyR",
|
||||
S: "KeyS",
|
||||
} as const;
|
||||
|
||||
export const KEYS = {
|
||||
ARROW_DOWN: "ArrowDown",
|
||||
ARROW_LEFT: "ArrowLeft",
|
||||
ARROW_RIGHT: "ArrowRight",
|
||||
ARROW_UP: "ArrowUp",
|
||||
PAGE_UP: "PageUp",
|
||||
PAGE_DOWN: "PageDown",
|
||||
BACKSPACE: "Backspace",
|
||||
ALT: "Alt",
|
||||
CTRL_OR_CMD: isDarwin ? "metaKey" : "ctrlKey",
|
||||
DELETE: "Delete",
|
||||
ENTER: "Enter",
|
||||
ESCAPE: "Escape",
|
||||
QUESTION_MARK: "?",
|
||||
SPACE: " ",
|
||||
TAB: "Tab",
|
||||
CHEVRON_LEFT: "<",
|
||||
CHEVRON_RIGHT: ">",
|
||||
PERIOD: ".",
|
||||
COMMA: ",",
|
||||
SUBTRACT: "-",
|
||||
SLASH: "/",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
D: "d",
|
||||
E: "e",
|
||||
F: "f",
|
||||
G: "g",
|
||||
H: "h",
|
||||
I: "i",
|
||||
L: "l",
|
||||
O: "o",
|
||||
P: "p",
|
||||
Q: "q",
|
||||
R: "r",
|
||||
S: "s",
|
||||
T: "t",
|
||||
V: "v",
|
||||
X: "x",
|
||||
Y: "y",
|
||||
Z: "z",
|
||||
K: "k",
|
||||
W: "w",
|
||||
|
||||
0: "0",
|
||||
1: "1",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
5: "5",
|
||||
6: "6",
|
||||
7: "7",
|
||||
8: "8",
|
||||
9: "9",
|
||||
} as const;
|
||||
|
||||
export type Key = keyof typeof KEYS;
|
||||
|
||||
// defines key code mapping for matching codes as fallback to respective keys on non-latin keyboard layouts
|
||||
export const KeyCodeMap = new Map<ValueOf<typeof KEYS>, ValueOf<typeof CODES>>([
|
||||
[KEYS.Z, CODES.Z],
|
||||
[KEYS.Y, CODES.Y],
|
||||
]);
|
||||
|
||||
export const isLatinChar = (key: string) => /^[a-z]$/.test(key.toLowerCase());
|
||||
|
||||
/**
|
||||
* Used to match key events for any keyboard layout, especially on Windows and Linux,
|
||||
* where non-latin character with modified (CMD) is not substituted with latin-based alternative.
|
||||
*
|
||||
* Uses `event.key` when it's latin, otherwise fallbacks to `event.code` (if mapping exists).
|
||||
*
|
||||
* Example of pressing "z" on different layouts, with the chosen key or code highlighted in []:
|
||||
*
|
||||
* Layout | Code | Key | Comment
|
||||
* --------------------- | ----- | --- | -------
|
||||
* U.S. | KeyZ | [z] |
|
||||
* Czech | KeyY | [z] |
|
||||
* Turkish | KeyN | [z] |
|
||||
* French | KeyW | [z] |
|
||||
* Macedonian | [KeyZ] | з | z with cmd; з is Cyrillic equivalent of z
|
||||
* Russian | [KeyZ] | я | z with cmd
|
||||
* Serbian | [KeyZ] | ѕ | z with cmd
|
||||
* Greek | [KeyZ] | ζ | z with cmd; also ζ is Greek equivalent of z
|
||||
* Hebrew | [KeyZ] | ז | z with cmd; also ז is Hebrew equivalent of z
|
||||
* Pinyin - Simplified | KeyZ | [z] | due to IME
|
||||
* Cangije - Traditional | [KeyZ] | 重 | z with cmd
|
||||
* Japanese | [KeyZ] | つ | z with cmd
|
||||
* 2-Set Korean | [KeyZ] | ㅋ | z with cmd
|
||||
*
|
||||
* More details in https://github.com/excalidraw/excalidraw/pull/5944
|
||||
*/
|
||||
export const matchKey = (
|
||||
event: KeyboardEvent | React.KeyboardEvent<Element>,
|
||||
key: ValueOf<typeof KEYS>,
|
||||
): boolean => {
|
||||
// for latin layouts use key
|
||||
if (key === event.key.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// non-latin layouts fallback to code
|
||||
const code = KeyCodeMap.get(key);
|
||||
return Boolean(code && !isLatinChar(event.key) && event.code === code);
|
||||
};
|
||||
|
||||
export const isArrowKey = (key: string) =>
|
||||
key === KEYS.ARROW_LEFT ||
|
||||
key === KEYS.ARROW_RIGHT ||
|
||||
key === KEYS.ARROW_DOWN ||
|
||||
key === KEYS.ARROW_UP;
|
||||
|
||||
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
|
||||
event.altKey;
|
||||
|
||||
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
|
||||
event.shiftKey;
|
||||
|
||||
export const shouldRotateWithDiscreteAngle = (
|
||||
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
|
||||
) => event.shiftKey;
|
||||
+31
-6
@@ -1,6 +1,14 @@
|
||||
import { Point } from "./types";
|
||||
import {
|
||||
pointFromPair,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
export const getSizeFromPoints = (points: readonly Point[]) => {
|
||||
import type { NullableGridSize } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export const getSizeFromPoints = (
|
||||
points: readonly (GlobalPoint | LocalPoint)[],
|
||||
) => {
|
||||
const xs = points.map((point) => point[0]);
|
||||
const ys = points.map((point) => point[1]);
|
||||
return {
|
||||
@@ -10,7 +18,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
|
||||
};
|
||||
|
||||
/** @arg dimension, 0 for rescaling only x, 1 for y */
|
||||
export const rescalePoints = (
|
||||
export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
|
||||
dimension: 0 | 1,
|
||||
newSize: number,
|
||||
points: readonly Point[],
|
||||
@@ -31,7 +39,7 @@ export const rescalePoints = (
|
||||
if (newCoordinate < nextMinCoordinate) {
|
||||
nextMinCoordinate = newCoordinate;
|
||||
}
|
||||
return newPoint as unknown as Point;
|
||||
return newPoint as Point;
|
||||
});
|
||||
|
||||
if (!normalize) {
|
||||
@@ -45,11 +53,28 @@ export const rescalePoints = (
|
||||
|
||||
const translation = minCoordinate - nextMinCoordinate;
|
||||
|
||||
const nextPoints = scaledPoints.map(
|
||||
(scaledPoint) =>
|
||||
const nextPoints = scaledPoints.map((scaledPoint) =>
|
||||
pointFromPair<Point>(
|
||||
scaledPoint.map((value, currentDimension) => {
|
||||
return currentDimension === dimension ? value + translation : value;
|
||||
}) as [number, number],
|
||||
),
|
||||
);
|
||||
|
||||
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];
|
||||
};
|
||||
@@ -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,5 +1,8 @@
|
||||
import { MaybePromise } from "./utility-types";
|
||||
import { promiseTry, ResolvablePromise, resolvablePromise } from "./utils";
|
||||
import { promiseTry, resolvablePromise } from ".";
|
||||
|
||||
import type { ResolvablePromise } from ".";
|
||||
|
||||
import type { MaybePromise } from "./utility-types";
|
||||
|
||||
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,11 +1,13 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
import { escapeDoubleQuotes } from "./utils";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (!link) {
|
||||
return link;
|
||||
}
|
||||
return sanitizeUrl(link);
|
||||
return sanitizeUrl(escapeDoubleQuotes(link));
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
+12
@@ -65,3 +65,15 @@ export type MakeBrand<T extends string> = {
|
||||
|
||||
/** Maybe just promise or already fulfilled one! */
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
// get union of all keys from the union of types
|
||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
/** Strip all the methods or functions from a type */
|
||||
export type DTO<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||
? [K, V]
|
||||
: never;
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
isTransparent,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
expect(isTransparent("#fff00000")).toEqual(true);
|
||||
expect(isTransparent("transparent")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false when color is not transparent", () => {
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToCommonValue()", () => {
|
||||
it("should return the common value when all values are the same", () => {
|
||||
expect(reduceToCommonValue([1, 1])).toEqual(1);
|
||||
expect(reduceToCommonValue([0, 0])).toEqual(0);
|
||||
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
|
||||
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
|
||||
expect(reduceToCommonValue([""])).toEqual("");
|
||||
expect(reduceToCommonValue([0])).toEqual(0);
|
||||
|
||||
const o = {};
|
||||
expect(reduceToCommonValue([o, o])).toEqual(o);
|
||||
|
||||
expect(
|
||||
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
|
||||
).toEqual(1);
|
||||
expect(
|
||||
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return `null` when values are different", () => {
|
||||
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
|
||||
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
|
||||
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return `null` when some values are nullable", () => {
|
||||
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([1, undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([])).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapFind()", () => {
|
||||
it("should return the first mapped non-null element", () => {
|
||||
{
|
||||
let counter = 0;
|
||||
|
||||
const result = mapFind(["a", "b", "c"], (value) => {
|
||||
counter++;
|
||||
return value === "b" ? 42 : null;
|
||||
});
|
||||
expect(result).toEqual(42);
|
||||
expect(counter).toBe(2);
|
||||
}
|
||||
|
||||
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
|
||||
expect(mapFind([1, 2], () => false)).toBe(false);
|
||||
expect(mapFind([1, 2], () => "")).toBe("");
|
||||
});
|
||||
|
||||
it("should return undefined if no mapped element is found", () => {
|
||||
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
+190
-30
@@ -1,20 +1,32 @@
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
DEFAULT_VERSION,
|
||||
EVENT,
|
||||
FONT_FAMILY,
|
||||
isDarwin,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
import { FontFamilyValues, FontString } from "./element/types";
|
||||
import {
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
ActiveTool,
|
||||
AppState,
|
||||
ToolType,
|
||||
UnsubscribeCallback,
|
||||
Zoom,
|
||||
} from "./types";
|
||||
import { MaybePromise, ResolutionType } from "./utility-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;
|
||||
|
||||
@@ -88,7 +100,9 @@ export const getFontFamilyString = ({
|
||||
}) => {
|
||||
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
||||
if (id === fontFamily) {
|
||||
return `${fontFamilyString}, ${WINDOWS_EMOJI_FALLBACK_FONT}`;
|
||||
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
||||
.map((x) => `, ${x}`)
|
||||
.join("")}`;
|
||||
}
|
||||
}
|
||||
return WINDOWS_EMOJI_FALLBACK_FONT;
|
||||
@@ -157,7 +171,7 @@ export const throttleRAF = <T extends any[]>(
|
||||
};
|
||||
|
||||
const ret = (...args: T) => {
|
||||
if (import.meta.env.MODE === "test") {
|
||||
if (isTestEnv()) {
|
||||
fn(...args);
|
||||
return;
|
||||
}
|
||||
@@ -370,7 +384,7 @@ export const updateActiveTool = (
|
||||
type: ToolType;
|
||||
}
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean }) & {
|
||||
) & { locked?: boolean; fromSelection?: boolean }) & {
|
||||
lastActiveToolBeforeEraser?: ActiveTool | null;
|
||||
},
|
||||
): AppState["activeTool"] => {
|
||||
@@ -392,6 +406,7 @@ export const updateActiveTool = (
|
||||
type: data.type,
|
||||
customType: null,
|
||||
locked: data.locked ?? appState.activeTool.locked,
|
||||
fromSelection: data.fromSelection ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -527,6 +542,20 @@ export const findLastIndex = <T>(
|
||||
return -1;
|
||||
};
|
||||
|
||||
/** returns the first non-null mapped value */
|
||||
export const mapFind = <T, K>(
|
||||
collection: readonly T[],
|
||||
iteratee: (value: T, index: number) => K | undefined | null,
|
||||
): K | undefined => {
|
||||
for (let idx = 0; idx < collection.length; idx++) {
|
||||
const result = iteratee(collection[idx], idx);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||
@@ -537,6 +566,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
|
||||
@@ -660,7 +692,7 @@ export const arrayToMap = <T extends { id: string } | string>(
|
||||
return items.reduce((acc: Map<string, T>, element) => {
|
||||
acc.set(typeof element === "string" ? element : element.id, element);
|
||||
return acc;
|
||||
}, new Map());
|
||||
}, new Map() as Map<string, T>);
|
||||
};
|
||||
|
||||
export const arrayToMapWithIndex = <T extends { id: string }>(
|
||||
@@ -671,7 +703,77 @@ export const arrayToMapWithIndex = <T extends { id: string }>(
|
||||
return acc;
|
||||
}, new Map<string, [element: T, index: number]>());
|
||||
|
||||
export const isTestEnv = () => import.meta.env.MODE === "test";
|
||||
/**
|
||||
* Transform array into an object, use only when array order is irrelevant.
|
||||
*/
|
||||
export const arrayToObject = <T>(
|
||||
array: readonly T[],
|
||||
groupBy?: (value: T) => string | number,
|
||||
) =>
|
||||
array.reduce((acc, value, idx) => {
|
||||
acc[groupBy ? groupBy(value) : idx] = value;
|
||||
return acc;
|
||||
}, {} as { [key: string]: T });
|
||||
|
||||
/** Doubly linked node */
|
||||
export type Node<T> = T & {
|
||||
prev: Node<T> | null;
|
||||
next: Node<T> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a circular doubly linked list by adding `prev` and `next` props to the existing array nodes.
|
||||
*/
|
||||
export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
||||
array.reduce((acc, curr, index) => {
|
||||
const node: Node<T> = { ...curr, prev: null, next: null };
|
||||
|
||||
// no-op for first item, we don't want circular references on a single item
|
||||
if (index !== 0) {
|
||||
const prevNode = acc[index - 1];
|
||||
node.prev = prevNode;
|
||||
prevNode.next = node;
|
||||
|
||||
if (index === array.length - 1) {
|
||||
// make the references circular and connect head & tail
|
||||
const firstNode = acc[0];
|
||||
node.next = firstNode;
|
||||
firstNode.prev = node;
|
||||
}
|
||||
}
|
||||
|
||||
acc.push(node);
|
||||
|
||||
return acc;
|
||||
}, [] as Node<T>[]);
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an iterable.
|
||||
* Useful for avoiding entry allocations when iterating object / map on each iteration.
|
||||
*/
|
||||
export const toIterable = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): Iterable<T> => {
|
||||
return Array.isArray(values) ? values : values.values();
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an array.
|
||||
*/
|
||||
export const toArray = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): T[] => {
|
||||
return Array.isArray(values) ? values : Array.from(toIterable(values));
|
||||
};
|
||||
|
||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||
|
||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||
|
||||
export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION;
|
||||
|
||||
export const isServerEnv = () =>
|
||||
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
|
||||
|
||||
export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
|
||||
return new CustomEvent(name, {
|
||||
@@ -791,14 +893,6 @@ export const isShallowEqual = <
|
||||
const aKeys = Object.keys(objA);
|
||||
const bKeys = Object.keys(objB);
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
if (debug) {
|
||||
console.warn(
|
||||
`%cisShallowEqual: objects don't have same properties ->`,
|
||||
"color: #8B4000",
|
||||
objA,
|
||||
objB,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -882,6 +976,12 @@ export const assertNever = (
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export function invariant(condition: any, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoizes on values of `opts` object (strict equality).
|
||||
*/
|
||||
@@ -939,10 +1039,6 @@ export const isMemberOf = <T extends string>(
|
||||
|
||||
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
export const isFiniteNumber = (value: any): value is number => {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
};
|
||||
|
||||
export const updateStable = <T extends any[] | Record<string, any>>(
|
||||
prevValue: T,
|
||||
nextValue: T,
|
||||
@@ -1026,7 +1122,6 @@ export function addEventListener(
|
||||
};
|
||||
}
|
||||
|
||||
const average = (a: number, b: number) => (a + b) / 2;
|
||||
export function getSvgPathFromStroke(points: number[][], closed = true) {
|
||||
const len = points.length;
|
||||
|
||||
@@ -1110,3 +1205,68 @@ export const promiseTry = async <TValue, TArgs extends unknown[]>(
|
||||
resolve(fn(...args));
|
||||
});
|
||||
};
|
||||
|
||||
export const isAnyTrue = (...args: boolean[]): boolean =>
|
||||
Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0;
|
||||
|
||||
export const safelyParseJSON = (json: string): Record<string, any> | null => {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
return Array.isArray(value);
|
||||
};
|
||||
|
||||
export const sizeOf = (
|
||||
value:
|
||||
| readonly unknown[]
|
||||
| Readonly<Map<string, unknown>>
|
||||
| Readonly<Record<string, unknown>>
|
||||
| ReadonlySet<unknown>,
|
||||
): number => {
|
||||
return isReadonlyArray(value)
|
||||
? value.length
|
||||
: value instanceof Map || value instanceof Set
|
||||
? value.size
|
||||
: Object.keys(value).length;
|
||||
};
|
||||
|
||||
export const reduceToCommonValue = <T, R = T>(
|
||||
collection: readonly T[] | ReadonlySet<T>,
|
||||
getValue?: (item: T) => R,
|
||||
): R | null => {
|
||||
if (sizeOf(collection) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
||||
|
||||
let commonValue: R | null = null;
|
||||
|
||||
for (const item of collection) {
|
||||
const value = valueExtractor(item);
|
||||
if ((commonValue === null || commonValue === value) && value != null) {
|
||||
commonValue = value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import { KEYS, matchKey } from "../src/keys";
|
||||
|
||||
describe("key matcher", async () => {
|
||||
it("should not match unexpected key", async () => {
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "N" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Unidentified" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "z" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "y" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Z" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Y" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should match key (case insensitive) when key is latin", async () => {
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "z" }), KEYS.Z),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "y" }), KEYS.Y),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Z" }), KEYS.Z),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Y" }), KEYS.Y),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should match key on QWERTY, QWERTZ, AZERTY", async () => {
|
||||
// QWERTY
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// QWERTZ
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyY" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyZ" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// AZERTY
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyW" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should match key on DVORAK, COLEMAK", async () => {
|
||||
// DVORAK
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeySemicolon" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyF" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// COLEMAK
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyJ" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should match key on Turkish-Q", async () => {
|
||||
// Turkish-Q
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyN" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "Y", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not fallback when code is not defined", async () => {
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "я" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "卜" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not fallback when code is incorrect", async () => {
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "Y", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should fallback to code when key is non-latin", async () => {
|
||||
// Macedonian
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "з", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ѕ", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Russian
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "я", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "н", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Serbian
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ѕ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "з", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Greek
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ζ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "υ", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Hebrew
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ז", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ט", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Cangjie - Traditional
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "重", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "卜", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Japanese
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "つ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ん", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// 2-Set Korean
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ㅋ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ㅛ", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Queue } from "./queue";
|
||||
import { Queue } from "../src/queue";
|
||||
|
||||
describe("Queue", () => {
|
||||
const calls: any[] = [];
|
||||
+2
-1
@@ -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
|
||||
@@ -26,5 +26,6 @@ describe("normalizeLink", () => {
|
||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["../eslintrc.base.json"]
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@excalidraw/element",
|
||||
"version": "0.18.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": "./dist/types/element/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
}
|
||||
},
|
||||
"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": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0"
|
||||
}
|
||||
}
|
||||
+164
-81
@@ -1,4 +1,28 @@
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import {
|
||||
randomInteger,
|
||||
arrayToMap,
|
||||
toBrandedType,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
toArray,
|
||||
} from "@excalidraw/common";
|
||||
import { isNonDeletedElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
@@ -6,18 +30,17 @@ import {
|
||||
ElementsMapOrArray,
|
||||
SceneElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { isNonDeletedElement } from "../element";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { AppState } from "../types";
|
||||
import { Assert, SameType } from "../utility-types";
|
||||
import { randomInteger } from "../random";
|
||||
import { toBrandedType } from "../utils";
|
||||
OrderedExcalidrawElement,
|
||||
Ordered,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||
import type {
|
||||
Assert,
|
||||
Mutable,
|
||||
SameType,
|
||||
} from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { AppState } from "../../excalidraw/types";
|
||||
|
||||
type SceneStateCallback = () => void;
|
||||
type SceneStateCallbackRemover = () => void;
|
||||
@@ -32,12 +55,29 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
for (const element of allElements) {
|
||||
if (!element.isDeleted) {
|
||||
elements.push(element as NonDeleted<T>);
|
||||
elementsMap.set(element.id, element as NonDeletedExcalidrawElement);
|
||||
elementsMap.set(
|
||||
element.id,
|
||||
element as Ordered<NonDeletedExcalidrawElement>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { elementsMap, elements };
|
||||
};
|
||||
|
||||
const validateIndicesThrottled = throttle(
|
||||
(elements: readonly ExcalidrawElement[]) => {
|
||||
if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) {
|
||||
validateFractionalIndices(elements, {
|
||||
// throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
|
||||
shouldThrow: isDevEnv() || isTestEnv(),
|
||||
includeBoundTextValidation: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
1000 * 60,
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
const hashSelectionOpts = (
|
||||
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
|
||||
) => {
|
||||
@@ -65,52 +105,20 @@ const hashSelectionOpts = (
|
||||
// in our codebase
|
||||
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||
|
||||
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
|
||||
if (typeof elementKey === "string") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
class Scene {
|
||||
// ---------------------------------------------------------------------------
|
||||
// static methods/props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
|
||||
private static sceneMapById = new Map<string, Scene>();
|
||||
|
||||
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
|
||||
if (isIdKey(elementKey)) {
|
||||
// for cases where we don't have access to the element object
|
||||
// (e.g. restore serialized appState with id references)
|
||||
this.sceneMapById.set(elementKey, scene);
|
||||
} else {
|
||||
this.sceneMapByElement.set(elementKey, scene);
|
||||
// if mapping element objects, also cache the id string when later
|
||||
// looking up by id alone
|
||||
this.sceneMapById.set(elementKey.id, scene);
|
||||
}
|
||||
}
|
||||
|
||||
static getScene(elementKey: ElementKey): Scene | null {
|
||||
if (isIdKey(elementKey)) {
|
||||
return this.sceneMapById.get(elementKey) || null;
|
||||
}
|
||||
return this.sceneMapByElement.get(elementKey) || null;
|
||||
}
|
||||
|
||||
export class Scene {
|
||||
// ---------------------------------------------------------------------------
|
||||
// instance methods/props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private callbacks: Set<SceneStateCallback> = new Set();
|
||||
|
||||
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
||||
private nonDeletedElements: readonly Ordered<NonDeletedExcalidrawElement>[] =
|
||||
[];
|
||||
private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
||||
new Map(),
|
||||
);
|
||||
private elements: readonly ExcalidrawElement[] = [];
|
||||
// ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so
|
||||
private elements: readonly OrderedExcalidrawElement[] = [];
|
||||
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
|
||||
[];
|
||||
private frames: readonly ExcalidrawFrameLikeElement[] = [];
|
||||
@@ -124,10 +132,16 @@ class Scene {
|
||||
elements: null,
|
||||
cache: new Map(),
|
||||
};
|
||||
private versionNonce: number | undefined;
|
||||
/**
|
||||
* Random integer regenerated each scene update.
|
||||
*
|
||||
* Does not relate to elements versions, it's only a renderer
|
||||
* cache-invalidation nonce at the moment.
|
||||
*/
|
||||
private sceneNonce: number | undefined;
|
||||
|
||||
getElementsMapIncludingDeleted() {
|
||||
return this.elementsMap;
|
||||
getSceneNonce() {
|
||||
return this.sceneNonce;
|
||||
}
|
||||
|
||||
getNonDeletedElementsMap() {
|
||||
@@ -138,7 +152,11 @@ class Scene {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
|
||||
getElementsMapIncludingDeleted() {
|
||||
return this.elementsMap;
|
||||
}
|
||||
|
||||
getNonDeletedElements() {
|
||||
return this.nonDeletedElements;
|
||||
}
|
||||
|
||||
@@ -146,6 +164,12 @@ class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements);
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedElements(opts: {
|
||||
// NOTE can be ommitted by making Scene constructor require App instance
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
@@ -200,10 +224,6 @@ class Scene {
|
||||
return (this.elementsMap.get(id) as T | undefined) || null;
|
||||
}
|
||||
|
||||
getVersionNonce() {
|
||||
return this.versionNonce;
|
||||
}
|
||||
|
||||
getNonDeletedElement(
|
||||
id: ExcalidrawElement["id"],
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
@@ -244,19 +264,19 @@ class Scene {
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
this.elements =
|
||||
// ts doesn't like `Array.isArray` of `instanceof Map`
|
||||
nextElements instanceof Array
|
||||
? nextElements
|
||||
: Array.from(nextElements.values());
|
||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
this.elements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
nextFrameLikes.push(element);
|
||||
}
|
||||
this.elementsMap.set(element.id, element);
|
||||
Scene.mapElementToScene(element, this);
|
||||
});
|
||||
const nonDeletedElements = getNonDeletedElements(this.elements);
|
||||
this.nonDeletedElements = nonDeletedElements.elements;
|
||||
@@ -265,18 +285,18 @@ class Scene {
|
||||
this.frames = nextFrameLikes;
|
||||
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
|
||||
|
||||
this.informMutation();
|
||||
this.triggerUpdate();
|
||||
}
|
||||
|
||||
informMutation() {
|
||||
this.versionNonce = randomInteger();
|
||||
triggerUpdate() {
|
||||
this.sceneNonce = randomInteger();
|
||||
|
||||
for (const callback of Array.from(this.callbacks)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
addCallback(cb: SceneStateCallback): SceneStateCallbackRemover {
|
||||
onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover {
|
||||
if (this.callbacks.has(cb)) {
|
||||
throw new Error();
|
||||
}
|
||||
@@ -292,8 +312,8 @@ class Scene {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.nonDeletedElements = [];
|
||||
this.elements = [];
|
||||
this.nonDeletedElements = [];
|
||||
this.nonDeletedFramesLikes = [];
|
||||
this.frames = [];
|
||||
this.elementsMap.clear();
|
||||
@@ -301,12 +321,6 @@ class Scene {
|
||||
this.selectedElementsCache.elements = null;
|
||||
this.selectedElementsCache.cache.clear();
|
||||
|
||||
Scene.sceneMapById.forEach((scene, elementKey) => {
|
||||
if (scene === this) {
|
||||
Scene.sceneMapById.delete(elementKey);
|
||||
}
|
||||
});
|
||||
|
||||
// done not for memory leaks, but to guard against possible late fires
|
||||
// (I guess?)
|
||||
this.callbacks.clear();
|
||||
@@ -318,35 +332,58 @@ class Scene {
|
||||
"insertElementAtIndex can only be called with index >= 0",
|
||||
);
|
||||
}
|
||||
|
||||
const nextElements = [
|
||||
...this.elements.slice(0, index),
|
||||
element,
|
||||
...this.elements.slice(index),
|
||||
];
|
||||
|
||||
syncMovedIndices(nextElements, arrayToMap([element]));
|
||||
|
||||
this.replaceAllElements(nextElements);
|
||||
}
|
||||
|
||||
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(index) || index < 0) {
|
||||
throw new Error(
|
||||
"insertElementAtIndex can only be called with index >= 0",
|
||||
);
|
||||
}
|
||||
|
||||
const nextElements = [
|
||||
...this.elements.slice(0, index),
|
||||
...elements,
|
||||
...this.elements.slice(index),
|
||||
];
|
||||
|
||||
syncMovedIndices(nextElements, arrayToMap(elements));
|
||||
|
||||
this.replaceAllElements(nextElements);
|
||||
}
|
||||
|
||||
addNewElement = (element: ExcalidrawElement) => {
|
||||
if (element.frameId) {
|
||||
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
|
||||
} else {
|
||||
this.replaceAllElements([...this.elements, element]);
|
||||
insertElement = (element: ExcalidrawElement) => {
|
||||
const index = element.frameId
|
||||
? this.getElementIndex(element.frameId)
|
||||
: this.elements.length;
|
||||
|
||||
this.insertElementAtIndex(element, index);
|
||||
};
|
||||
|
||||
insertElements = (elements: ExcalidrawElement[]) => {
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = elements[0]?.frameId
|
||||
? this.getElementIndex(elements[0].frameId)
|
||||
: this.elements.length;
|
||||
|
||||
this.insertElementsAtIndex(elements, index);
|
||||
};
|
||||
|
||||
getElementIndex(elementId: string) {
|
||||
@@ -368,6 +405,52 @@ class Scene {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export default Scene;
|
||||
getElementsFromId = (id: string): ExcalidrawElement[] => {
|
||||
const elementsMap = this.getNonDeletedElementsMap();
|
||||
// first check if the id is an element
|
||||
const el = elementsMap.get(id);
|
||||
if (el) {
|
||||
return [el];
|
||||
}
|
||||
|
||||
// then, check if the id is a group
|
||||
return getElementsInGroup(elementsMap, id);
|
||||
};
|
||||
|
||||
// Mutate an element with passed updates and trigger the component to update. Make sure you
|
||||
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
||||
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
options: {
|
||||
informMutation: boolean;
|
||||
isDragging: boolean;
|
||||
} = {
|
||||
informMutation: true,
|
||||
isDragging: false,
|
||||
},
|
||||
) {
|
||||
const elementsMap = this.getNonDeletedElementsMap();
|
||||
|
||||
const { version: prevVersion } = element;
|
||||
const { version: nextVersion } = mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
updates,
|
||||
options,
|
||||
);
|
||||
|
||||
if (
|
||||
// skip if the element is not in the scene (i.e. selection)
|
||||
this.elementsMap.has(element.id) &&
|
||||
// skip if the element's version hasn't changed, as mutateElement returned the same element
|
||||
prevVersion !== nextVersion &&
|
||||
options.informMutation
|
||||
) {
|
||||
this.triggerUpdate();
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
axis: "x" | "y";
|
||||
@@ -10,9 +14,10 @@ export interface Alignment {
|
||||
|
||||
export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
@@ -25,12 +30,19 @@ export const alignElements = (
|
||||
selectionBoundingBox,
|
||||
alignment,
|
||||
);
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
// update element
|
||||
const updatedEle = scene.mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// update bound elements
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedEle;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+496
-164
@@ -1,17 +1,42 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
import { distance2d, rotate, rotatePoint } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import { AppState, Point } from "../types";
|
||||
import { generateRoughOptions } from "../scene/Shape";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
invariant,
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
|
||||
import type {
|
||||
Curve,
|
||||
Degrees,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./shape";
|
||||
import { ShapeCache } from "./shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
@@ -19,12 +44,28 @@ import {
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { rescalePoints } from "../points";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
import { getElementShape } from "./shape";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type { Drawable, Op } from "roughjs/bin/core";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import type {
|
||||
Arrowhead,
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
@@ -61,9 +102,23 @@ export class ElementBounds {
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
private static nonRotatedBoundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
static getBounds(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
) {
|
||||
const cachedBounds =
|
||||
nonRotated && element.angle !== 0
|
||||
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||
: ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (
|
||||
cachedBounds?.version &&
|
||||
@@ -74,6 +129,23 @@ export class ElementBounds {
|
||||
) {
|
||||
return cachedBounds.bounds;
|
||||
}
|
||||
|
||||
if (nonRotated && element.angle !== 0) {
|
||||
const nonRotatedBounds = ElementBounds.calculateBounds(
|
||||
{
|
||||
...element,
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
ElementBounds.nonRotatedBoundsCache.set(element, {
|
||||
version: element.version,
|
||||
bounds: nonRotatedBounds,
|
||||
});
|
||||
|
||||
return nonRotatedBounds;
|
||||
}
|
||||
|
||||
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
@@ -97,7 +169,11 @@ export class ElementBounds {
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
pointRotateRads(
|
||||
pointFrom(x, y),
|
||||
pointFrom(cx - element.x, cy - element.y),
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -110,10 +186,26 @@ export class ElementBounds {
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const [x11, y11] = pointRotateRads(
|
||||
pointFrom(cx, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x12, y12] = pointRotateRads(
|
||||
pointFrom(cx, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x22, y22] = pointRotateRads(
|
||||
pointFrom(x1, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x21, y21] = pointRotateRads(
|
||||
pointFrom(x2, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
@@ -128,10 +220,26 @@ export class ElementBounds {
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const [x11, y11] = pointRotateRads(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x12, y12] = pointRotateRads(
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x22, y22] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x21, y21] = pointRotateRads(
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
@@ -165,18 +273,18 @@ export const getElementAbsoluteCoords = (
|
||||
? getContainerElement(element, elementsMap)
|
||||
: null;
|
||||
if (isArrowElement(container)) {
|
||||
const coords = LinearElementEditor.getBoundTextElementPosition(
|
||||
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
element as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
);
|
||||
return [
|
||||
coords.x,
|
||||
coords.y,
|
||||
coords.x + element.width,
|
||||
coords.y + element.height,
|
||||
coords.x + element.width / 2,
|
||||
coords.y + element.height / 2,
|
||||
x,
|
||||
y,
|
||||
x + element.width,
|
||||
y + element.height,
|
||||
x + element.width / 2,
|
||||
y + element.height / 2,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -195,48 +303,82 @@ export const getElementAbsoluteCoords = (
|
||||
* that can be used for visual collision detection (useful for frames)
|
||||
* as opposed to bounding box collision detection
|
||||
*/
|
||||
/**
|
||||
* Given an element, return the line segments that make up the element.
|
||||
*
|
||||
* Uses helpers from /math
|
||||
*/
|
||||
export const getElementLineSegments = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [Point, Point][] => {
|
||||
): LineSegment<GlobalPoint>[] => {
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const center = pointFrom<GlobalPoint>(cx, cy);
|
||||
|
||||
const center: Point = [cx, cy];
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: [Point, Point][] = [];
|
||||
|
||||
if (shape.type === "polycurve") {
|
||||
const curves = shape.data;
|
||||
const points = curves
|
||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
||||
.flat();
|
||||
let i = 0;
|
||||
|
||||
while (i < element.points.length - 1) {
|
||||
segments.push([
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i + 1][0] + element.x,
|
||||
element.points[i + 1][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
]);
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
} else if (shape.type === "polyline") {
|
||||
return shape.data as LineSegment<GlobalPoint>[];
|
||||
} else if (_isRectanguloidElement(element)) {
|
||||
const [sides, corners] = deconstructRectanguloidElement(element);
|
||||
const cornerSegments: LineSegment<GlobalPoint>[] = corners
|
||||
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
|
||||
.flat();
|
||||
const rotatedSides = getRotatedSides(sides, center, element.angle);
|
||||
return [...rotatedSides, ...cornerSegments];
|
||||
} else if (element.type === "diamond") {
|
||||
const [sides, corners] = deconstructDiamondElement(element);
|
||||
const cornerSegments = corners
|
||||
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
|
||||
.flat();
|
||||
const rotatedSides = getRotatedSides(sides, center, element.angle);
|
||||
|
||||
return [...rotatedSides, ...cornerSegments];
|
||||
} else if (shape.type === "polygon") {
|
||||
if (isTextElement(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
if (container && isLinearElement(container)) {
|
||||
const segments: LineSegment<GlobalPoint>[] = [
|
||||
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
|
||||
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
|
||||
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
|
||||
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
|
||||
];
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
|
||||
const points = shape.data as GlobalPoint[];
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
segments.push(lineSegment(points[i], points[i + 1]));
|
||||
}
|
||||
return segments;
|
||||
} else if (shape.type === "ellipse") {
|
||||
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
|
||||
}
|
||||
|
||||
const [nw, ne, sw, se, n, s, w, e] = (
|
||||
const [nw, ne, sw, se, , , w, e] = (
|
||||
[
|
||||
[x1, y1],
|
||||
[x2, y1],
|
||||
@@ -246,43 +388,109 @@ export const getElementLineSegments = (
|
||||
[cx, y2],
|
||||
[x1, cy],
|
||||
[x2, cy],
|
||||
] as Point[]
|
||||
).map((point) => rotatePoint(point, center, element.angle));
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
|
||||
if (element.type === "ellipse") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
] as GlobalPoint[]
|
||||
).map((point) => pointRotateRads(point, center, element.angle));
|
||||
|
||||
return [
|
||||
[nw, ne],
|
||||
[sw, se],
|
||||
[nw, sw],
|
||||
[ne, se],
|
||||
[nw, e],
|
||||
[sw, e],
|
||||
[ne, w],
|
||||
[se, w],
|
||||
lineSegment(nw, ne),
|
||||
lineSegment(sw, se),
|
||||
lineSegment(nw, sw),
|
||||
lineSegment(ne, se),
|
||||
lineSegment(nw, e),
|
||||
lineSegment(sw, e),
|
||||
lineSegment(ne, w),
|
||||
lineSegment(se, w),
|
||||
];
|
||||
};
|
||||
|
||||
const _isRectanguloidElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawRectanguloidElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
(element.type === "text" && !element.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
const getRotatedSides = (
|
||||
sides: LineSegment<GlobalPoint>[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
) => {
|
||||
return sides.map((side) => {
|
||||
return lineSegment(
|
||||
pointRotateRads<GlobalPoint>(side[0], center, angle),
|
||||
pointRotateRads<GlobalPoint>(side[1], center, angle),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getSegmentsOnCurve = (
|
||||
curve: Curve<GlobalPoint>,
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
): LineSegment<GlobalPoint>[] => {
|
||||
const points = pointsOnBezierCurves(curve, 10);
|
||||
let i = 0;
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointRotateRads<GlobalPoint>(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
center,
|
||||
angle,
|
||||
),
|
||||
pointRotateRads<GlobalPoint>(
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
center,
|
||||
angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
const getSegmentsOnEllipse = (
|
||||
ellipse: ExcalidrawEllipseElement,
|
||||
): LineSegment<GlobalPoint>[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
ellipse.x + ellipse.width / 2,
|
||||
ellipse.y + ellipse.height / 2,
|
||||
);
|
||||
|
||||
const a = ellipse.width / 2;
|
||||
const b = ellipse.height / 2;
|
||||
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
const points: GlobalPoint[] = [];
|
||||
const n = 90;
|
||||
const deltaT = (Math.PI * 2) / n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i * deltaT;
|
||||
const x = center[0] + a * Math.cos(t);
|
||||
const y = center[1] + b * Math.sin(t);
|
||||
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
|
||||
}
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
segments.push(lineSegment(points[i], points[i + 1]));
|
||||
}
|
||||
|
||||
segments.push(lineSegment(points[points.length - 1], points[0]));
|
||||
return segments;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
*
|
||||
@@ -299,13 +507,6 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
|
||||
];
|
||||
};
|
||||
|
||||
export const pointRelativeTo = (
|
||||
element: ExcalidrawElement,
|
||||
absoluteCoords: Point,
|
||||
): Point => {
|
||||
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
|
||||
};
|
||||
|
||||
export const getDiamondPoints = (element: ExcalidrawElement) => {
|
||||
// Here we add +1 to avoid these numbers to be 0
|
||||
// otherwise rough.js will throw an error complaining about it
|
||||
@@ -321,15 +522,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,
|
||||
@@ -392,11 +584,11 @@ const solveQuadratic = (
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
const getCubicBezierCurveBound = (
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point,
|
||||
export const getCubicBezierCurveBound = (
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
p3: GlobalPoint,
|
||||
): Bounds => {
|
||||
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
|
||||
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
|
||||
@@ -422,9 +614,9 @@ const getCubicBezierCurveBound = (
|
||||
|
||||
export const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
transformXY?: (x: number, y: number) => [number, number],
|
||||
transformXY?: (p: GlobalPoint) => GlobalPoint,
|
||||
): Bounds => {
|
||||
let currentP: Point = [0, 0];
|
||||
let currentP: GlobalPoint = pointFrom(0, 0);
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
(limits, { op, data }) => {
|
||||
@@ -432,19 +624,21 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
// move, bcurveTo, lineTo, and curveTo
|
||||
if (op === "move") {
|
||||
// change starting point
|
||||
currentP = data as unknown as Point;
|
||||
const p: GlobalPoint | undefined = pointFromArray(data);
|
||||
invariant(p != null, "Op data is not a point");
|
||||
currentP = p;
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
const _p1 = [data[0], data[1]] as Point;
|
||||
const _p2 = [data[2], data[3]] as Point;
|
||||
const _p3 = [data[4], data[5]] as Point;
|
||||
const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
|
||||
const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
|
||||
const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
|
||||
|
||||
const p1 = transformXY ? transformXY(..._p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(..._p2) : _p2;
|
||||
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
||||
const p1 = transformXY ? transformXY(_p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(_p2) : _p2;
|
||||
const p3 = transformXY ? transformXY(_p3) : _p3;
|
||||
|
||||
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
||||
const p0 = transformXY ? transformXY(currentP) : currentP;
|
||||
currentP = _p3;
|
||||
|
||||
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
||||
@@ -508,20 +702,24 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
case "diamond":
|
||||
case "diamond_outline":
|
||||
return 12;
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one":
|
||||
case "crowfoot_one_or_many":
|
||||
return 20;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
};
|
||||
|
||||
/** @returns number in degrees */
|
||||
export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
|
||||
export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
|
||||
switch (arrowhead) {
|
||||
case "bar":
|
||||
return 90;
|
||||
return 90 as Degrees;
|
||||
case "arrow":
|
||||
return 20;
|
||||
return 20 as Degrees;
|
||||
default:
|
||||
return 25;
|
||||
return 25 as Degrees;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -531,6 +729,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;
|
||||
@@ -540,19 +742,24 @@ export const getArrowheadPoints = (
|
||||
const index = position === "start" ? 1 : ops.length - 1;
|
||||
|
||||
const data = ops[index].data;
|
||||
const p3 = [data[4], data[5]] as Point;
|
||||
const p2 = [data[2], data[3]] as Point;
|
||||
const p1 = [data[0], data[1]] as Point;
|
||||
|
||||
invariant(data.length === 6, "Op data length is not 6");
|
||||
|
||||
const p3 = pointFrom(data[4], data[5]);
|
||||
const p2 = pointFrom(data[2], data[3]);
|
||||
const p1 = pointFrom(data[0], data[1]);
|
||||
|
||||
// We need to find p0 of the bezier curve.
|
||||
// It is typically the last point of the previous
|
||||
// curve; it can also be the position of moveTo operation.
|
||||
const prevOp = ops[index - 1];
|
||||
let p0: Point = [0, 0];
|
||||
let p0 = pointFrom(0, 0);
|
||||
if (prevOp.op === "move") {
|
||||
p0 = prevOp.data as unknown as Point;
|
||||
const p = pointFromArray(prevOp.data);
|
||||
invariant(p != null, "Op data is not a point");
|
||||
p0 = p;
|
||||
} else if (prevOp.op === "bcurveTo") {
|
||||
p0 = [prevOp.data[4], prevOp.data[5]];
|
||||
p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
|
||||
}
|
||||
|
||||
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
||||
@@ -616,9 +823,32 @@ export const getArrowheadPoints = (
|
||||
|
||||
const angle = getArrowheadAngle(arrowhead);
|
||||
|
||||
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
|
||||
// swap (xs, ys) with (x2, y2)
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(-angle as Degrees),
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
return [xs, ys, x3, y3, x4, y4];
|
||||
}
|
||||
|
||||
// Return points
|
||||
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
|
||||
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
((-angle * Math.PI) / 180) as Radians,
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
|
||||
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
|
||||
// point opposite to the arrowhead point
|
||||
@@ -628,12 +858,10 @@ export const getArrowheadPoints = (
|
||||
if (position === "start") {
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = rotate(
|
||||
x2 + minSize * 2,
|
||||
y2,
|
||||
x2,
|
||||
y2,
|
||||
Math.atan2(py - y2, px - x2),
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
);
|
||||
} else {
|
||||
const [px, py] =
|
||||
@@ -641,12 +869,10 @@ export const getArrowheadPoints = (
|
||||
? element.points[element.points.length - 2]
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = rotate(
|
||||
x2 - minSize * 2,
|
||||
y2,
|
||||
x2,
|
||||
y2,
|
||||
Math.atan2(y2 - py, x2 - px),
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -672,7 +898,10 @@ const generateLinearElementShape = (
|
||||
return "linearPath";
|
||||
})();
|
||||
|
||||
return generator[method](element.points as Mutable<Point>[], options);
|
||||
return generator[method](
|
||||
element.points as Mutable<LocalPoint>[] as RoughPoint[],
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
const getLinearElementRotatedBounds = (
|
||||
@@ -685,11 +914,9 @@ const getLinearElementRotatedBounds = (
|
||||
|
||||
if (element.points.length < 2) {
|
||||
const [pointX, pointY] = element.points[0];
|
||||
const [x, y] = rotate(
|
||||
element.x + pointX,
|
||||
element.y + pointY,
|
||||
cx,
|
||||
cy,
|
||||
const [x, y] = pointRotateRads(
|
||||
pointFrom(element.x + pointX, element.y + pointY),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -715,8 +942,12 @@ const getLinearElementRotatedBounds = (
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = (x: number, y: number) =>
|
||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
pointRotateRads<GlobalPoint>(
|
||||
pointFrom(element.x + x, element.y + y),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
let coords: Bounds = [res[0], res[1], res[2], res[3]];
|
||||
if (boundTextElement) {
|
||||
@@ -739,14 +970,16 @@ const getLinearElementRotatedBounds = (
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
): Bounds => {
|
||||
return ElementBounds.getBounds(element, elementsMap);
|
||||
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elements: ElementsMapOrArray,
|
||||
elementsMap?: ElementsMap,
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
if (!sizeOf(elements)) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
@@ -755,10 +988,10 @@ export const getCommonBounds = (
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const _elementsMap = elementsMap || arrayToMap(elements);
|
||||
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, _elementsMap);
|
||||
minX = Math.min(minX, x1);
|
||||
minY = Math.min(minY, y1);
|
||||
maxX = Math.max(maxX, x2);
|
||||
@@ -867,7 +1100,10 @@ export const getClosestElementBounds = (
|
||||
const elementsMap = arrayToMap(elements);
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
|
||||
const distance = pointDistance(
|
||||
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
pointFrom(from.x, from.y),
|
||||
);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
@@ -922,3 +1158,99 @@ export const getVisibleSceneBounds = ({
|
||||
-scrollY + height / zoom.value,
|
||||
];
|
||||
};
|
||||
|
||||
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the axis-aligned bounding box for a given element
|
||||
*/
|
||||
export const aabbForElement = (
|
||||
element: Readonly<ExcalidrawElement>,
|
||||
elementsMap: ElementsMap,
|
||||
offset?: [number, number, number, number],
|
||||
) => {
|
||||
const bbox = {
|
||||
minX: element.x,
|
||||
minY: element.y,
|
||||
maxX: element.x + element.width,
|
||||
maxY: element.y + element.height,
|
||||
midX: element.x + element.width / 2,
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const bounds = [
|
||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
] as Bounds;
|
||||
|
||||
if (offset) {
|
||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||
return [
|
||||
bounds[0] - leftOffset,
|
||||
bounds[1] - topOffset,
|
||||
bounds[2] + rightOffset,
|
||||
bounds[3] + downOffset,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const doBoundsIntersect = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
): 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;
|
||||
};
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
};
|
||||
@@ -0,0 +1,556 @@
|
||||
import { isTransparent } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
ellipse,
|
||||
ellipseSegmentInterceptPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
type Bounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
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: GlobalPoint;
|
||||
element: ExcalidrawElement;
|
||||
threshold: number;
|
||||
elementsMap: ElementsMap;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
element,
|
||||
threshold,
|
||||
elementsMap,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs) => {
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
? isPointWithinBounds(
|
||||
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||
point,
|
||||
pointFrom(
|
||||
frameNameBound.x + frameNameBound.width + threshold,
|
||||
frameNameBound.y + frameNameBound.height + threshold,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
// rotated bounding box or not hitting the frame name (saves 99%)
|
||||
if (!hitBounds && !hitFrameName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInElement(point, element, elementsMap) ||
|
||||
isPointOnElementOutline(point, element, elementsMap, threshold)
|
||||
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
||||
|
||||
return hitElement || hitFrameName;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
point: GlobalPoint,
|
||||
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), point, pointFrom(x2, y2));
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
hitArgs: HitTestArgs,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||
|
||||
export const hitElementBoundText = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (!boundTextElementCandidate) {
|
||||
return false;
|
||||
}
|
||||
const boundTextElement = isLinearElement(element)
|
||||
? {
|
||||
...boundTextElementCandidate,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: boundTextElementCandidate;
|
||||
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
/**
|
||||
* Intersect a line with an element for binding test
|
||||
*
|
||||
* @param element
|
||||
* @param line
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export const intersectElementWithLineSegment = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
line: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// First check if the line intersects the element's axis-aligned bounding box
|
||||
// as it is much faster than checking intersection against the element's shape
|
||||
const intersectorBounds = [
|
||||
Math.min(line[0][0] - offset, line[1][0] - offset),
|
||||
Math.min(line[0][1] - offset, line[1][1] - offset),
|
||||
Math.max(line[0][0] + offset, line[1][0] + offset),
|
||||
Math.max(line[0][1] + offset, line[1][1] + offset),
|
||||
] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Do the actual intersection test against the element's shape
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "selection":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
);
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
}
|
||||
};
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const lineIntersections = (
|
||||
lines: LineSegment<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, angle));
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(intersection);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
// 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>(
|
||||
segment[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
segment[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param element
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
const intersectDiamondWithLineSegment = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
// 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 rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param element
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
const intersectEllipseWithLineSegment = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseSegmentInterceptPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
).map((p) => pointRotateRads(p, center, element.angle));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given point is considered on the given shape's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @param tolerance
|
||||
* @returns
|
||||
*/
|
||||
const isPointOnElementOutline = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 1,
|
||||
) => distanceToElement(element, elementsMap, point) <= tolerance;
|
||||
|
||||
/**
|
||||
* Check if the given point is considered inside the element's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
export const isPointInElement = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (
|
||||
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||
!isPathALoop(element.points)
|
||||
) {
|
||||
// There isn't any "inside" for a non-looping path
|
||||
return false;
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const otherPoint = pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(point, center, 0.1)),
|
||||
Math.max(element.width, element.height) * 2,
|
||||
),
|
||||
center,
|
||||
);
|
||||
const intersector = lineSegment(point, otherPoint);
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
intersector,
|
||||
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import { ExcalidrawTextContainer } from "./types";
|
||||
import type { ExcalidrawTextContainer } from "./types";
|
||||
|
||||
export const originalContainerCache: {
|
||||
[id: ExcalidrawTextContainer["id"]]:
|
||||
@@ -0,0 +1,629 @@
|
||||
import {
|
||||
type Radians,
|
||||
pointFrom,
|
||||
pointCenter,
|
||||
pointRotateRads,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorSubtract,
|
||||
vectorAdd,
|
||||
vectorScale,
|
||||
pointFromVector,
|
||||
clamp,
|
||||
isCloseTo,
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
|
||||
import type { TransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ImageCrop,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export const MINIMAL_CROP_SIZE = 10;
|
||||
|
||||
export const cropElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandle: TransformHandleType,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
widthAspectRatio?: number,
|
||||
) => {
|
||||
const { width: uncroppedWidth, height: uncroppedHeight } =
|
||||
getUncroppedWidthAndHeight(element);
|
||||
|
||||
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
|
||||
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
|
||||
|
||||
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
|
||||
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
|
||||
|
||||
/**
|
||||
* uncropped width
|
||||
* *––––––––––––––––––––––––*
|
||||
* | (x,y) (natural) |
|
||||
* | *–––––––* |
|
||||
* | |///////| height | uncropped height
|
||||
* | *–––––––* |
|
||||
* | width (natural) |
|
||||
* *––––––––––––––––––––––––*
|
||||
*/
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
pointerX = rotatedPointer[0];
|
||||
pointerY = rotatedPointer[1];
|
||||
|
||||
let nextWidth = element.width;
|
||||
let nextHeight = element.height;
|
||||
|
||||
let crop: ImageCrop | null = element.crop ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: naturalWidth,
|
||||
height: naturalHeight,
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
};
|
||||
|
||||
const previousCropHeight = crop.height;
|
||||
const previousCropWidth = crop.width;
|
||||
|
||||
const isFlippedByX = element.scale[0] === -1;
|
||||
const isFlippedByY = element.scale[1] === -1;
|
||||
|
||||
let changeInHeight = pointerY - element.y;
|
||||
let changeInWidth = pointerX - element.x;
|
||||
|
||||
if (transformHandle.includes("n")) {
|
||||
nextHeight = clamp(
|
||||
element.height - changeInHeight,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("s")) {
|
||||
changeInHeight = pointerY - element.y - element.height;
|
||||
nextHeight = clamp(
|
||||
element.height + changeInHeight,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("e")) {
|
||||
changeInWidth = pointerX - element.x - element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
element.width + changeInWidth,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("w")) {
|
||||
nextWidth = clamp(
|
||||
element.width - changeInWidth,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
|
||||
);
|
||||
}
|
||||
|
||||
const updateCropWidthAndHeight = (crop: ImageCrop) => {
|
||||
crop.height = nextHeight * naturalHeightToUncropped;
|
||||
crop.width = nextWidth * naturalWidthToUncropped;
|
||||
};
|
||||
|
||||
updateCropWidthAndHeight(crop);
|
||||
|
||||
const adjustFlipForHandle = (
|
||||
handle: TransformHandleType,
|
||||
crop: ImageCrop,
|
||||
) => {
|
||||
updateCropWidthAndHeight(crop);
|
||||
if (handle.includes("n")) {
|
||||
if (!isFlippedByY) {
|
||||
crop.y += previousCropHeight - crop.height;
|
||||
}
|
||||
}
|
||||
if (handle.includes("s")) {
|
||||
if (isFlippedByY) {
|
||||
crop.y += previousCropHeight - crop.height;
|
||||
}
|
||||
}
|
||||
if (handle.includes("e")) {
|
||||
if (isFlippedByX) {
|
||||
crop.x += previousCropWidth - crop.width;
|
||||
}
|
||||
}
|
||||
if (handle.includes("w")) {
|
||||
if (!isFlippedByX) {
|
||||
crop.x += previousCropWidth - crop.width;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
switch (transformHandle) {
|
||||
case "n": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToLeft = croppedLeft + element.width / 2;
|
||||
const distanceToRight =
|
||||
uncroppedWidth - croppedLeft - element.width / 2;
|
||||
|
||||
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.x += (previousCropWidth - crop.width) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "s": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToLeft = croppedLeft + element.width / 2;
|
||||
const distanceToRight =
|
||||
uncroppedWidth - croppedLeft - element.width / 2;
|
||||
|
||||
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.x += (previousCropWidth - crop.width) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "w": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToTop = croppedTop + element.height / 2;
|
||||
const distanceToBottom =
|
||||
uncroppedHeight - croppedTop - element.height / 2;
|
||||
|
||||
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.y += (previousCropHeight - crop.height) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "e": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToTop = croppedTop + element.height / 2;
|
||||
const distanceToBottom =
|
||||
uncroppedHeight - croppedTop - element.height / 2;
|
||||
|
||||
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.y += (previousCropHeight - crop.height) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "ne": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth > -changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? uncroppedHeight - croppedTop
|
||||
: croppedTop + element.height;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? croppedLeft + element.width
|
||||
: uncroppedWidth - croppedLeft;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "nw": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth < changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? uncroppedHeight - croppedTop
|
||||
: croppedTop + element.height;
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? uncroppedWidth - croppedLeft
|
||||
: croppedLeft + element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "se": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth > changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? croppedTop + element.height
|
||||
: uncroppedHeight - croppedTop;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? croppedLeft + element.width
|
||||
: uncroppedWidth - croppedLeft;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "sw": {
|
||||
if (widthAspectRatio) {
|
||||
if (-changeInWidth > changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? croppedTop + element.height
|
||||
: uncroppedHeight - croppedTop;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? uncroppedWidth - croppedLeft
|
||||
: croppedLeft + element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const newOrigin = recomputeOrigin(
|
||||
element,
|
||||
transformHandle,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
!!widthAspectRatio,
|
||||
);
|
||||
|
||||
// reset crop to null if we're back to orig size
|
||||
if (
|
||||
isCloseTo(crop.width, crop.naturalWidth) &&
|
||||
isCloseTo(crop.height, crop.naturalHeight)
|
||||
) {
|
||||
crop = null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
crop,
|
||||
};
|
||||
};
|
||||
|
||||
const recomputeOrigin = (
|
||||
stateAtCropStart: NonDeleted<ExcalidrawElement>,
|
||||
transformHandle: TransformHandleType,
|
||||
width: number,
|
||||
height: number,
|
||||
shouldMaintainAspectRatio?: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtCropStart,
|
||||
stateAtCropStart.width,
|
||||
stateAtCropStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = pointFrom(x1, y1);
|
||||
const startBottomRight = pointFrom(x2, y2);
|
||||
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
// Calculate new topLeft based on fixed corner during resize
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
|
||||
if (["n", "w", "nw"].includes(transformHandle)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startBottomRight[1] - Math.abs(newBoundsHeight),
|
||||
];
|
||||
}
|
||||
if (transformHandle === "ne") {
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
|
||||
}
|
||||
if (transformHandle === "sw") {
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
|
||||
}
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (["s", "n"].includes(transformHandle)) {
|
||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||
}
|
||||
if (["e", "w"].includes(transformHandle)) {
|
||||
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtCropStart.angle;
|
||||
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
-angle as Radians,
|
||||
);
|
||||
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
|
||||
|
||||
return newOrigin;
|
||||
};
|
||||
|
||||
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
|
||||
export const getUncroppedImageElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (element.crop) {
|
||||
const { width, height } = getUncroppedWidthAndHeight(element);
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const topLeftVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topRightVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topEdgeNormalized = vectorNormalize(
|
||||
vectorSubtract(topRightVector, topLeftVector),
|
||||
);
|
||||
const bottomLeftVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
|
||||
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
|
||||
|
||||
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
|
||||
|
||||
const rotatedTopLeft = vectorAdd(
|
||||
vectorAdd(
|
||||
topLeftVector,
|
||||
vectorScale(
|
||||
topEdgeNormalized,
|
||||
(-cropX * width) / element.crop.naturalWidth,
|
||||
),
|
||||
),
|
||||
vectorScale(
|
||||
leftEdgeNormalized,
|
||||
(-cropY * height) / element.crop.naturalHeight,
|
||||
),
|
||||
);
|
||||
|
||||
const center = pointFromVector(
|
||||
vectorAdd(
|
||||
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
|
||||
vectorScale(leftEdgeNormalized, height / 2),
|
||||
),
|
||||
);
|
||||
|
||||
const unrotatedTopLeft = pointRotateRads(
|
||||
pointFromVector(rotatedTopLeft),
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
const uncroppedElement: ExcalidrawImageElement = {
|
||||
...element,
|
||||
x: unrotatedTopLeft[0],
|
||||
y: unrotatedTopLeft[1],
|
||||
width,
|
||||
height,
|
||||
crop: null,
|
||||
};
|
||||
|
||||
return uncroppedElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
|
||||
if (element.crop) {
|
||||
const width =
|
||||
element.width / (element.crop.width / element.crop.naturalWidth);
|
||||
const height =
|
||||
element.height / (element.crop.height / element.crop.naturalHeight);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
};
|
||||
};
|
||||
|
||||
const adjustCropPosition = (
|
||||
crop: ImageCrop,
|
||||
scale: ExcalidrawImageElement["scale"],
|
||||
) => {
|
||||
let cropX = crop.x;
|
||||
let cropY = crop.y;
|
||||
|
||||
const flipX = scale[0] === -1;
|
||||
const flipY = scale[1] === -1;
|
||||
|
||||
if (flipX) {
|
||||
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
|
||||
}
|
||||
|
||||
if (flipY) {
|
||||
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
|
||||
}
|
||||
|
||||
return {
|
||||
cropX,
|
||||
cropY,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFlipAdjustedCropPosition = (
|
||||
element: ExcalidrawImageElement,
|
||||
natural = false,
|
||||
) => {
|
||||
const crop = element.crop;
|
||||
if (!crop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isFlippedByX = element.scale[0] === -1;
|
||||
const isFlippedByY = element.scale[1] === -1;
|
||||
|
||||
let cropX = crop.x;
|
||||
let cropY = crop.y;
|
||||
|
||||
if (isFlippedByX) {
|
||||
cropX = crop.naturalWidth - crop.width - crop.x;
|
||||
}
|
||||
|
||||
if (isFlippedByY) {
|
||||
cropY = crop.naturalHeight - crop.height - crop.y;
|
||||
}
|
||||
|
||||
if (natural) {
|
||||
return {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
};
|
||||
}
|
||||
|
||||
const { width, height } = getUncroppedWidthAndHeight(element);
|
||||
|
||||
return {
|
||||
x: cropX / (crop.naturalWidth / width),
|
||||
y: cropY / (crop.naturalHeight / height),
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { elementCenterPoint } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToElement = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||
case "diamond":
|
||||
return distanceToDiamondElement(element, elementsMap, p);
|
||||
case "ellipse":
|
||||
return distanceToEllipseElement(element, elementsMap, p);
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(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,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
// 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,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
// 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,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
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),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceToLinearOrFreeDraElement = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
return Math.min(
|
||||
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
);
|
||||
};
|
||||
+5
-3
@@ -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";
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
TEXT_AUTOWRAP_THRESHOLD,
|
||||
getGridPoint,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
NormalizedZoomValue,
|
||||
NullableGridSize,
|
||||
PointerDownState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { getMinTextElementWidth } from "./textMeasurements";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
_selectedElements: NonDeletedExcalidrawElement[],
|
||||
offset: { x: number; y: number },
|
||||
scene: Scene,
|
||||
snapOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
gridSize: NullableGridSize,
|
||||
) => {
|
||||
if (
|
||||
_selectedElements.length === 1 &&
|
||||
isElbowArrow(_selectedElements[0]) &&
|
||||
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = _selectedElements.filter((element) => {
|
||||
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
|
||||
const startElement = _selectedElements.find(
|
||||
(el) => el.id === element.startBinding?.elementId,
|
||||
);
|
||||
const endElement = _selectedElements.find(
|
||||
(el) => el.id === element.endBinding?.elementId,
|
||||
);
|
||||
|
||||
return startElement && endElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
|
||||
selectedElements,
|
||||
);
|
||||
const frames = selectedElements
|
||||
.filter((e) => isFrameLikeElement(e))
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
for (const element of scene.getNonDeletedElements()) {
|
||||
if (element.frameId !== null && frames.includes(element.frameId)) {
|
||||
elementsToUpdate.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
getCommonBounds(origElements),
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
if (!isArrowElement(element)) {
|
||||
// skip arrow labels since we calculate its position during render
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (textElement) {
|
||||
updateElementCoords(
|
||||
pointerDownState,
|
||||
textElement,
|
||||
scene,
|
||||
adjustedOffset,
|
||||
);
|
||||
}
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const calculateOffset = (
|
||||
commonBounds: Bounds,
|
||||
dragOffset: { x: number; y: number },
|
||||
snapOffset: { x: number; y: number },
|
||||
gridSize: NullableGridSize,
|
||||
): { x: number; y: number } => {
|
||||
const [x, y] = commonBounds;
|
||||
let nextX = x + dragOffset.x + snapOffset.x;
|
||||
let nextY = y + dragOffset.y + snapOffset.y;
|
||||
|
||||
if (snapOffset.x === 0 || snapOffset.y === 0) {
|
||||
const [nextGridX, nextGridY] = getGridPoint(
|
||||
x + dragOffset.x,
|
||||
y + dragOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
if (snapOffset.x === 0) {
|
||||
nextX = nextGridX;
|
||||
}
|
||||
|
||||
if (snapOffset.y === 0) {
|
||||
nextY = nextGridY;
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: nextX - x,
|
||||
y: nextY - y,
|
||||
};
|
||||
};
|
||||
|
||||
const updateElementCoords = (
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
scene: Scene,
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
const originalElement =
|
||||
pointerDownState.originalElements.get(element.id) ?? element;
|
||||
|
||||
const nextX = originalElement.x + dragOffset.x;
|
||||
const nextY = originalElement.y + dragOffset.y;
|
||||
|
||||
scene.mutateElement(element, {
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDragOffsetXY = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
x: number,
|
||||
y: number,
|
||||
): [number, number] => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
return [x - x1, y - y1];
|
||||
};
|
||||
|
||||
export const dragNewElement = ({
|
||||
newElement,
|
||||
elementType,
|
||||
originX,
|
||||
originY,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
zoom,
|
||||
scene,
|
||||
widthAspectRatio = null,
|
||||
originOffset = null,
|
||||
informMutation = true,
|
||||
}: {
|
||||
newElement: NonDeletedExcalidrawElement;
|
||||
elementType: AppState["activeTool"]["type"];
|
||||
originX: number;
|
||||
originY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
shouldMaintainAspectRatio: boolean;
|
||||
shouldResizeFromCenter: boolean;
|
||||
zoom: NormalizedZoomValue;
|
||||
scene: Scene;
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null;
|
||||
originOffset?: {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
informMutation?: boolean;
|
||||
}) => {
|
||||
if (shouldMaintainAspectRatio && newElement.type !== "selection") {
|
||||
if (widthAspectRatio) {
|
||||
height = width / widthAspectRatio;
|
||||
} else {
|
||||
// Depending on where the cursor is at (x, y) relative to where the starting point is
|
||||
// (originX, originY), we use ONLY width or height to control size increase.
|
||||
// This allows the cursor to always "stick" to one of the sides of the bounding box.
|
||||
if (Math.abs(y - originY) > Math.abs(x - originX)) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
height,
|
||||
x < originX ? -width : width,
|
||||
));
|
||||
} else {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
}
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
if (shouldResizeFromCenter) {
|
||||
width += width;
|
||||
height += height;
|
||||
newX = originX - width / 2;
|
||||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
let textAutoResize = null;
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
height = newElement.height;
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
);
|
||||
width = Math.max(width, minWidth);
|
||||
|
||||
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
|
||||
textAutoResize = {
|
||||
autoResize: false,
|
||||
};
|
||||
}
|
||||
|
||||
newY = originY;
|
||||
if (shouldResizeFromCenter) {
|
||||
newX = originX - width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
let imageInitialDimension = null;
|
||||
if (isImageElement(newElement)) {
|
||||
imageInitialDimension = {
|
||||
initialWidth: width,
|
||||
initialHeight: height,
|
||||
};
|
||||
}
|
||||
|
||||
scene.mutateElement(
|
||||
newElement,
|
||||
{
|
||||
x: newX + (originOffset?.x ?? 0),
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
width,
|
||||
height,
|
||||
...textAutoResize,
|
||||
...imageInitialDimension,
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,487 @@
|
||||
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 { fixDuplicatedBindingsAfterDuplication } 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
|
||||
*/
|
||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
randomizeSeed?: boolean,
|
||||
): Readonly<TElement> => {
|
||||
const 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)!;
|
||||
},
|
||||
);
|
||||
return copy;
|
||||
};
|
||||
|
||||
export const duplicateElements = (
|
||||
opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
randomizeSeed?: boolean;
|
||||
overrides?: (data: {
|
||||
duplicateElement: ExcalidrawElement;
|
||||
origElement: ExcalidrawElement;
|
||||
origIdToDuplicateId: Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>;
|
||||
}) => 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"];
|
||||
};
|
||||
}
|
||||
),
|
||||
) => {
|
||||
let { elements } = opts;
|
||||
|
||||
const appState =
|
||||
"appState" in opts
|
||||
? opts.appState
|
||||
: ({
|
||||
editingGroupId: null,
|
||||
selectedGroupIds: {},
|
||||
} as const);
|
||||
|
||||
// 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 duplicatedElements: ExcalidrawElement[] = [];
|
||||
const origElements: ExcalidrawElement[] = [];
|
||||
const origIdToDuplicateId = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>();
|
||||
const duplicateIdToOrigElement = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>();
|
||||
const duplicateElementsMap = 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 elementsWithDuplicates: 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.randomizeSeed,
|
||||
);
|
||||
|
||||
processedIds.set(newElement.id, true);
|
||||
|
||||
duplicateElementsMap.set(newElement.id, newElement);
|
||||
origIdToDuplicateId.set(element.id, newElement.id);
|
||||
duplicateIdToOrigElement.set(newElement.id, element);
|
||||
|
||||
origElements.push(element);
|
||||
duplicatedElements.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 (index > elementsWithDuplicates.length - 1) {
|
||||
elementsWithDuplicates.push(...castArray(elements));
|
||||
return;
|
||||
}
|
||||
|
||||
elementsWithDuplicates.splice(index + 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 = findLastIndex(elementsWithDuplicates, (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(elementsWithDuplicates, (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(elementsWithDuplicates, (el) => {
|
||||
return (
|
||||
el.id === element.id ||
|
||||
("containerId" in el && el.containerId === element.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (boundTextElement) {
|
||||
insertBeforeOrAfterIndex(
|
||||
targetIndex,
|
||||
copyElements([element, boundTextElement]),
|
||||
);
|
||||
} else {
|
||||
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (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(elementsWithDuplicates, (el) => el.id === element.id),
|
||||
copyElements(element),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fixDuplicatedBindingsAfterDuplication(
|
||||
duplicatedElements,
|
||||
origIdToDuplicateId,
|
||||
duplicateElementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
bindElementsToFramesAfterDuplication(
|
||||
elementsWithDuplicates,
|
||||
origElements,
|
||||
origIdToDuplicateId,
|
||||
);
|
||||
|
||||
if (opts.overrides) {
|
||||
for (const duplicateElement of duplicatedElements) {
|
||||
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
|
||||
if (origElement) {
|
||||
Object.assign(
|
||||
duplicateElement,
|
||||
opts.overrides({
|
||||
duplicateElement,
|
||||
origElement,
|
||||
origIdToDuplicateId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
duplicatedElements,
|
||||
duplicateElementsMap,
|
||||
elementsWithDuplicates,
|
||||
origIdToDuplicateId,
|
||||
};
|
||||
};
|
||||
|
||||
// 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,
|
||||
});
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Create and link between shapes.
|
||||
*/
|
||||
|
||||
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<
|
||||
AppProps["generateLinkForSelection"],
|
||||
undefined
|
||||
> = (id, type) => {
|
||||
const url = window.location.href;
|
||||
|
||||
try {
|
||||
const link = new URL(url);
|
||||
link.searchParams.set(ELEMENT_LINK_KEY, id);
|
||||
|
||||
return normalizeLink(link.toString());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return normalizeLink(url);
|
||||
};
|
||||
|
||||
export const getLinkIdAndTypeFromSelection = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): {
|
||||
id: string;
|
||||
type: "element" | "group";
|
||||
} | null => {
|
||||
if (
|
||||
selectedElements.length > 0 &&
|
||||
canCreateLinkFromElements(selectedElements)
|
||||
) {
|
||||
if (selectedElements.length === 1) {
|
||||
return {
|
||||
id: selectedElements[0].id,
|
||||
type: "element",
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedElements.length > 1) {
|
||||
const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
|
||||
|
||||
if (selectedGroupId) {
|
||||
return {
|
||||
id: selectedGroupId,
|
||||
type: "group",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: selectedElements[0].groupIds[0],
|
||||
type: "group",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const canCreateLinkFromElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
) => {
|
||||
if (selectedElements.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isElementLink = (url: string) => {
|
||||
try {
|
||||
const _url = new URL(url);
|
||||
return (
|
||||
_url.searchParams.has(ELEMENT_LINK_KEY) &&
|
||||
_url.host === window.location.host
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseElementLinkFromURL = (url: string) => {
|
||||
try {
|
||||
const { searchParams } = new URL(url);
|
||||
if (searchParams.has(ELEMENT_LINK_KEY)) {
|
||||
const id = searchParams.get(ELEMENT_LINK_KEY);
|
||||
return id;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
};
|
||||
+170
-104
@@ -1,37 +1,48 @@
|
||||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import { ExcalidrawProps } from "../types";
|
||||
import { getFontString, updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { wrapText } from "./textElement";
|
||||
import { isIframeElement } from "./typeChecks";
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
VERTICAL_ALIGN,
|
||||
escapeDoubleQuotes,
|
||||
getFontString,
|
||||
} 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";
|
||||
|
||||
const embeddedLinkCache = new Map<string, IframeData>();
|
||||
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||
|
||||
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
|
||||
const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
||||
const RE_GH_GIST_EMBED =
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||
|
||||
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
|
||||
|
||||
// not anchored to start to allow <blockquote> twitter embeds
|
||||
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:twitter|x).com/;
|
||||
const RE_TWITTER =
|
||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||
const RE_TWITTER_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/(?:twitter|x).com\/[^"']*)/i;
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
|
||||
|
||||
const RE_VALTOWN =
|
||||
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||
|
||||
const RE_GENERIC_EMBED =
|
||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||
@@ -39,6 +50,12 @@ const RE_GENERIC_EMBED =
|
||||
const RE_GIPHY =
|
||||
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
|
||||
|
||||
const RE_REDDIT =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
|
||||
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@@ -53,7 +70,22 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"dddice.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
const ALLOW_SAME_ORIGIN = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"figma.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
export const createSrcDoc = (body: string) => {
|
||||
@@ -62,7 +94,7 @@ export const createSrcDoc = (body: string) => {
|
||||
|
||||
export const getEmbedLink = (
|
||||
link: string | null | undefined,
|
||||
): IframeData | null => {
|
||||
): IframeDataWithSandbox | null => {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
@@ -73,6 +105,10 @@ export const getEmbedLink = (
|
||||
|
||||
const originalLink = link;
|
||||
|
||||
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
|
||||
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
|
||||
);
|
||||
|
||||
let type: "video" | "generic" = "generic";
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
@@ -99,8 +135,14 @@ export const getEmbedLink = (
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const vimeoLink = link.match(RE_VIMEO);
|
||||
@@ -118,8 +160,15 @@ export const getEmbedLink = (
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type, error };
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
error,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const figmaLink = link.match(RE_FIGMA);
|
||||
@@ -133,8 +182,14 @@ export const getEmbedLink = (
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const valLink = link.match(RE_VALTOWN);
|
||||
@@ -145,70 +200,96 @@ export const getEmbedLink = (
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
|
||||
link += link.includes("?") ? "&embed=true" : "?embed=true";
|
||||
}
|
||||
|
||||
if (RE_TWITTER.test(link)) {
|
||||
// the embed srcdoc still supports twitter.com domain only
|
||||
link = link.replace(/\bx.com\b/, "twitter.com");
|
||||
const postId = link.match(RE_TWITTER)![1];
|
||||
// the embed srcdoc still supports twitter.com domain only.
|
||||
// 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 = escapeDoubleQuotes(
|
||||
`https://twitter.com/x/status/${postId}`,
|
||||
);
|
||||
|
||||
let ret: IframeData;
|
||||
// assume embed code
|
||||
if (/<blockquote/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
};
|
||||
// assume regular tweet url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
};
|
||||
}
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_REDDIT.test(link)) {
|
||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
let ret: IframeData;
|
||||
// assume embed code
|
||||
if (/<script>/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
intrinsicSize: { w: 550, h: 720 },
|
||||
};
|
||||
// assume regular url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () =>
|
||||
createSrcDoc(`
|
||||
<script src="${link}.js"></script>
|
||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://gist.github.com/${user}/${gistId}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: () =>
|
||||
createSrcDoc(`
|
||||
<script src="${safeURL}.js"></script>
|
||||
<style type="text/css">
|
||||
* { margin: 0px; }
|
||||
table, .gist { height: 100%; }
|
||||
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
||||
</style>
|
||||
`),
|
||||
intrinsicSize: { w: 550, h: 720 },
|
||||
};
|
||||
}
|
||||
intrinsicSize: { w: 550, h: 720 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(link, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
embeddedLinkCache.set(link, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
};
|
||||
|
||||
export const createPlaceholderEmbeddableLabel = (
|
||||
@@ -248,60 +329,39 @@ export const createPlaceholderEmbeddableLabel = (
|
||||
});
|
||||
};
|
||||
|
||||
export const actionSetEmbeddableAsActiveTool = register({
|
||||
name: "setEmbeddableAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
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",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const validateHostname = (
|
||||
const matchHostname = (
|
||||
url: string,
|
||||
/** using a Set assumes it already contains normalized bare domains */
|
||||
allowedHostnames: Set<string> | string,
|
||||
): boolean => {
|
||||
): string | null => {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
const bareDomain = hostname.replace(/^www\./, "");
|
||||
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
||||
/^([^.]+)/,
|
||||
"*",
|
||||
);
|
||||
|
||||
if (allowedHostnames instanceof Set) {
|
||||
return (
|
||||
ALLOWED_DOMAINS.has(bareDomain) ||
|
||||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
|
||||
if (ALLOWED_DOMAINS.has(bareDomain)) {
|
||||
return bareDomain;
|
||||
}
|
||||
|
||||
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
||||
/^([^.]+)/,
|
||||
"*",
|
||||
);
|
||||
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
|
||||
return bareDomainWithFirstSubdomainWildcarded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
|
||||
return true;
|
||||
const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
|
||||
if (bareDomain === bareAllowedHostname) {
|
||||
return bareAllowedHostname;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const maybeParseEmbedSrc = (str: string): string => {
|
||||
@@ -310,6 +370,11 @@ export const maybeParseEmbedSrc = (str: string): string => {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
|
||||
const redditMatch = str.match(RE_REDDIT_EMBED);
|
||||
if (redditMatch && redditMatch.length === 2) {
|
||||
return redditMatch[1];
|
||||
}
|
||||
|
||||
const gistMatch = str.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
@@ -323,6 +388,7 @@ export const maybeParseEmbedSrc = (str: string): string => {
|
||||
if (match && match.length === 2) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
@@ -350,7 +416,7 @@ export const embeddableURLValidator = (
|
||||
if (url.match(domain)) {
|
||||
return true;
|
||||
}
|
||||
} else if (validateHostname(url, domain)) {
|
||||
} else if (matchHostname(url, domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -358,5 +424,5 @@ export const embeddableURLValidator = (
|
||||
}
|
||||
}
|
||||
|
||||
return validateHostname(url, ALLOWED_DOMAINS);
|
||||
return !!matchHostname(url, ALLOWED_DOMAINS);
|
||||
};
|
||||
@@ -0,0 +1,719 @@
|
||||
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,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
compareHeading,
|
||||
headingForPointFromElement,
|
||||
type Heading,
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
isElbowArrow,
|
||||
isFrameElement,
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
type ElementsMap,
|
||||
type ExcalidrawBindableElement,
|
||||
type ExcalidrawElement,
|
||||
type ExcalidrawFlowchartNodeElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
type Ordered,
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
const VERTICAL_OFFSET = 100;
|
||||
const HORIZONTAL_OFFSET = 100;
|
||||
|
||||
export const getLinkDirectionFromKey = (key: string): LinkDirection => {
|
||||
switch (key) {
|
||||
case KEYS.ARROW_UP:
|
||||
return "up";
|
||||
case KEYS.ARROW_DOWN:
|
||||
return "down";
|
||||
case KEYS.ARROW_RIGHT:
|
||||
return "right";
|
||||
case KEYS.ARROW_LEFT:
|
||||
return "left";
|
||||
default:
|
||||
return "right";
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeRelatives = (
|
||||
type: "predecessors" | "successors",
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const items = [...elementsMap.values()].reduce(
|
||||
(acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
|
||||
let oppositeBinding;
|
||||
if (
|
||||
isElbowArrow(el) &&
|
||||
// we want check existence of the opposite binding, in the direction
|
||||
// we're interested in
|
||||
(oppositeBinding =
|
||||
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
|
||||
// similarly, we need to filter only arrows bound to target node
|
||||
el[type === "predecessors" ? "endBinding" : "startBinding"]
|
||||
?.elementId === node.id
|
||||
) {
|
||||
const relative = elementsMap.get(oppositeBinding.elementId);
|
||||
|
||||
if (!relative) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
invariant(
|
||||
isBindableElement(relative),
|
||||
"not an ExcalidrawBindableElement",
|
||||
);
|
||||
|
||||
const edgePoint = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(
|
||||
node,
|
||||
aabbForElement(node, elementsMap),
|
||||
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||
);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
heading,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_UP))
|
||||
.map((item) => item.relative);
|
||||
case "down":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
|
||||
.map((item) => item.relative);
|
||||
case "right":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
|
||||
.map((item) => item.relative);
|
||||
case "left":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
|
||||
.map((item) => item.relative);
|
||||
}
|
||||
};
|
||||
|
||||
const getSuccessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("successors", node, elementsMap, direction);
|
||||
};
|
||||
|
||||
export const getPredecessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("predecessors", node, elementsMap, direction);
|
||||
};
|
||||
|
||||
const getOffsets = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
linkedNodes: ExcalidrawElement[],
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
|
||||
|
||||
// check if vertical space or horizontal space is available first
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
// check vertical space
|
||||
const minX = element.x;
|
||||
const maxX = element.x + element.width;
|
||||
|
||||
// vertical space is available
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x: 0,
|
||||
y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
|
||||
};
|
||||
}
|
||||
} else if (direction === "right" || direction === "left") {
|
||||
const minY = element.y;
|
||||
const maxY = element.y + element.height;
|
||||
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x:
|
||||
(HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
|
||||
y: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
|
||||
const x =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
|
||||
|
||||
if (direction === "up") {
|
||||
return {
|
||||
x,
|
||||
y: y * -1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
}
|
||||
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const x =
|
||||
(linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
|
||||
element.width;
|
||||
const y =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
|
||||
|
||||
if (direction === "left") {
|
||||
return {
|
||||
x: x * -1,
|
||||
y,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
};
|
||||
|
||||
const addNewNode = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const successors = getSuccessors(element, elementsMap, direction);
|
||||
const predeccessors = getPredecessors(element, elementsMap, direction);
|
||||
|
||||
const offsets = getOffsets(
|
||||
element,
|
||||
[...successors, ...predeccessors],
|
||||
direction,
|
||||
);
|
||||
|
||||
const nextNode = newElement({
|
||||
type: element.type,
|
||||
x: element.x + offsets.x,
|
||||
y: element.y + offsets.y,
|
||||
// TODO: extract this to a util
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
roundness: element.roundness,
|
||||
roughness: element.roughness,
|
||||
backgroundColor: element.backgroundColor,
|
||||
strokeColor: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
opacity: element.opacity,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeStyle: element.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
|
||||
const bindingArrow = createBindingArrow(
|
||||
element,
|
||||
nextNode,
|
||||
direction,
|
||||
appState,
|
||||
scene,
|
||||
);
|
||||
|
||||
return {
|
||||
nextNode,
|
||||
bindingArrow,
|
||||
};
|
||||
};
|
||||
|
||||
export const addNewNodes = (
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
numberOfNodes: number,
|
||||
) => {
|
||||
// always start from 0 and distribute evenly
|
||||
const newNodes: ExcalidrawElement[] = [];
|
||||
|
||||
for (let i = 0; i < numberOfNodes; i++) {
|
||||
let nextX: number;
|
||||
let nextY: number;
|
||||
if (direction === "left" || direction === "right") {
|
||||
const totalHeight =
|
||||
VERTICAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.height;
|
||||
|
||||
const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
|
||||
|
||||
let offsetX = HORIZONTAL_OFFSET + startNode.width;
|
||||
if (direction === "left") {
|
||||
offsetX *= -1;
|
||||
}
|
||||
nextX = startNode.x + offsetX;
|
||||
const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
|
||||
nextY = startY + offsetY;
|
||||
} else {
|
||||
const totalWidth =
|
||||
HORIZONTAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.width;
|
||||
const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
|
||||
let offsetY = VERTICAL_OFFSET + startNode.height;
|
||||
|
||||
if (direction === "up") {
|
||||
offsetY *= -1;
|
||||
}
|
||||
nextY = startNode.y + offsetY;
|
||||
const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
|
||||
nextX = startX + offsetX;
|
||||
}
|
||||
|
||||
const nextNode = newElement({
|
||||
type: startNode.type,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
// TODO: extract this to a util
|
||||
width: startNode.width,
|
||||
height: startNode.height,
|
||||
roundness: startNode.roundness,
|
||||
roughness: startNode.roughness,
|
||||
backgroundColor: startNode.backgroundColor,
|
||||
strokeColor: startNode.strokeColor,
|
||||
strokeWidth: startNode.strokeWidth,
|
||||
opacity: startNode.opacity,
|
||||
fillStyle: startNode.fillStyle,
|
||||
strokeStyle: startNode.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
|
||||
const bindingArrow = createBindingArrow(
|
||||
startNode,
|
||||
nextNode,
|
||||
direction,
|
||||
appState,
|
||||
scene,
|
||||
);
|
||||
|
||||
newNodes.push(nextNode);
|
||||
newNodes.push(bindingArrow);
|
||||
}
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const createBindingArrow = (
|
||||
startBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
endBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
direction: LinkDirection,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
let startX: number;
|
||||
let startY: number;
|
||||
|
||||
const PADDING = 6;
|
||||
|
||||
switch (direction) {
|
||||
case "up": {
|
||||
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||
startY = startBindingElement.y - PADDING;
|
||||
break;
|
||||
}
|
||||
case "down": {
|
||||
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||
startY = startBindingElement.y + startBindingElement.height + PADDING;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
startX = startBindingElement.x + startBindingElement.width + PADDING;
|
||||
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
startX = startBindingElement.x - PADDING;
|
||||
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let endX: number;
|
||||
let endY: number;
|
||||
|
||||
switch (direction) {
|
||||
case "up": {
|
||||
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||
endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
|
||||
break;
|
||||
}
|
||||
case "down": {
|
||||
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||
endY = endBindingElement.y - startY - PADDING;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
endX = endBindingElement.x - startX - PADDING;
|
||||
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
|
||||
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bindingArrow = newArrowElement({
|
||||
type: "arrow",
|
||||
x: startX,
|
||||
y: startY,
|
||||
startArrowhead: null,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
strokeColor: startBindingElement.strokeColor,
|
||||
strokeStyle: startBindingElement.strokeStyle,
|
||||
strokeWidth: startBindingElement.strokeWidth,
|
||||
opacity: startBindingElement.opacity,
|
||||
roughness: startBindingElement.roughness,
|
||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
||||
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
startBindingElement.id,
|
||||
startBindingElement as OrderedExcalidrawElement,
|
||||
);
|
||||
changedElements.set(
|
||||
endBindingElement.id,
|
||||
endBindingElement as OrderedExcalidrawElement,
|
||||
);
|
||||
changedElements.set(
|
||||
bindingArrow.id,
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
bindingArrow,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
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 {
|
||||
isExploring: boolean = false;
|
||||
// nodes that are ONE link away (successor and predecessor both included)
|
||||
private sameLevelNodes: ExcalidrawElement[] = [];
|
||||
private sameLevelIndex: number = 0;
|
||||
// set it to the opposite of the defalut creation direction
|
||||
private direction: LinkDirection | null = null;
|
||||
// for speedier navigation
|
||||
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
|
||||
|
||||
clear() {
|
||||
this.isExploring = false;
|
||||
this.sameLevelNodes = [];
|
||||
this.sameLevelIndex = 0;
|
||||
this.direction = null;
|
||||
this.visitedNodes.clear();
|
||||
}
|
||||
|
||||
exploreByDirection(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
): ExcalidrawElement["id"] | null {
|
||||
if (!isBindableElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// clear if going at a different direction
|
||||
if (direction !== this.direction) {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
// add the current node to the visited
|
||||
if (!this.visitedNodes.has(element.id)) {
|
||||
this.visitedNodes.add(element.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - already started exploring, AND
|
||||
* - there are multiple nodes at the same level, AND
|
||||
* - still going at the same direction, AND
|
||||
*
|
||||
* RESULT:
|
||||
* - loop through nodes at the same level
|
||||
*
|
||||
* WHY:
|
||||
* - provides user the capability to loop through nodes at the same level
|
||||
*/
|
||||
if (
|
||||
this.isExploring &&
|
||||
direction === this.direction &&
|
||||
this.sameLevelNodes.length > 1
|
||||
) {
|
||||
this.sameLevelIndex =
|
||||
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
|
||||
|
||||
return this.sameLevelNodes[this.sameLevelIndex].id;
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
...getSuccessors(element, elementsMap, direction),
|
||||
...getPredecessors(element, elementsMap, direction),
|
||||
];
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - just started exploring at the given direction
|
||||
*
|
||||
* RESULT:
|
||||
* - go to the first node in the given direction
|
||||
*/
|
||||
if (nodes.length > 0) {
|
||||
this.sameLevelIndex = 0;
|
||||
this.isExploring = true;
|
||||
this.sameLevelNodes = nodes;
|
||||
this.direction = direction;
|
||||
this.visitedNodes.add(nodes[0].id);
|
||||
|
||||
return nodes[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - (just started exploring or still going at the same direction) OR
|
||||
* - there're no nodes at the given direction
|
||||
*
|
||||
* RESULT:
|
||||
* - go to some other unvisited linked node
|
||||
*
|
||||
* WHY:
|
||||
* - provide a speedier navigation from a given node to some predecessor
|
||||
* without the user having to change arrow key
|
||||
*/
|
||||
if (direction === this.direction || !this.isExploring) {
|
||||
if (!this.isExploring) {
|
||||
// just started and no other nodes at the given direction
|
||||
// so the current node is technically the first visited node
|
||||
// (this is needed so that we don't get stuck between looping through )
|
||||
this.visitedNodes.add(element.id);
|
||||
}
|
||||
|
||||
const otherDirections: LinkDirection[] = [
|
||||
"up",
|
||||
"right",
|
||||
"down",
|
||||
"left",
|
||||
].filter((dir): dir is LinkDirection => dir !== direction);
|
||||
|
||||
const otherLinkedNodes = otherDirections
|
||||
.map((dir) => [
|
||||
...getSuccessors(element, elementsMap, dir),
|
||||
...getPredecessors(element, elementsMap, dir),
|
||||
])
|
||||
.flat()
|
||||
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
|
||||
|
||||
for (const linkedNode of otherLinkedNodes) {
|
||||
if (!this.visitedNodes.has(linkedNode.id)) {
|
||||
this.visitedNodes.add(linkedNode.id);
|
||||
this.isExploring = true;
|
||||
this.direction = direction;
|
||||
return linkedNode.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FlowChartCreator {
|
||||
isCreatingChart: boolean = false;
|
||||
private numberOfNodes: number = 0;
|
||||
private direction: LinkDirection | null = "right";
|
||||
pendingNodes: PendingExcalidrawElements | null = null;
|
||||
|
||||
createNodes(
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
) {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
if (direction !== this.direction) {
|
||||
const { nextNode, bindingArrow } = addNewNode(
|
||||
startNode,
|
||||
appState,
|
||||
direction,
|
||||
scene,
|
||||
);
|
||||
|
||||
this.numberOfNodes = 1;
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = [nextNode, bindingArrow];
|
||||
} else {
|
||||
this.numberOfNodes += 1;
|
||||
const newNodes = addNewNodes(
|
||||
startNode,
|
||||
appState,
|
||||
direction,
|
||||
scene,
|
||||
this.numberOfNodes,
|
||||
);
|
||||
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = newNodes;
|
||||
}
|
||||
|
||||
// add pending nodes to the same frame as the start node
|
||||
// if every pending node is at least intersecting with the frame
|
||||
if (startNode.frameId) {
|
||||
const frame = elementsMap.get(startNode.frameId);
|
||||
|
||||
invariant(
|
||||
frame && isFrameElement(frame),
|
||||
"not an ExcalidrawFrameElement",
|
||||
);
|
||||
|
||||
if (
|
||||
frame &&
|
||||
this.pendingNodes.every(
|
||||
(node) =>
|
||||
elementsAreInFrameBounds([node], frame, elementsMap) ||
|
||||
elementOverlapsWithFrame(node, frame, elementsMap),
|
||||
)
|
||||
) {
|
||||
this.pendingNodes = this.pendingNodes.map((node) =>
|
||||
mutateElement(node, elementsMap, {
|
||||
frameId: startNode.frameId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.isCreatingChart = false;
|
||||
this.pendingNodes = null;
|
||||
this.direction = null;
|
||||
this.numberOfNodes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const isNodeInFlowchart = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
for (const [, el] of elementsMap) {
|
||||
if (
|
||||
el.type === "arrow" &&
|
||||
(el.startBinding?.elementId === element.id ||
|
||||
el.endBinding?.elementId === element.id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,446 @@
|
||||
import { generateNKeysBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
export class InvalidFractionalIndexError extends Error {
|
||||
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envisioned relation between array order and fractional indices:
|
||||
*
|
||||
* 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
|
||||
* - it's undesirable to perform reorder for each related operation, therefore it's necessary to cache the order defined by fractional indices into an ordered data structure
|
||||
* - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps)
|
||||
* - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
|
||||
* - it's necessary to always keep the fractional indices in sync with the array order
|
||||
* - elements with invalid indices should be detected and synced, without altering the already valid indices
|
||||
*
|
||||
* 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
|
||||
* - as the fractional indices are encoded as part of the elements, it opens up possibilities for incremental-like APIs
|
||||
* - re-order based on fractional indices should be part of (multiplayer) operations such as reconciliation & undo/redo
|
||||
* - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits,
|
||||
* as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ensure that all elements have valid fractional indices.
|
||||
*
|
||||
* @throws `InvalidFractionalIndexError` if invalid index is detected.
|
||||
*/
|
||||
export const validateFractionalIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
shouldThrow = false,
|
||||
includeBoundTextValidation = false,
|
||||
ignoreLogs,
|
||||
reconciliationContext,
|
||||
}: {
|
||||
shouldThrow: boolean;
|
||||
includeBoundTextValidation: boolean;
|
||||
ignoreLogs?: true;
|
||||
reconciliationContext?: {
|
||||
localElements: ReadonlyArray<ExcalidrawElement>;
|
||||
remoteElements: ReadonlyArray<ExcalidrawElement>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const errorMessages = [];
|
||||
const stringifyElement = (element: ExcalidrawElement | void) =>
|
||||
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
|
||||
|
||||
const indices = elements.map((x) => x.index);
|
||||
for (const [i, index] of indices.entries()) {
|
||||
const predecessorIndex = indices[i - 1];
|
||||
const successorIndex = indices[i + 1];
|
||||
|
||||
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
|
||||
errorMessages.push(
|
||||
`Fractional indices invariant has been compromised: "${stringifyElement(
|
||||
elements[i - 1],
|
||||
)}", "${stringifyElement(elements[i])}", "${stringifyElement(
|
||||
elements[i + 1],
|
||||
)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// disabled by default, as we don't fix it
|
||||
if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
|
||||
const container = elements[i];
|
||||
const text = getBoundTextElement(container, arrayToMap(elements));
|
||||
|
||||
if (text && text.index! <= container.index!) {
|
||||
errorMessages.push(
|
||||
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
|
||||
text,
|
||||
)}", "${stringifyElement(container)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessages.length) {
|
||||
const error = new InvalidFractionalIndexError();
|
||||
const additionalContext = [];
|
||||
|
||||
if (reconciliationContext) {
|
||||
additionalContext.push("Additional reconciliation context:");
|
||||
additionalContext.push(
|
||||
reconciliationContext.localElements.map((x) => stringifyElement(x)),
|
||||
);
|
||||
additionalContext.push(
|
||||
reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
|
||||
);
|
||||
}
|
||||
|
||||
if (!ignoreLogs) {
|
||||
// report just once and with the stacktrace
|
||||
console.error(
|
||||
errorMessages.join("\n\n"),
|
||||
error.stack,
|
||||
elements.map((x) => stringifyElement(x)),
|
||||
...additionalContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldThrow) {
|
||||
// if enabled, gather all the errors first, throw once
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Order the elements based on the fractional indices.
|
||||
* - when fractional indices are identical, break the tie based on the element id
|
||||
* - when there is no fractional index in one of the elements, respect the order of the array
|
||||
*/
|
||||
export const orderByFractionalIndex = (
|
||||
elements: OrderedExcalidrawElement[],
|
||||
) => {
|
||||
return elements.sort((a, b) => {
|
||||
// in case the indices are not the defined at runtime
|
||||
if (isOrderedElement(a) && isOrderedElement(b)) {
|
||||
if (a.index < b.index) {
|
||||
return -1;
|
||||
} else if (a.index > b.index) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// break ties based on the element id
|
||||
return a.id < b.id ? -1 : 1;
|
||||
}
|
||||
|
||||
// defensively keep the array order
|
||||
return 1;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
|
||||
* If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
|
||||
*/
|
||||
export const syncMovedIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElements: ElementsMap,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
try {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
|
||||
|
||||
// try generatating indices, throws on invalid movedElements
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
const elementsCandidates = elements.map((x) => {
|
||||
const elementUpdates = elementsUpdates.get(x);
|
||||
|
||||
if (elementUpdates) {
|
||||
return { ...x, index: elementUpdates.index };
|
||||
}
|
||||
|
||||
return x;
|
||||
});
|
||||
|
||||
// ensure next indices are valid before mutation, throws on invalid ones
|
||||
validateFractionalIndices(
|
||||
elementsCandidates,
|
||||
// we don't autofix invalid bound text indices, hence don't include it in the validation
|
||||
{
|
||||
includeBoundTextValidation: false,
|
||||
shouldThrow: true,
|
||||
ignoreLogs: true,
|
||||
},
|
||||
);
|
||||
|
||||
// split mutation so we don't end up in an incosistent state
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback to default sync
|
||||
syncInvalidIndices(elements);
|
||||
}
|
||||
|
||||
return elements as OrderedExcalidrawElement[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
|
||||
*
|
||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||
*/
|
||||
export const syncInvalidIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): OrderedExcalidrawElement[] => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
}
|
||||
|
||||
return elements as OrderedExcalidrawElement[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
|
||||
*
|
||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||
*/
|
||||
export const syncInvalidIndicesImmutable = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): SceneElementsMap | undefined => {
|
||||
const syncedElements = arrayToMap(elements);
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
syncedElements.set(element.id, newElementWith(element, { index }));
|
||||
}
|
||||
|
||||
return syncedElements as SceneElementsMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get contiguous groups of indices of passed moved elements.
|
||||
*
|
||||
* NOTE: First and last elements within the groups are indices of lower and upper bounds.
|
||||
*/
|
||||
const getMovedIndicesGroups = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElements: ElementsMap,
|
||||
) => {
|
||||
const indicesGroups: number[][] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < elements.length) {
|
||||
if (movedElements.has(elements[i].id)) {
|
||||
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
||||
|
||||
while (++i < elements.length) {
|
||||
if (!movedElements.has(elements[i].id)) {
|
||||
break;
|
||||
}
|
||||
|
||||
indicesGroup.push(i);
|
||||
}
|
||||
|
||||
indicesGroup.push(i); // push the upper bound index as the last item
|
||||
indicesGroups.push(indicesGroup);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return indicesGroups;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets contiguous groups of all invalid indices automatically detected inside the elements array.
|
||||
*
|
||||
* WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
|
||||
*/
|
||||
const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => {
|
||||
const indicesGroups: number[][] = [];
|
||||
|
||||
// once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
|
||||
let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||
let upperBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||
let lowerBoundIndex: number = -1;
|
||||
let upperBoundIndex: number = 0;
|
||||
|
||||
/** @returns maybe valid lowerBound */
|
||||
const getLowerBound = (
|
||||
index: number,
|
||||
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||
const lowerBound = elements[lowerBoundIndex]
|
||||
? elements[lowerBoundIndex].index
|
||||
: undefined;
|
||||
|
||||
// we are already iterating left to right, therefore there is no need for additional looping
|
||||
const candidate = elements[index - 1]?.index;
|
||||
|
||||
if (
|
||||
(!lowerBound && candidate) || // first lowerBound
|
||||
(lowerBound && candidate && candidate > lowerBound) // next lowerBound
|
||||
) {
|
||||
// WARN: candidate's index could be higher or same as the current element's index
|
||||
return [candidate, index - 1];
|
||||
}
|
||||
|
||||
// cache hit! take the last lower bound
|
||||
return [lowerBound, lowerBoundIndex];
|
||||
};
|
||||
|
||||
/** @returns always valid upperBound */
|
||||
const getUpperBound = (
|
||||
index: number,
|
||||
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||
const upperBound = elements[upperBoundIndex]
|
||||
? elements[upperBoundIndex].index
|
||||
: undefined;
|
||||
|
||||
// cache hit! don't let it find the upper bound again
|
||||
if (upperBound && index < upperBoundIndex) {
|
||||
return [upperBound, upperBoundIndex];
|
||||
}
|
||||
|
||||
// set the current upperBoundIndex as the starting point
|
||||
let i = upperBoundIndex;
|
||||
while (++i < elements.length) {
|
||||
const candidate = elements[i]?.index;
|
||||
|
||||
if (
|
||||
(!upperBound && candidate) || // first upperBound
|
||||
(upperBound && candidate && candidate > upperBound) // next upperBound
|
||||
) {
|
||||
return [candidate, i];
|
||||
}
|
||||
}
|
||||
|
||||
// we reached the end, sky is the limit
|
||||
return [undefined, i];
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < elements.length) {
|
||||
const current = elements[i].index;
|
||||
[lowerBound, lowerBoundIndex] = getLowerBound(i);
|
||||
[upperBound, upperBoundIndex] = getUpperBound(i);
|
||||
|
||||
if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
|
||||
// push the lower bound index as the first item
|
||||
const indicesGroup = [lowerBoundIndex, i];
|
||||
|
||||
while (++i < elements.length) {
|
||||
const current = elements[i].index;
|
||||
const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
|
||||
const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
|
||||
|
||||
if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// assign bounds only for the moved elements
|
||||
[lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
|
||||
[upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
|
||||
|
||||
indicesGroup.push(i);
|
||||
}
|
||||
|
||||
// push the upper bound index as the last item
|
||||
indicesGroup.push(upperBoundIndex);
|
||||
indicesGroups.push(indicesGroup);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return indicesGroups;
|
||||
};
|
||||
|
||||
const isValidFractionalIndex = (
|
||||
index: ExcalidrawElement["index"] | undefined,
|
||||
predecessor: ExcalidrawElement["index"] | undefined,
|
||||
successor: ExcalidrawElement["index"] | undefined,
|
||||
) => {
|
||||
if (!index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (predecessor && successor) {
|
||||
return predecessor < index && index < successor;
|
||||
}
|
||||
|
||||
if (!predecessor && successor) {
|
||||
// first element
|
||||
return index < successor;
|
||||
}
|
||||
|
||||
if (predecessor && !successor) {
|
||||
// last element
|
||||
return predecessor < index;
|
||||
}
|
||||
|
||||
// only element in the array
|
||||
return !!index;
|
||||
};
|
||||
|
||||
const generateIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
indicesGroups: number[][],
|
||||
) => {
|
||||
const elementsUpdates = new Map<
|
||||
ExcalidrawElement,
|
||||
{ index: FractionalIndex }
|
||||
>();
|
||||
|
||||
for (const indices of indicesGroups) {
|
||||
const lowerBoundIndex = indices.shift()!;
|
||||
const upperBoundIndex = indices.pop()!;
|
||||
|
||||
const fractionalIndices = generateNKeysBetween(
|
||||
elements[lowerBoundIndex]?.index,
|
||||
elements[upperBoundIndex]?.index,
|
||||
indices.length,
|
||||
) as FractionalIndex[];
|
||||
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const element = elements[indices[i]];
|
||||
|
||||
elementsUpdates.set(element, {
|
||||
index: fractionalIndices[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return elementsUpdates;
|
||||
};
|
||||
|
||||
const isOrderedElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is OrderedExcalidrawElement => {
|
||||
// for now it's sufficient whether the index is there
|
||||
// meaning, the element was already ordered in the past
|
||||
// meaning, it is not a newly inserted element, not an unrestored element, etc.
|
||||
// it does not have to mean that the index itself is valid
|
||||
if (element.index) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
+278
-111
@@ -1,59 +1,64 @@
|
||||
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 {
|
||||
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,
|
||||
isTextElement,
|
||||
} from "./element";
|
||||
} from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { isPointWithinBounds } from "./math";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "./element/textElement";
|
||||
import { arrayToMap } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { 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 { ReadonlySetLike } from "./utility-types";
|
||||
} from "./types";
|
||||
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
nextElements: ExcalidrawElement[],
|
||||
oldElements: readonly ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
nextElements: readonly ExcalidrawElement[],
|
||||
origElements: readonly ExcalidrawElement[],
|
||||
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
const nextElementMap = arrayToMap(nextElements) as Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
|
||||
for (const element of oldElements) {
|
||||
for (const element of origElements) {
|
||||
if (element.frameId) {
|
||||
// use its frameId to get the new frameId
|
||||
const nextElementId = oldIdToDuplicatedId.get(element.id);
|
||||
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
|
||||
if (nextElementId) {
|
||||
const nextElement = nextElementMap.get(nextElementId);
|
||||
if (nextElement) {
|
||||
mutateElement(
|
||||
nextElement,
|
||||
{
|
||||
frameId: nextFrameId ?? element.frameId,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
const nextElementId = origIdToDuplicateId.get(element.id);
|
||||
const nextFrameId = origIdToDuplicateId.get(element.frameId);
|
||||
const nextElement = nextElementId && nextElementMap.get(nextElementId);
|
||||
if (nextElement) {
|
||||
mutateElement(nextElement, nextElementMap, {
|
||||
frameId: nextFrameId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,12 +96,11 @@ export const getElementsCompletelyInFrame = (
|
||||
);
|
||||
|
||||
export const isElementContainingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return getElementsWithinSelection(elements, element, elementsMap).some(
|
||||
return getElementsWithinSelection([frame], element, elementsMap).some(
|
||||
(e) => e.id === frame.id,
|
||||
);
|
||||
};
|
||||
@@ -140,7 +144,7 @@ export const elementOverlapsWithFrame = (
|
||||
return (
|
||||
elementsAreInFrameBounds([element], frame, elementsMap) ||
|
||||
isElementIntersectingFrame(element, frame, elementsMap) ||
|
||||
isElementContainingFrame([frame], element, frame, elementsMap)
|
||||
isElementContainingFrame(element, frame, elementsMap)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -155,9 +159,9 @@ export const isCursorInFrame = (
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||
|
||||
return isPointWithinBounds(
|
||||
[fx1, fy1],
|
||||
[cursorCoords.x, cursorCoords.y],
|
||||
[fx2, fy2],
|
||||
pointFrom(fx1, fy1),
|
||||
pointFrom(cursorCoords.x, cursorCoords.y),
|
||||
pointFrom(fx2, fy2),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -279,7 +283,7 @@ export const getElementsInResizingFrame = (
|
||||
const elementsCompletelyInFrame = new Set([
|
||||
...getElementsCompletelyInFrame(allElements, frame, elementsMap),
|
||||
...prevElementsInFrame.filter((element) =>
|
||||
isElementContainingFrame(allElements, element, frame, elementsMap),
|
||||
isElementContainingFrame(element, frame, elementsMap),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -366,12 +370,57 @@ export const getElementsInNewFrame = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return omitGroupsContainingFrameLikes(
|
||||
elements,
|
||||
getElementsCompletelyInFrame(elements, frame, elementsMap),
|
||||
return omitPartialGroups(
|
||||
omitGroupsContainingFrameLikes(
|
||||
elements,
|
||||
getElementsCompletelyInFrame(elements, frame, elementsMap),
|
||||
),
|
||||
frame,
|
||||
elementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
export const omitPartialGroups = (
|
||||
elements: ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
allElementsMap: ElementsMap,
|
||||
) => {
|
||||
const elementsToReturn = [];
|
||||
const checkedGroups = new Map<string, boolean>();
|
||||
|
||||
for (const element of elements) {
|
||||
let shouldOmit = false;
|
||||
if (element.groupIds.length > 0) {
|
||||
// if some partial group should be omitted, then all elements in that group should be omitted
|
||||
if (element.groupIds.some((gid) => checkedGroups.get(gid))) {
|
||||
shouldOmit = true;
|
||||
} else {
|
||||
const allElementsInGroup = new Set(
|
||||
element.groupIds.flatMap((gid) =>
|
||||
getElementsInGroup(allElementsMap, gid),
|
||||
),
|
||||
);
|
||||
|
||||
shouldOmit = !elementsAreInFrameBounds(
|
||||
Array.from(allElementsInGroup),
|
||||
frame,
|
||||
allElementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
element.groupIds.forEach((gid) => {
|
||||
checkedGroups.set(gid, shouldOmit);
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldOmit) {
|
||||
elementsToReturn.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
return elementsToReturn;
|
||||
};
|
||||
|
||||
export const getContainingFrame = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
@@ -450,6 +499,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
allElements: T,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
): T => {
|
||||
const elementsMap = arrayToMap(allElements);
|
||||
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
||||
@@ -485,6 +535,17 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the element is already in another frame (which is also in elementsToAdd),
|
||||
// it means that frame and children are selected at the same time
|
||||
// => keep original frame membership, do not add to the target frame
|
||||
if (
|
||||
element.frameId &&
|
||||
appState.selectedElementIds[element.id] &&
|
||||
appState.selectedElementIds[element.frameId]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currTargetFrameChildrenMap.has(element.id)) {
|
||||
finalElementsToAdd.push(element);
|
||||
}
|
||||
@@ -500,13 +561,9 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
}
|
||||
|
||||
for (const element of finalElementsToAdd) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
mutateElement(element, elementsMap, {
|
||||
frameId: frame.id,
|
||||
});
|
||||
}
|
||||
|
||||
return allElements;
|
||||
@@ -544,13 +601,9 @@ export const removeElementsFromFrame = (
|
||||
}
|
||||
|
||||
for (const [, element] of _elementsToRemove) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
mutateElement(element, elementsMap, {
|
||||
frameId: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -573,6 +626,7 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
|
||||
removeAllElementsFromFrame(allElements, frame),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
app.state,
|
||||
).slice();
|
||||
};
|
||||
|
||||
@@ -679,6 +733,16 @@ export const getTargetFrame = (
|
||||
? getContainerElement(element, elementsMap) || element
|
||||
: element;
|
||||
|
||||
// if the element and its containing frame are both selected, then
|
||||
// the containing frame is the target frame
|
||||
if (
|
||||
_element.frameId &&
|
||||
appState.selectedElementIds[_element.id] &&
|
||||
appState.selectedElementIds[_element.frameId]
|
||||
) {
|
||||
return getContainingFrame(_element, elementsMap);
|
||||
}
|
||||
|
||||
return appState.selectedElementIds[_element.id] &&
|
||||
appState.selectedElementsAreBeingDragged
|
||||
? appState.frameToHighlight
|
||||
@@ -691,76 +755,166 @@ export const isElementInFrame = (
|
||||
element: ExcalidrawElement,
|
||||
allElementsMap: ElementsMap,
|
||||
appState: StaticCanvasAppState,
|
||||
opts?: {
|
||||
targetFrame?: ExcalidrawFrameLikeElement;
|
||||
checkedGroups?: Map<string, boolean>;
|
||||
},
|
||||
) => {
|
||||
const frame = getTargetFrame(element, allElementsMap, appState);
|
||||
const frame =
|
||||
opts?.targetFrame ?? getTargetFrame(element, allElementsMap, appState);
|
||||
|
||||
if (!frame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const _element = isTextElement(element)
|
||||
? getContainerElement(element, allElementsMap) || element
|
||||
: element;
|
||||
|
||||
if (frame) {
|
||||
// Perf improvement:
|
||||
// For an element that's already in a frame, if it's not being dragged
|
||||
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
|
||||
// It has to be in its containing frame.
|
||||
if (
|
||||
!appState.selectedElementIds[element.id] ||
|
||||
!appState.selectedElementsAreBeingDragged
|
||||
) {
|
||||
const setGroupsInFrame = (isInFrame: boolean) => {
|
||||
if (opts?.checkedGroups) {
|
||||
_element.groupIds.forEach((groupId) => {
|
||||
opts.checkedGroups?.set(groupId, isInFrame);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
// if the element is not selected, or it is selected but not being dragged,
|
||||
// frame membership won't update, so return true
|
||||
!appState.selectedElementIds[_element.id] ||
|
||||
!appState.selectedElementsAreBeingDragged ||
|
||||
// if both frame and element are selected, won't update membership, so return true
|
||||
(appState.selectedElementIds[_element.id] &&
|
||||
appState.selectedElementIds[frame.id])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame, allElementsMap);
|
||||
}
|
||||
|
||||
for (const gid of _element.groupIds) {
|
||||
if (opts?.checkedGroups?.has(gid)) {
|
||||
return opts.checkedGroups.get(gid)!!;
|
||||
}
|
||||
}
|
||||
|
||||
const allElementsInGroup = new Set(
|
||||
_element.groupIds
|
||||
.filter((gid) => {
|
||||
if (opts?.checkedGroups) {
|
||||
return !opts.checkedGroups.has(gid);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.flatMap((gid) => getElementsInGroup(allElementsMap, gid)),
|
||||
);
|
||||
|
||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||
const selectedElements = new Set(
|
||||
getSelectedElements(allElementsMap, appState),
|
||||
);
|
||||
|
||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||
|
||||
if (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame, allElementsMap);
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
});
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameLikeElement(elementInGroup)) {
|
||||
setGroupsInFrame(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const allElementsInGroup = new Set(
|
||||
_element.groupIds.flatMap((gid) =>
|
||||
getElementsInGroup(allElementsMap, gid),
|
||||
),
|
||||
);
|
||||
|
||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||
const selectedElements = new Set(
|
||||
getSelectedElements(allElementsMap, appState),
|
||||
);
|
||||
|
||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||
|
||||
if (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
});
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameLikeElement(elementInGroup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
|
||||
return true;
|
||||
}
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
|
||||
setGroupsInFrame(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getFrameLikeTitle = (
|
||||
element: ExcalidrawFrameLikeElement,
|
||||
frameIdx: number,
|
||||
export const shouldApplyFrameClip = (
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: StaticCanvasAppState,
|
||||
elementsMap: ElementsMap,
|
||||
checkedGroups?: Map<string, boolean>,
|
||||
) => {
|
||||
if (!appState.frameRendering || !appState.frameRendering.clip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// for individual elements, only clip when the element is
|
||||
// a. overlapping with the frame, or
|
||||
// b. containing the frame, for example when an element is used as a background
|
||||
// and is therefore bigger than the frame and completely contains the frame
|
||||
const shouldClipElementItself =
|
||||
isElementIntersectingFrame(element, frame, elementsMap) ||
|
||||
isElementContainingFrame(element, frame, elementsMap);
|
||||
|
||||
if (shouldClipElementItself) {
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// if an element is outside the frame, but is part of a group that has some elements
|
||||
// "in" the frame, we should clip the element
|
||||
if (
|
||||
!shouldClipElementItself &&
|
||||
element.groupIds.length > 0 &&
|
||||
!elementsAreInFrameBounds([element], frame, elementsMap)
|
||||
) {
|
||||
let shouldClip = false;
|
||||
|
||||
// if no elements are being dragged, we can skip the geometry check
|
||||
// because we know if the element is in the given frame or not
|
||||
if (!appState.selectedElementsAreBeingDragged) {
|
||||
shouldClip = element.frameId === frame.id;
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, shouldClip);
|
||||
}
|
||||
} else {
|
||||
shouldClip = isElementInFrame(element, elementsMap, appState, {
|
||||
targetFrame: frame,
|
||||
checkedGroups,
|
||||
});
|
||||
}
|
||||
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, shouldClip);
|
||||
}
|
||||
|
||||
return shouldClip;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const DEFAULT_FRAME_NAME = "Frame";
|
||||
const DEFAULT_AI_FRAME_NAME = "AI Frame";
|
||||
|
||||
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
|
||||
// TODO name frames "AI" only if specific to AI frames
|
||||
return element.name === null
|
||||
? isFrameElement(element)
|
||||
? `Frame ${frameIdx}`
|
||||
: `AI Frame $${frameIdx}`
|
||||
: element.name;
|
||||
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
|
||||
};
|
||||
|
||||
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||
return element.name === null ? getDefaultFrameName(element) : element.name;
|
||||
};
|
||||
|
||||
export const getElementsOverlappingFrame = (
|
||||
@@ -778,3 +932,16 @@ export const getElementsOverlappingFrame = (
|
||||
.filter((el) => !el.frameId || el.frameId === frame.id)
|
||||
);
|
||||
};
|
||||
|
||||
export const frameAndChildrenSelectedTogether = (
|
||||
selectedElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
selectedElements.some(
|
||||
(element) => element.frameId && selectedElementsMap.has(element.frameId),
|
||||
)
|
||||
);
|
||||
};
|
||||
+63
-30
@@ -1,20 +1,22 @@
|
||||
import {
|
||||
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,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
ElementsMapOrArray,
|
||||
ElementsMap,
|
||||
} from "./element/types";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
} from "./types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
import { makeNextSelectedElementIds } from "./scene/selection";
|
||||
import { Mutable } from "./utility-types";
|
||||
|
||||
export const selectGroup = (
|
||||
groupId: GroupId,
|
||||
@@ -105,6 +107,10 @@ export const selectGroupsForSelectedElements = (function () {
|
||||
const groupElementsIndex: Record<GroupId, string[]> = {};
|
||||
const selectedElementIdsInGroups = elements.reduce(
|
||||
(acc: Record<string, true>, element) => {
|
||||
if (element.isDeleted) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
|
||||
|
||||
if (groupId) {
|
||||
@@ -209,7 +215,10 @@ export const isSelectedViaGroup = (
|
||||
) => getSelectedGroupForElement(appState, element) != null;
|
||||
|
||||
export const getSelectedGroupForElement = (
|
||||
appState: InteractiveCanvasAppState,
|
||||
appState: Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"editingGroupId" | "selectedGroupIds"
|
||||
>,
|
||||
element: ExcalidrawElement,
|
||||
) =>
|
||||
element.groupIds
|
||||
@@ -289,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,
|
||||
@@ -355,7 +346,27 @@ export const getMaximumGroups = (
|
||||
return Array.from(groups.values());
|
||||
};
|
||||
|
||||
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
||||
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
|
||||
const nonDeletedGroupIds = new Set<string>();
|
||||
|
||||
for (const [, element] of elements) {
|
||||
// defensive check
|
||||
if (element.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// defensive fallback
|
||||
for (const groupId of element.groupIds ?? []) {
|
||||
nonDeletedGroupIds.add(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
return nonDeletedGroupIds;
|
||||
};
|
||||
|
||||
export const elementsAreInSameGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const allGroups = elements.flatMap((element) => element.groupIds);
|
||||
const groupCount = new Map<string, number>();
|
||||
let maxGroup = 0;
|
||||
@@ -369,3 +380,25 @@ export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
||||
|
||||
return maxGroup === elements.length;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,282 @@
|
||||
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
pointsEqual,
|
||||
triangleIncludesPoint,
|
||||
vectorCross,
|
||||
vectorFromPoint,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
LocalPoint,
|
||||
GlobalPoint,
|
||||
Triangle,
|
||||
Vector,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
|
||||
export const HEADING_RIGHT = [1, 0] as Heading;
|
||||
export const HEADING_DOWN = [0, 1] as Heading;
|
||||
export const HEADING_LEFT = [-1, 0] as Heading;
|
||||
export const HEADING_UP = [0, -1] as Heading;
|
||||
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
|
||||
|
||||
export const vectorToHeading = (vec: Vector): Heading => {
|
||||
const [x, y] = vec;
|
||||
const absX = Math.abs(x);
|
||||
const absY = Math.abs(y);
|
||||
if (x > absY) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (x <= -absY) {
|
||||
return HEADING_LEFT;
|
||||
} else if (y > absX) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
return HEADING_UP;
|
||||
};
|
||||
|
||||
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
o: P,
|
||||
) => vectorToHeading(vectorFromPoint<P>(p, o));
|
||||
|
||||
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
o: P,
|
||||
) => headingIsHorizontal(headingForPoint<P>(p, o));
|
||||
|
||||
export const compareHeading = (a: Heading, b: Heading) =>
|
||||
a[0] === b[0] && a[1] === b[1];
|
||||
|
||||
export const headingIsHorizontal = (a: Heading) =>
|
||||
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
|
||||
|
||||
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
||||
|
||||
const headingForPointFromDiamondElement = (
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
point: Readonly<GlobalPoint>,
|
||||
): Heading => {
|
||||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (isDevEnv() || isTestEnv()) {
|
||||
invariant(
|
||||
element.width > 0 && element.height > 0,
|
||||
"Diamond element has no width or height",
|
||||
);
|
||||
invariant(
|
||||
!pointsEqual(midPoint, point),
|
||||
"The point is too close to the element mid point to determine heading",
|
||||
);
|
||||
}
|
||||
|
||||
const SHRINK = 0.95; // Rounded elements tolerance
|
||||
const top = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const right = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const bottom = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const left = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
|
||||
// Corners
|
||||
if (
|
||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
|
||||
0 &&
|
||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
|
||||
) {
|
||||
return headingForPoint(top, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, right),
|
||||
vectorFromPoint(right, bottom),
|
||||
) <= 0 &&
|
||||
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
|
||||
) {
|
||||
return headingForPoint(right, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, bottom),
|
||||
vectorFromPoint(bottom, left),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, bottom),
|
||||
vectorFromPoint(bottom, right),
|
||||
) > 0
|
||||
) {
|
||||
return headingForPoint(bottom, midPoint);
|
||||
} else if (
|
||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
|
||||
0 &&
|
||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
|
||||
) {
|
||||
return headingForPoint(left, midPoint);
|
||||
}
|
||||
|
||||
// Sides
|
||||
if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(top, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(right, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? top : right;
|
||||
return headingForPoint(p, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(right, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(bottom, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? bottom : right;
|
||||
return headingForPoint(p, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(bottom, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(left, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? bottom : left;
|
||||
return headingForPoint(p, midPoint);
|
||||
}
|
||||
|
||||
const p = element.width > element.height ? top : left;
|
||||
return headingForPoint(p, midPoint);
|
||||
};
|
||||
|
||||
// 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>(
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
p: Readonly<Point>,
|
||||
): Heading => {
|
||||
const SEARCH_CONE_MULTIPLIER = 2;
|
||||
|
||||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return headingForPointFromDiamondElement(element, aabb, p);
|
||||
}
|
||||
|
||||
const topLeft = pointScaleFromOrigin(
|
||||
pointFrom(aabb[0], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const topRight = pointScaleFromOrigin(
|
||||
pointFrom(aabb[2], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomLeft = pointScaleFromOrigin(
|
||||
pointFrom(aabb[0], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomRight = pointScaleFromOrigin(
|
||||
pointFrom(aabb[2], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
|
||||
return triangleIncludesPoint<Point>(
|
||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_UP
|
||||
: triangleIncludesPoint<Point>(
|
||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_RIGHT
|
||||
: triangleIncludesPoint<Point>(
|
||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_DOWN
|
||||
: HEADING_LEFT;
|
||||
};
|
||||
|
||||
export const flipHeading = (h: Heading): Heading =>
|
||||
[
|
||||
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
|
||||
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
|
||||
] as Heading;
|
||||
+38
-9
@@ -2,10 +2,17 @@
|
||||
// ExcalidrawImageElement & related helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { MIME_TYPES, SVG_NS } from "../constants";
|
||||
import { 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 {
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
@@ -94,7 +101,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
|
||||
return node?.nodeName.toLowerCase() === "svg";
|
||||
};
|
||||
|
||||
export const normalizeSVG = async (SVGString: string) => {
|
||||
export const normalizeSVG = (SVGString: string) => {
|
||||
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
|
||||
const svg = doc.querySelector("svg");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
@@ -105,20 +112,42 @@ export const normalizeSVG = async (SVGString: string) => {
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
}
|
||||
|
||||
if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
|
||||
const viewBox = svg.getAttribute("viewBox");
|
||||
let width = svg.getAttribute("width") || "50";
|
||||
let height = svg.getAttribute("height") || "50";
|
||||
let width = svg.getAttribute("width");
|
||||
let height = svg.getAttribute("height");
|
||||
|
||||
// Do not use % or auto values for width/height
|
||||
// to avoid scaling issues when rendering at different sizes/zoom levels
|
||||
if (width?.includes("%") || width === "auto") {
|
||||
width = null;
|
||||
}
|
||||
if (height?.includes("%") || height === "auto") {
|
||||
height = null;
|
||||
}
|
||||
|
||||
const viewBox = svg.getAttribute("viewBox");
|
||||
|
||||
if (!width || !height) {
|
||||
width = width || "50";
|
||||
height = height || "50";
|
||||
|
||||
if (viewBox) {
|
||||
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
|
||||
const match = viewBox.match(
|
||||
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
|
||||
);
|
||||
if (match) {
|
||||
[, width, height] = match;
|
||||
}
|
||||
}
|
||||
|
||||
svg.setAttribute("width", width);
|
||||
svg.setAttribute("height", height);
|
||||
}
|
||||
|
||||
// Make sure viewBox is set
|
||||
if (!viewBox) {
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
}
|
||||
|
||||
return svg.outerHTML;
|
||||
}
|
||||
};
|
||||
+53
-63
@@ -1,64 +1,14 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
import { toIterable } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
export {
|
||||
newElement,
|
||||
newTextElement,
|
||||
updateTextElement,
|
||||
refreshTextDimensions,
|
||||
newLinearElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
export {
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
getCommonBounds,
|
||||
getDiamondPoints,
|
||||
getArrowheadPoints,
|
||||
getClosestElementBounds,
|
||||
} from "./bounds";
|
||||
|
||||
export {
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
} from "./transformHandles";
|
||||
export {
|
||||
hitTest,
|
||||
isHittingElementBoundingBoxWithoutHittingElement,
|
||||
} from "./collision";
|
||||
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 } from "./textElement";
|
||||
export {
|
||||
getPerfectElementSize,
|
||||
getLockedLinearCursorAlignSize,
|
||||
isInvisiblySmallElement,
|
||||
resizePerfectLineForNWHandler,
|
||||
getNormalizedDimensions,
|
||||
} from "./sizeHelpers";
|
||||
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ElementsMapOrArray,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* @deprecated unsafe, use hashElementsVersion instead
|
||||
@@ -69,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||
/**
|
||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||
*/
|
||||
export const hashElementsVersion = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): number => {
|
||||
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
||||
for (const element of toIterable(elements)) {
|
||||
hash = (hash << 5) + hash + element.versionNonce;
|
||||
}
|
||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||
};
|
||||
@@ -124,3 +72,45 @@ export const clearElementsForExport = (
|
||||
export const clearElementsForLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
export * from "./collision";
|
||||
export * from "./comparisons";
|
||||
export * from "./containerCache";
|
||||
export * from "./cropElement";
|
||||
export * from "./delta";
|
||||
export * from "./distance";
|
||||
export * from "./distribute";
|
||||
export * from "./dragElements";
|
||||
export * from "./duplicate";
|
||||
export * from "./elbowArrow";
|
||||
export * from "./elementLink";
|
||||
export * from "./embeddable";
|
||||
export * from "./flowchart";
|
||||
export * from "./fractionalIndex";
|
||||
export * from "./frame";
|
||||
export * from "./groups";
|
||||
export * from "./heading";
|
||||
export * from "./image";
|
||||
export * from "./linearElementEditor";
|
||||
export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
export * from "./renderElement";
|
||||
export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./shape";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
export * from "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
+898
-482
File diff suppressed because it is too large
Load Diff
+72
-29
@@ -1,33 +1,77 @@
|
||||
import { ExcalidrawElement } from "./types";
|
||||
import Scene from "../scene/Scene";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { Point } from "../types";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import {
|
||||
getSizeFromPoints,
|
||||
randomInteger,
|
||||
getUpdatedTimestamp,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import { isElbowArrow } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "version" | "versionNonce"
|
||||
"id" | "updated"
|
||||
>;
|
||||
|
||||
// This function tracks updates of text elements for the purposes for collaboration.
|
||||
// The version is used to compare updates when more than one user is working in
|
||||
// the same drawing. Note: this will trigger the component to update. Make sure you
|
||||
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
||||
/**
|
||||
* This function tracks updates of text elements for the purposes for collaboration.
|
||||
* The version is used to compare updates when more than one user is working in
|
||||
* the same drawing.
|
||||
*
|
||||
* WARNING: this won't trigger the component to update, so if you need to trigger component update,
|
||||
* use `scene.mutateElement` or `ExcalidrawImperativeAPI.mutateElement` instead.
|
||||
*/
|
||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
elementsMap: ElementsMap,
|
||||
updates: ElementUpdate<TElement>,
|
||||
informMutation = true,
|
||||
): TElement => {
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
},
|
||||
) => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fileId } = updates as any;
|
||||
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
||||
updates as any;
|
||||
|
||||
if (typeof points !== "undefined") {
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
(Object.keys(updates).length === 0 || // normalization case
|
||||
typeof points !== "undefined" || // repositioning
|
||||
typeof fixedSegments !== "undefined" || // segment fixing
|
||||
typeof startBinding !== "undefined" ||
|
||||
typeof endBinding !== "undefined") // manual binding to element
|
||||
) {
|
||||
updates = {
|
||||
...updates,
|
||||
angle: 0 as Radians,
|
||||
...updateElbowArrowPoints(
|
||||
{
|
||||
...element,
|
||||
x: updates.x || element.x,
|
||||
y: updates.y || element.y,
|
||||
},
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
|
||||
options,
|
||||
),
|
||||
};
|
||||
} else if (typeof points !== "undefined") {
|
||||
updates = { ...getSizeFromPoints(points), ...updates };
|
||||
}
|
||||
|
||||
@@ -59,8 +103,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
let didChangePoints = false;
|
||||
let index = prevPoints.length;
|
||||
while (--index) {
|
||||
const prevPoint: Point = prevPoints[index];
|
||||
const nextPoint: Point = nextPoints[index];
|
||||
const prevPoint = prevPoints[index];
|
||||
const nextPoint = nextPoints[index];
|
||||
if (
|
||||
prevPoint[0] !== nextPoint[0] ||
|
||||
prevPoint[1] !== nextPoint[1]
|
||||
@@ -79,6 +123,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
return element;
|
||||
}
|
||||
@@ -92,20 +137,18 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
element.version = updates.version ?? element.version + 1;
|
||||
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.informMutation();
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
/** pass `true` to always regenerate */
|
||||
force = false,
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
for (const key in updates) {
|
||||
@@ -122,16 +165,16 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
if (!didChange && !force) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
...updates,
|
||||
version: updates.version ?? element.version + 1,
|
||||
versionNonce: updates.versionNonce ?? randomInteger(),
|
||||
updated: getUpdatedTimestamp(),
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
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 { isLineElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawIframeElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "frameId"
|
||||
| "index"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
| "link"
|
||||
| "strokeStyle"
|
||||
| "fillStyle"
|
||||
| "strokeColor"
|
||||
| "backgroundColor"
|
||||
| "roughness"
|
||||
| "strokeWidth"
|
||||
| "roundness"
|
||||
| "locked"
|
||||
| "opacity"
|
||||
| "customData"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
type: T["type"],
|
||||
{
|
||||
x,
|
||||
y,
|
||||
strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
roughness = DEFAULT_ELEMENT_PROPS.roughness,
|
||||
opacity = DEFAULT_ELEMENT_PROPS.opacity,
|
||||
width = 0,
|
||||
height = 0,
|
||||
angle = 0 as Radians,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
index = null,
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
locked = DEFAULT_ELEMENT_PROPS.locked,
|
||||
...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(),
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
angle,
|
||||
strokeColor,
|
||||
backgroundColor,
|
||||
fillStyle,
|
||||
strokeWidth,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
opacity,
|
||||
groupIds,
|
||||
frameId,
|
||||
index,
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
link,
|
||||
locked,
|
||||
customData: rest.customData,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
export const newElement = (
|
||||
opts: {
|
||||
type: ExcalidrawGenericElement["type"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
|
||||
};
|
||||
|
||||
export const newIframeElement = (
|
||||
opts: {
|
||||
type: "iframe";
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawIframeElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
|
||||
};
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: {
|
||||
name?: string;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
||||
type: "frame",
|
||||
name: opts?.name || null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
export const newMagicFrameElement = (
|
||||
opts: {
|
||||
name?: string;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawMagicFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
|
||||
type: "magicframe",
|
||||
name: opts?.name || null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
textAlign: ExcalidrawTextElement["textAlign"];
|
||||
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
||||
},
|
||||
metrics: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) => {
|
||||
return {
|
||||
x:
|
||||
opts.textAlign === "center"
|
||||
? metrics.width / 2
|
||||
: opts.textAlign === "right"
|
||||
? metrics.width
|
||||
: 0,
|
||||
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
originalText?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(
|
||||
text,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
||||
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
||||
const offsets = getTextElementPositionOffsets(
|
||||
{ textAlign, verticalAlign },
|
||||
metrics,
|
||||
);
|
||||
|
||||
const textElementProps: ExcalidrawTextElement = {
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
x: opts.x - offsets.x,
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: opts.originalText ?? text,
|
||||
autoResize: opts.autoResize ?? true,
|
||||
lineHeight,
|
||||
};
|
||||
|
||||
const textElement: ExcalidrawTextElement = newElementWith(
|
||||
textElementProps,
|
||||
{},
|
||||
);
|
||||
|
||||
return textElement;
|
||||
};
|
||||
|
||||
const getAdjustedDimensions = (
|
||||
element: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
nextText: string,
|
||||
): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} => {
|
||||
let { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
// wrapped text
|
||||
if (!element.autoResize) {
|
||||
nextWidth = element.width;
|
||||
}
|
||||
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (
|
||||
textAlign === "center" &&
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId &&
|
||||
element.autoResize
|
||||
) {
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
});
|
||||
|
||||
x = element.x - offsets.x;
|
||||
y = element.y - offsets.y;
|
||||
} else {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
|
||||
[x, y] = adjustXYWithRotation(
|
||||
{
|
||||
s: true,
|
||||
e: textAlign === "center" || textAlign === "left",
|
||||
w: textAlign === "center" || textAlign === "right",
|
||||
},
|
||||
element.x,
|
||||
element.y,
|
||||
element.angle,
|
||||
deltaX1,
|
||||
deltaY1,
|
||||
deltaX2,
|
||||
deltaY2,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
};
|
||||
};
|
||||
|
||||
const adjustXYWithRotation = (
|
||||
sides: {
|
||||
n?: boolean;
|
||||
e?: boolean;
|
||||
s?: boolean;
|
||||
w?: boolean;
|
||||
},
|
||||
x: number,
|
||||
y: number,
|
||||
angle: number,
|
||||
deltaX1: number,
|
||||
deltaY1: number,
|
||||
deltaX2: number,
|
||||
deltaY2: number,
|
||||
): [number, number] => {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
if (sides.e && sides.w) {
|
||||
x += deltaX1 + deltaX2;
|
||||
} else if (sides.e) {
|
||||
x += deltaX1 * (1 + cos);
|
||||
y += deltaX1 * sin;
|
||||
x += deltaX2 * (1 - cos);
|
||||
y += deltaX2 * -sin;
|
||||
} else if (sides.w) {
|
||||
x += deltaX1 * (1 - cos);
|
||||
y += deltaX1 * -sin;
|
||||
x += deltaX2 * (1 + cos);
|
||||
y += deltaX2 * sin;
|
||||
}
|
||||
|
||||
if (sides.n && sides.s) {
|
||||
y += deltaY1 + deltaY2;
|
||||
} else if (sides.n) {
|
||||
x += deltaY1 * sin;
|
||||
y += deltaY1 * (1 - cos);
|
||||
x += deltaY2 * -sin;
|
||||
y += deltaY2 * (1 + cos);
|
||||
} else if (sides.s) {
|
||||
x += deltaY1 * -sin;
|
||||
y += deltaY1 * (1 + cos);
|
||||
x += deltaY2 * sin;
|
||||
y += deltaY2 * (1 - cos);
|
||||
}
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
export const refreshTextDimensions = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawTextContainer | null,
|
||||
elementsMap: ElementsMap,
|
||||
text = textElement.text,
|
||||
) => {
|
||||
if (textElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
if (container || !textElement.autoResize) {
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
container
|
||||
? getBoundTextMaxWidth(container, textElement)
|
||||
: textElement.width,
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
|
||||
return { text, ...dimensions };
|
||||
};
|
||||
|
||||
export const newFreeDrawElement = (
|
||||
opts: {
|
||||
type: "freedraw";
|
||||
points?: ExcalidrawFreeDrawElement["points"];
|
||||
simulatePressure: boolean;
|
||||
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
lastCommittedPoint: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
polygon?: ExcalidrawLineElement["polygon"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
const element = {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
};
|
||||
|
||||
if (isLineElement(element)) {
|
||||
const lineElement: NonDeleted<ExcalidrawLineElement> = {
|
||||
...element,
|
||||
polygon: opts.polygon ?? false,
|
||||
};
|
||||
|
||||
return lineElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const newArrowElement = <T extends boolean>(
|
||||
opts: {
|
||||
type: ExcalidrawArrowElement["type"];
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawArrowElement["points"];
|
||||
elbowed?: T;
|
||||
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
|
||||
} & ElementConstructorOpts,
|
||||
): T extends true
|
||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||
: NonDeleted<ExcalidrawArrowElement> => {
|
||||
if (opts.elbowed) {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
elbowed: true,
|
||||
fixedSegments: opts.fixedSegments || [],
|
||||
startIsSpecial: false,
|
||||
endIsSpecial: false,
|
||||
} as NonDeleted<ExcalidrawElbowArrowElement>;
|
||||
}
|
||||
|
||||
return {
|
||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
elbowed: false,
|
||||
} as T extends true
|
||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||
: NonDeleted<ExcalidrawArrowElement>;
|
||||
};
|
||||
|
||||
export const newImageElement = (
|
||||
opts: {
|
||||
type: ExcalidrawImageElement["type"];
|
||||
status?: ExcalidrawImageElement["status"];
|
||||
fileId?: ExcalidrawImageElement["fileId"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
crop?: ExcalidrawImageElement["crop"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawImageElement>("image", opts),
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
// and `transparent` will likely mean "use original colors of the image"
|
||||
strokeColor: "transparent",
|
||||
status: opts.status ?? "pending",
|
||||
fileId: opts.fileId ?? null,
|
||||
scale: opts.scale ?? [1, 1],
|
||||
crop: opts.crop ?? null,
|
||||
};
|
||||
};
|
||||
+267
-145
@@ -1,4 +1,64 @@
|
||||
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 "./utils";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -8,55 +68,11 @@ import {
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
ElementsMap,
|
||||
} from "../element/types";
|
||||
import {
|
||||
isTextElement,
|
||||
isLinearElement,
|
||||
isFreeDrawElement,
|
||||
isInitializedImageElement,
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||
} from "./types";
|
||||
|
||||
import type { StrokeOptions } from "perfect-freehand";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
import {
|
||||
StaticCanvasRenderConfig,
|
||||
RenderableElementsMap,
|
||||
} from "../scene/types";
|
||||
import { distance, getFontString, isRTL } from "../utils";
|
||||
import { getCornerRadius, isRightAngle } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import {
|
||||
AppState,
|
||||
StaticCanvasAppState,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
} from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||
FRAME_STYLE,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getLineHeightInPx,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
getVerticalOffset,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
import { getContainingFrame } from "../frame";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
// 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
|
||||
// color scheme (it's still not quite there and the colors look slightly
|
||||
@@ -64,8 +80,6 @@ import { ShapeCache } from "../scene/ShapeCache";
|
||||
export const IMAGE_INVERT_FILTER =
|
||||
"invert(100%) hue-rotate(180deg) saturate(1.25)";
|
||||
|
||||
const defaultAppState = getDefaultAppState();
|
||||
|
||||
const isPendingImageElement = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
@@ -79,29 +93,42 @@ const shouldResetImageFilter = (
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
return (
|
||||
appState.theme === "dark" &&
|
||||
appState.theme === THEME.DARK &&
|
||||
isInitializedImageElement(element) &&
|
||||
!isPendingImageElement(element, renderConfig) &&
|
||||
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
||||
);
|
||||
};
|
||||
|
||||
const getCanvasPadding = (element: ExcalidrawElement) =>
|
||||
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
|
||||
const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
switch (element.type) {
|
||||
case "freedraw":
|
||||
return element.strokeWidth * 12;
|
||||
case "text":
|
||||
return element.fontSize / 2;
|
||||
default:
|
||||
return 20;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRenderOpacity = (
|
||||
element: ExcalidrawElement,
|
||||
containingFrame: ExcalidrawFrameLikeElement | null,
|
||||
elementsPendingErasure: ElementsPendingErasure,
|
||||
pendingNodes: Readonly<PendingExcalidrawElements> | null,
|
||||
globalAlpha: number = 1,
|
||||
) => {
|
||||
// multiplying frame opacity with element opacity to combine them
|
||||
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
||||
let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000;
|
||||
let opacity =
|
||||
(((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
|
||||
globalAlpha;
|
||||
|
||||
// if pending erasure, multiply again to combine further
|
||||
// (so that erasing always results in lower opacity than original)
|
||||
if (
|
||||
elementsPendingErasure.has(element.id) ||
|
||||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
|
||||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
||||
) {
|
||||
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
||||
@@ -115,11 +142,14 @@ export interface ExcalidrawElementWithCanvas {
|
||||
canvas: HTMLCanvasElement;
|
||||
theme: AppState["theme"];
|
||||
scale: number;
|
||||
angle: number;
|
||||
zoomValue: AppState["zoom"]["value"];
|
||||
canvasOffsetX: number;
|
||||
canvasOffsetY: number;
|
||||
boundTextElementVersion: number | null;
|
||||
imageCrop: ExcalidrawImageElement["crop"] | null;
|
||||
containingFrameOpacity: number;
|
||||
boundTextCanvas: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
const cappedElementCanvasSize = (
|
||||
@@ -179,11 +209,11 @@ const cappedElementCanvasSize = (
|
||||
|
||||
const generateElementCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: RenderableElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom: Zoom,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
): ExcalidrawElementWithCanvas => {
|
||||
): ExcalidrawElementWithCanvas | null => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
const padding = getCanvasPadding(element);
|
||||
@@ -194,10 +224,14 @@ const generateElementCanvas = (
|
||||
zoom,
|
||||
);
|
||||
|
||||
if (!width || !height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
let canvasOffsetX = 0;
|
||||
let canvasOffsetX = -100;
|
||||
let canvasOffsetY = 0;
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
@@ -231,8 +265,72 @@ const generateElementCanvas = (
|
||||
}
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
|
||||
context.restore();
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
const boundTextCanvas = document.createElement("canvas");
|
||||
const boundTextCanvasContext = boundTextCanvas.getContext("2d")!;
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
// Take max dimensions of arrow canvas so that when canvas is rotated
|
||||
// the arrow doesn't get clipped
|
||||
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
|
||||
boundTextCanvas.width =
|
||||
maxDim * window.devicePixelRatio * scale + padding * scale * 10;
|
||||
boundTextCanvas.height =
|
||||
maxDim * window.devicePixelRatio * scale + padding * scale * 10;
|
||||
boundTextCanvasContext.translate(
|
||||
boundTextCanvas.width / 2,
|
||||
boundTextCanvas.height / 2,
|
||||
);
|
||||
boundTextCanvasContext.rotate(element.angle);
|
||||
boundTextCanvasContext.drawImage(
|
||||
canvas!,
|
||||
-canvas.width / 2,
|
||||
-canvas.height / 2,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
|
||||
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
boundTextCanvasContext.rotate(-element.angle);
|
||||
const offsetX = (boundTextCanvas.width - canvas!.width) / 2;
|
||||
const offsetY = (boundTextCanvas.height - canvas!.height) / 2;
|
||||
const shiftX =
|
||||
boundTextCanvas.width / 2 -
|
||||
(boundTextCx - x1) * window.devicePixelRatio * scale -
|
||||
offsetX -
|
||||
padding * scale;
|
||||
|
||||
const shiftY =
|
||||
boundTextCanvas.height / 2 -
|
||||
(boundTextCy - y1) * window.devicePixelRatio * scale -
|
||||
offsetY -
|
||||
padding * scale;
|
||||
boundTextCanvasContext.translate(-shiftX, -shiftY);
|
||||
// Clear the bound text area
|
||||
boundTextCanvasContext.clearRect(
|
||||
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
canvas,
|
||||
@@ -245,17 +343,28 @@ const generateElementCanvas = (
|
||||
getBoundTextElement(element, elementsMap)?.version || null,
|
||||
containingFrameOpacity:
|
||||
getContainingFrame(element, elementsMap)?.opacity || 100,
|
||||
boundTextCanvas,
|
||||
angle: element.angle,
|
||||
imageCrop: isImageElement(element) ? element.crop : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_LINK_SIZE = 14;
|
||||
|
||||
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
|
||||
const IMAGE_PLACEHOLDER_IMG =
|
||||
typeof document !== "undefined"
|
||||
? document.createElement("img")
|
||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||
|
||||
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
||||
)}`;
|
||||
|
||||
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
|
||||
const IMAGE_ERROR_PLACEHOLDER_IMG =
|
||||
typeof document !== "undefined"
|
||||
? document.createElement("img")
|
||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||
|
||||
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
||||
)}`;
|
||||
@@ -263,7 +372,6 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
const drawImagePlaceholder = (
|
||||
element: ExcalidrawImageElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
zoomValue: AppState["zoom"]["value"],
|
||||
) => {
|
||||
context.fillStyle = "#E7E7E7";
|
||||
context.fillRect(0, 0, element.width, element.height);
|
||||
@@ -348,15 +456,29 @@ const drawElementOnCanvas = (
|
||||
);
|
||||
context.clip();
|
||||
}
|
||||
|
||||
const { x, y, width, height } = element.crop
|
||||
? element.crop
|
||||
: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
|
||||
context.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0 /* hardcoded for the selection box*/,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
} else {
|
||||
drawImagePlaceholder(element, context, appState.zoom.value);
|
||||
drawImagePlaceholder(element, context);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -421,18 +543,23 @@ export const elementWithCanvasCache = new WeakMap<
|
||||
|
||||
const generateElementWithCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: RenderableElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
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 &&
|
||||
prevElementWithCanvas.zoomValue !== zoom.value &&
|
||||
!appState?.shouldCacheIgnoreZoom;
|
||||
const boundTextElementVersion =
|
||||
getBoundTextElement(element, elementsMap)?.version || null;
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
const boundTextElementVersion = boundTextElement?.version || null;
|
||||
const imageCrop = isImageElement(element) ? element.crop : null;
|
||||
|
||||
const containingFrameOpacity =
|
||||
getContainingFrame(element, elementsMap)?.opacity || 100;
|
||||
@@ -442,7 +569,15 @@ const generateElementWithCanvas = (
|
||||
shouldRegenerateBecauseZoom ||
|
||||
prevElementWithCanvas.theme !== appState.theme ||
|
||||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
|
||||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
|
||||
prevElementWithCanvas.imageCrop !== imageCrop ||
|
||||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity ||
|
||||
// since we rotate the canvas when copying from cached canvas, we don't
|
||||
// regenerate the cached canvas. But we need to in case of labels which are
|
||||
// cached alongside the arrow, and we want the labels to remain unrotated
|
||||
// with respect to the arrow.
|
||||
(isArrowElement(element) &&
|
||||
boundTextElement &&
|
||||
element.angle !== prevElementWithCanvas.angle)
|
||||
) {
|
||||
const elementWithCanvas = generateElementCanvas(
|
||||
element,
|
||||
@@ -452,6 +587,10 @@ const generateElementWithCanvas = (
|
||||
appState,
|
||||
);
|
||||
|
||||
if (!elementWithCanvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.set(element, elementWithCanvas);
|
||||
|
||||
return elementWithCanvas;
|
||||
@@ -469,16 +608,7 @@ const drawElementFromCanvas = (
|
||||
const element = elementWithCanvas.element;
|
||||
const padding = getCanvasPadding(element);
|
||||
const zoom = elementWithCanvas.scale;
|
||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||
|
||||
// Free draw elements will otherwise "shuffle" as the min x and y change
|
||||
if (isFreeDrawElement(element)) {
|
||||
x1 = Math.floor(x1);
|
||||
x2 = Math.ceil(x2);
|
||||
y1 = Math.floor(y1);
|
||||
y2 = Math.ceil(y2);
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
||||
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
||||
|
||||
@@ -488,75 +618,21 @@ const drawElementFromCanvas = (
|
||||
const boundTextElement = getBoundTextElement(element, allElementsMap);
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
const tempCanvasContext = tempCanvas.getContext("2d")!;
|
||||
|
||||
// Take max dimensions of arrow canvas so that when canvas is rotated
|
||||
// the arrow doesn't get clipped
|
||||
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
|
||||
tempCanvas.width =
|
||||
maxDim * window.devicePixelRatio * zoom +
|
||||
padding * elementWithCanvas.scale * 10;
|
||||
tempCanvas.height =
|
||||
maxDim * window.devicePixelRatio * zoom +
|
||||
padding * elementWithCanvas.scale * 10;
|
||||
const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
|
||||
const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
|
||||
|
||||
tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2);
|
||||
tempCanvasContext.rotate(element.angle);
|
||||
|
||||
tempCanvasContext.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
-elementWithCanvas.canvas.width / 2,
|
||||
-elementWithCanvas.canvas.height / 2,
|
||||
elementWithCanvas.canvas.width,
|
||||
elementWithCanvas.canvas.height,
|
||||
);
|
||||
|
||||
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
|
||||
boundTextElement,
|
||||
allElementsMap,
|
||||
);
|
||||
|
||||
tempCanvasContext.rotate(-element.angle);
|
||||
|
||||
// Shift the canvas to the center of the bound text element
|
||||
const shiftX =
|
||||
tempCanvas.width / 2 -
|
||||
(boundTextCx - x1) * window.devicePixelRatio * zoom -
|
||||
offsetX -
|
||||
padding * zoom;
|
||||
|
||||
const shiftY =
|
||||
tempCanvas.height / 2 -
|
||||
(boundTextCy - y1) * window.devicePixelRatio * zoom -
|
||||
offsetY -
|
||||
padding * zoom;
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
// Clear the bound text area
|
||||
tempCanvasContext.clearRect(
|
||||
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
);
|
||||
|
||||
const offsetX =
|
||||
(elementWithCanvas.boundTextCanvas.width -
|
||||
elementWithCanvas.canvas!.width) /
|
||||
2;
|
||||
const offsetY =
|
||||
(elementWithCanvas.boundTextCanvas.height -
|
||||
elementWithCanvas.canvas!.height) /
|
||||
2;
|
||||
context.translate(cx, cy);
|
||||
context.drawImage(
|
||||
tempCanvas,
|
||||
elementWithCanvas.boundTextCanvas,
|
||||
(-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
|
||||
(-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
|
||||
tempCanvas.width / zoom,
|
||||
tempCanvas.height / zoom,
|
||||
elementWithCanvas.boundTextCanvas.width / zoom,
|
||||
elementWithCanvas.boundTextCanvas.height / zoom,
|
||||
);
|
||||
} else {
|
||||
// we translate context to element center so that rotation and scale
|
||||
@@ -617,6 +693,7 @@ export const renderSelectionElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
|
||||
@@ -630,7 +707,7 @@ export const renderSelectionElement = (
|
||||
|
||||
context.fillRect(offset, offset, element.width, element.height);
|
||||
context.lineWidth = 1 / appState.zoom.value;
|
||||
context.strokeStyle = " rgb(105, 101, 219)";
|
||||
context.strokeStyle = selectionColor;
|
||||
context.strokeRect(offset, offset, element.width, element.height);
|
||||
|
||||
context.restore();
|
||||
@@ -645,10 +722,17 @@ export const renderElement = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const reduceAlphaForSelection =
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
!appState.selectedElementIds[element.id] &&
|
||||
!appState.hoveredElementIds[element.id];
|
||||
|
||||
context.globalAlpha = getRenderOpacity(
|
||||
element,
|
||||
getContainingFrame(element, elementsMap),
|
||||
renderConfig.elementsPendingErasure,
|
||||
renderConfig.pendingFlowchartNodes,
|
||||
reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
|
||||
);
|
||||
|
||||
switch (element.type) {
|
||||
@@ -668,7 +752,7 @@ export const renderElement = (
|
||||
// TODO change later to only affect AI frames
|
||||
if (isMagicFrameElement(element)) {
|
||||
context.strokeStyle =
|
||||
appState.theme === "light" ? "#7affd7" : "#1d8264";
|
||||
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
|
||||
}
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
@@ -711,10 +795,14 @@ export const renderElement = (
|
||||
} else {
|
||||
const elementWithCanvas = generateElementWithCanvas(
|
||||
element,
|
||||
elementsMap,
|
||||
allElementsMap,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
if (!elementWithCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawElementFromCanvas(
|
||||
elementWithCanvas,
|
||||
context,
|
||||
@@ -849,11 +937,15 @@ export const renderElement = (
|
||||
} else {
|
||||
const elementWithCanvas = generateElementWithCanvas(
|
||||
element,
|
||||
elementsMap,
|
||||
allElementsMap,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
if (!elementWithCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
|
||||
|
||||
if (
|
||||
@@ -864,7 +956,8 @@ export const renderElement = (
|
||||
(!element.angle ||
|
||||
// or check if angle is a right angle in which case we can still
|
||||
// disable smoothing without adversely affecting the result
|
||||
isRightAngle(element.angle))
|
||||
// We need less-than comparison because of FP artihmetic
|
||||
isRightAngleRads(element.angle))
|
||||
) {
|
||||
// Disabling smoothing makes output much sharper, especially for
|
||||
// text. Unless for non-right angles, where the aliasing is really
|
||||
@@ -876,6 +969,35 @@ export const renderElement = (
|
||||
context.imageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
if (
|
||||
element.id === appState.croppingElementId &&
|
||||
isImageElement(elementWithCanvas.element) &&
|
||||
elementWithCanvas.element.crop !== null
|
||||
) {
|
||||
context.save();
|
||||
context.globalAlpha = 0.1;
|
||||
|
||||
const uncroppedElementCanvas = generateElementCanvas(
|
||||
getUncroppedImageElement(elementWithCanvas.element, elementsMap),
|
||||
allElementsMap,
|
||||
appState.zoom,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
if (uncroppedElementCanvas) {
|
||||
drawElementFromCanvas(
|
||||
uncroppedElementCanvas,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
drawElementFromCanvas(
|
||||
elementWithCanvas,
|
||||
context,
|
||||
File diff suppressed because it is too large
Load Diff
+132
-17
@@ -1,21 +1,38 @@
|
||||
import {
|
||||
pointFrom,
|
||||
pointOnLineSegment,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
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 { isImageElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
TransformHandleType,
|
||||
TransformHandle,
|
||||
MaybeTransformHandleType,
|
||||
} from "./transformHandles";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
PointerType,
|
||||
NonDeletedExcalidrawElement,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import {
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
TransformHandleType,
|
||||
TransformHandle,
|
||||
MaybeTransformHandleType,
|
||||
} from "./transformHandles";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { Bounds } from "./bounds";
|
||||
|
||||
const isInsideTransformHandle = (
|
||||
transformHandle: TransformHandle,
|
||||
x: number,
|
||||
@@ -26,7 +43,7 @@ const isInsideTransformHandle = (
|
||||
y >= transformHandle[1] &&
|
||||
y <= transformHandle[1] + transformHandle[3];
|
||||
|
||||
export const resizeTest = (
|
||||
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
@@ -34,13 +51,20 @@ export const resizeTest = (
|
||||
y: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
): MaybeTransformHandleType => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { rotation: rotationTransformHandle, ...transformHandles } =
|
||||
getTransformHandles(element, zoom, elementsMap, pointerType);
|
||||
getTransformHandles(
|
||||
element,
|
||||
zoom,
|
||||
elementsMap,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
);
|
||||
|
||||
if (
|
||||
rotationTransformHandle &&
|
||||
@@ -62,6 +86,41 @@ export const resizeTest = (
|
||||
return filter[0] as TransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// do not resize from the sides for linear elements with only two points
|
||||
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||
const SPACING = isImageElement(element)
|
||||
? 0
|
||||
: SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const ZOOMED_SIDE_RESIZING_THRESHOLD =
|
||||
SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const sides = getSelectionBorders(
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(
|
||||
pointFrom(x, y),
|
||||
side as LineSegment<Point>,
|
||||
ZOOMED_SIDE_RESIZING_THRESHOLD,
|
||||
)
|
||||
) {
|
||||
return dir as TransformHandleType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -73,6 +132,7 @@ export const getElementWithTransformHandleType = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
elementsMap: ElementsMap,
|
||||
device: Device,
|
||||
) => {
|
||||
return elements.reduce((result, element) => {
|
||||
if (result) {
|
||||
@@ -86,24 +146,28 @@ export const getElementWithTransformHandleType = (
|
||||
scenePointerY,
|
||||
zoom,
|
||||
pointerType,
|
||||
device,
|
||||
);
|
||||
return transformHandleType ? { element, transformHandleType } : null;
|
||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||
};
|
||||
|
||||
export const getTransformHandleTypeFromCoords = (
|
||||
export const getTransformHandleTypeFromCoords = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
): MaybeTransformHandleType => {
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0,
|
||||
0 as Radians,
|
||||
zoom,
|
||||
pointerType,
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
getOmitSidesForDevice(device),
|
||||
);
|
||||
|
||||
const found = Object.keys(transformHandles).find((key) => {
|
||||
@@ -114,7 +178,39 @@ export const getTransformHandleTypeFromCoords = (
|
||||
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
|
||||
);
|
||||
});
|
||||
return (found || false) as MaybeTransformHandleType;
|
||||
|
||||
if (found) {
|
||||
return found as MaybeTransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
|
||||
const sides = getSelectionBorders(
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
0 as Radians,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
side as LineSegment<Point>,
|
||||
SPACING,
|
||||
)
|
||||
) {
|
||||
return dir as TransformHandleType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
|
||||
@@ -174,3 +270,22 @@ export const getCursorForResizingElement = (resizingElement: {
|
||||
|
||||
return cursor ? `${cursor}-resize` : "";
|
||||
};
|
||||
|
||||
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
|
||||
[x1, y1]: Point,
|
||||
[x2, y2]: Point,
|
||||
center: Point,
|
||||
angle: Radians,
|
||||
) => {
|
||||
const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
|
||||
const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
|
||||
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
|
||||
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
|
||||
|
||||
return {
|
||||
n: [topLeft, topRight],
|
||||
e: [topRight, bottomRight],
|
||||
s: [bottomRight, bottomLeft],
|
||||
w: [bottomLeft, topLeft],
|
||||
};
|
||||
};
|
||||
+83
-35
@@ -1,19 +1,32 @@
|
||||
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
||||
import { AppState, InteractiveCanvasAppState } from "../types";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
} from "../frame";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { isElementInViewport } from "../element/sizeHelpers";
|
||||
} from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { selectGroupsForSelectedElements } from "./groups";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Frames and their containing elements are not to be selected at the same time.
|
||||
@@ -156,25 +169,6 @@ export const isSomeElementSelected = (function () {
|
||||
return ret;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Returns common attribute (picked by `getAttribute` callback) of selected
|
||||
* elements. If elements don't share the same value, returns `null`.
|
||||
*/
|
||||
export const getCommonAttributeOfSelectedElements = <T>(
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Pick<AppState, "selectedElementIds">,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
): T | null => {
|
||||
const attributes = Array.from(
|
||||
new Set(
|
||||
getSelectedElements(elements, appState).map((element) =>
|
||||
getAttribute(element),
|
||||
),
|
||||
),
|
||||
);
|
||||
return attributes.length === 1 ? attributes[0] : null;
|
||||
};
|
||||
|
||||
export const getSelectedElements = (
|
||||
elements: ElementsMapOrArray,
|
||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||
@@ -183,10 +177,12 @@ export const getSelectedElements = (
|
||||
includeElementsInFrames?: boolean;
|
||||
},
|
||||
) => {
|
||||
const addedElements = new Set<ExcalidrawElement["id"]>();
|
||||
const selectedElements: ExcalidrawElement[] = [];
|
||||
for (const element of elements.values()) {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -195,6 +191,7 @@ export const getSelectedElements = (
|
||||
appState.selectedElementIds[element?.containerId]
|
||||
) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -203,8 +200,8 @@ export const getSelectedElements = (
|
||||
const elementsToInclude: ExcalidrawElement[] = [];
|
||||
selectedElements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
getFrameChildren(elements, element.id).forEach((e) =>
|
||||
elementsToInclude.push(e),
|
||||
getFrameChildren(elements, element.id).forEach(
|
||||
(e) => !addedElements.has(e.id) && elementsToInclude.push(e),
|
||||
);
|
||||
}
|
||||
elementsToInclude.push(element);
|
||||
@@ -218,10 +215,15 @@ export const getSelectedElements = (
|
||||
|
||||
export const getTargetElements = (
|
||||
elements: ElementsMapOrArray,
|
||||
appState: Pick<AppState, "selectedElementIds" | "editingElement">,
|
||||
appState: Pick<
|
||||
AppState,
|
||||
"selectedElementIds" | "editingTextElement" | "newElement"
|
||||
>,
|
||||
) =>
|
||||
appState.editingElement
|
||||
? [appState.editingElement]
|
||||
appState.editingTextElement
|
||||
? [appState.editingTextElement]
|
||||
: appState.newElement
|
||||
? [appState.newElement]
|
||||
: getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
@@ -240,3 +242,49 @@ export const makeNextSelectedElementIds = (
|
||||
|
||||
return nextSelectedElementIds;
|
||||
};
|
||||
|
||||
const _getLinearElementEditor = (
|
||||
targetElements: readonly ExcalidrawElement[],
|
||||
allElements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const linears = targetElements.filter(isLinearElement);
|
||||
if (linears.length === 1) {
|
||||
const linear = linears[0];
|
||||
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
||||
const onlySingleLinearSelected = targetElements.every(
|
||||
(el) => el.id === linear.id || boundElements.includes(el.id),
|
||||
);
|
||||
|
||||
if (onlySingleLinearSelected) {
|
||||
return new LinearElementEditor(linear, arrayToMap(allElements));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getSelectionStateForElements = (
|
||||
targetElements: readonly ExcalidrawElement[],
|
||||
allElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
return {
|
||||
selectedLinearElement: _getLinearElementEditor(targetElements, allElements),
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: excludeElementsInFramesFromSelection(
|
||||
targetElements,
|
||||
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
allElements,
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -1,6 +1,8 @@
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { UIAppState } from "../types";
|
||||
import type { UIAppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "./types";
|
||||
|
||||
export const showSelectedShapeActions = (
|
||||
appState: UIAppState,
|
||||
@@ -8,9 +10,11 @@ export const showSelectedShapeActions = (
|
||||
) =>
|
||||
Boolean(
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
((appState.activeTool.type !== "custom" &&
|
||||
(appState.editingElement ||
|
||||
(appState.editingTextElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "lasso" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand" &&
|
||||
appState.activeTool.type !== "laser"))) ||
|
||||
+109
-46
@@ -1,17 +1,48 @@
|
||||
import { ElementsMap, ExcalidrawElement } from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import { viewportCoordsToSceneCoords } from "../utils";
|
||||
import {
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
normalizeRadians,
|
||||
radiansBetweenAngles,
|
||||
radiansDifference,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||
|
||||
// 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'
|
||||
// - could also be part of `_clearElements`
|
||||
export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
return element.points.length < 2;
|
||||
return (
|
||||
element.points.length < 2 ||
|
||||
(element.points.length === 2 &&
|
||||
isArrowElement(element) &&
|
||||
pointsEqual(
|
||||
element.points[0],
|
||||
element.points[element.points.length - 1],
|
||||
INVISIBLY_SMALL_ELEMENT_SIZE,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return element.width === 0 && element.height === 0;
|
||||
};
|
||||
|
||||
@@ -52,6 +83,44 @@ export const isElementInViewport = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isElementCompletelyInViewport = (
|
||||
elements: ExcalidrawElement[],
|
||||
width: number,
|
||||
height: number,
|
||||
viewTransformations: {
|
||||
zoom: Zoom;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
},
|
||||
elementsMap: ElementsMap,
|
||||
padding?: Offsets,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft + (padding?.left || 0),
|
||||
clientY: viewTransformations.offsetTop + (padding?.top || 0),
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
|
||||
clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
|
||||
return (
|
||||
x1 >= topLeftSceneCoords.x &&
|
||||
y1 >= topLeftSceneCoords.y &&
|
||||
x2 <= bottomRightSceneCoords.x &&
|
||||
y2 <= bottomRightSceneCoords.y
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
@@ -89,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
customAngle?: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
const angle = Math.atan2(height, width) as Radians;
|
||||
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE) as Radians;
|
||||
|
||||
if (customAngle) {
|
||||
// If custom angle is provided, we check if the angle is close to the
|
||||
// custom angle, snap to that if close engough, otherwise snap to the
|
||||
// higher or lower angle depending on the current angle vs custom angle.
|
||||
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE) as Radians;
|
||||
if (
|
||||
radiansBetweenAngles(
|
||||
angle,
|
||||
lower,
|
||||
(lower + SHIFT_LOCKING_ANGLE) as Radians,
|
||||
)
|
||||
) {
|
||||
if (
|
||||
radiansDifference(angle, customAngle as Radians) <
|
||||
SHIFT_LOCKING_ANGLE / 6
|
||||
) {
|
||||
lockedAngle = customAngle as Radians;
|
||||
} else if (
|
||||
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
|
||||
) {
|
||||
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
|
||||
} else {
|
||||
lockedAngle = lower;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
@@ -124,41 +222,6 @@ export const getLockedLinearCursorAlignSize = (
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const resizePerfectLineForNWHandler = (
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const anchorX = element.x + element.width;
|
||||
const anchorY = element.y + element.height;
|
||||
const distanceToAnchorX = x - anchorX;
|
||||
const distanceToAnchorY = y - anchorY;
|
||||
if (Math.abs(distanceToAnchorX) < Math.abs(distanceToAnchorY) / 2) {
|
||||
mutateElement(element, {
|
||||
x: anchorX,
|
||||
width: 0,
|
||||
y,
|
||||
height: -distanceToAnchorY,
|
||||
});
|
||||
} else if (Math.abs(distanceToAnchorY) < Math.abs(element.width) / 2) {
|
||||
mutateElement(element, {
|
||||
y: anchorY,
|
||||
height: 0,
|
||||
});
|
||||
} else {
|
||||
const nextHeight =
|
||||
Math.sign(distanceToAnchorY) *
|
||||
Math.sign(distanceToAnchorX) *
|
||||
element.width;
|
||||
mutateElement(element, {
|
||||
x,
|
||||
y: anchorY - nextHeight,
|
||||
width: -distanceToAnchorX,
|
||||
height: nextHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getNormalizedDimensions = (
|
||||
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
|
||||
): {
|
||||
+4
-6
@@ -1,5 +1,6 @@
|
||||
import { arrayToMapWithIndex } from "../utils";
|
||||
import { ExcalidrawElement } from "./types";
|
||||
import { arrayToMapWithIndex } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
const origElements: ExcalidrawElement[] = elements.slice();
|
||||
@@ -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));
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,510 @@
|
||||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
isProdEnv,
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isArrowElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
let maxWidth = undefined;
|
||||
|
||||
if (!isProdEnv()) {
|
||||
invariant(
|
||||
!container || !isArrowElement(container) || textElement.angle === 0,
|
||||
"text element angle must be 0 if bound to arrow container",
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
angle: (container
|
||||
? isArrowElement(container)
|
||||
? 0
|
||||
: container.angle
|
||||
: textElement.angle) as Radians,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container || !textElement.autoResize) {
|
||||
maxWidth = container
|
||||
? getBoundTextMaxWidth(container, textElement)
|
||||
: textElement.width;
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
|
||||
if (textElement.autoResize) {
|
||||
boundTextUpdates.width = metrics.width;
|
||||
}
|
||||
boundTextUpdates.height = metrics.height;
|
||||
|
||||
if (container) {
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container, textElement);
|
||||
|
||||
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
scene.mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
|
||||
if (metrics.width > maxContainerWidth) {
|
||||
const nextWidth = computeContainerDimensionForBoundText(
|
||||
metrics.width,
|
||||
container.type,
|
||||
);
|
||||
scene.mutateElement(container, { width: nextWidth });
|
||||
}
|
||||
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
|
||||
scene.mutateElement(textElement, boundTextUpdates);
|
||||
};
|
||||
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
scene: Scene,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
return;
|
||||
}
|
||||
resetOriginalContainerCache(container.id);
|
||||
const textElement = getBoundTextElement(container, elementsMap);
|
||||
if (textElement && textElement.text) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
const maxHeight = getBoundTextMaxHeight(container, textElement);
|
||||
let containerHeight = container.height;
|
||||
if (
|
||||
shouldMaintainAspectRatio ||
|
||||
(transformHandleType !== "n" && transformHandleType !== "s")
|
||||
) {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const metrics = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
nextHeight = metrics.height;
|
||||
nextWidth = metrics.width;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
containerHeight = computeContainerDimensionForBoundText(
|
||||
nextHeight,
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
(transformHandleType === "ne" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "n")
|
||||
? container.y - diff
|
||||
: container.y;
|
||||
scene.mutateElement(container, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
});
|
||||
}
|
||||
|
||||
scene.mutateElement(textElement, {
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
scene.mutateElement(
|
||||
textElement,
|
||||
computeBoundTextPosition(container, textElement, elementsMap),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const computeBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
||||
} else {
|
||||
y =
|
||||
containerCoords.y +
|
||||
(maxContainerHeight / 2 - boundTextElement.height / 2);
|
||||
}
|
||||
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
x = containerCoords.x;
|
||||
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
|
||||
} else {
|
||||
x =
|
||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||
}
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
if (boundTextElementId) {
|
||||
return (elementsMap.get(boundTextElementId) ||
|
||||
null) as ExcalidrawTextElementWithContainer | null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerElement = (
|
||||
element: ExcalidrawTextElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
): ExcalidrawTextContainer | null => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
if (element.containerId) {
|
||||
return (elementsMap.get(element.containerId) ||
|
||||
null) as ExcalidrawTextContainer | null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!isArrowElement(container)) {
|
||||
return {
|
||||
x: container.x + container.width / 2,
|
||||
y: container.y + container.height / 2,
|
||||
};
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
container,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length % 2 === 1) {
|
||||
const index = Math.floor(container.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
container,
|
||||
container.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
return { x: midPoint[0], y: midPoint[1] };
|
||||
}
|
||||
const index = container.points.length / 2 - 1;
|
||||
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
|
||||
container,
|
||||
elementsMap,
|
||||
appState,
|
||||
)[index];
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
index + 1,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
|
||||
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
|
||||
}
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
|
||||
if (container.type === "diamond") {
|
||||
offsetX += container.width / 4;
|
||||
offsetY += container.height / 4;
|
||||
}
|
||||
return {
|
||||
x: container.x + offsetX,
|
||||
y: container.y + offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
export const getTextElementAngle = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawTextContainer | null,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return 0;
|
||||
}
|
||||
if (!container) {
|
||||
return textElement.angle;
|
||||
}
|
||||
return container.angle;
|
||||
};
|
||||
|
||||
export const getBoundTextElementPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
if (isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const suppportsHorizontalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
if (isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return isTextElement(element);
|
||||
});
|
||||
};
|
||||
|
||||
const VALID_CONTAINER_TYPES = new Set([
|
||||
"rectangle",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElementType;
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
||||
) => {
|
||||
dimension = Math.ceil(dimension);
|
||||
const padding = BOUND_TEXT_PADDING * 2;
|
||||
|
||||
if (containerType === "ellipse") {
|
||||
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
||||
}
|
||||
if (containerType === "arrow") {
|
||||
return dimension + padding * 8;
|
||||
}
|
||||
if (containerType === "diamond") {
|
||||
return 2 * (dimension + padding);
|
||||
}
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElement | null,
|
||||
) => {
|
||||
const { width } = container;
|
||||
if (isArrowElement(container)) {
|
||||
const minWidth =
|
||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
|
||||
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
|
||||
}
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The width of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const { height } = container;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
return boundTextElement.height;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
if (container.type === "ellipse") {
|
||||
// The height of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The height of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
/** retrieves text from text elements and concatenates to a single string */
|
||||
export const getTextFromElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
separator = "\n\n",
|
||||
) => {
|
||||
const text = elements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join(separator);
|
||||
return text;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,570 @@
|
||||
import { isDevEnv, isTestEnv } from "@excalidraw/common";
|
||||
|
||||
import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
|
||||
import type { FontString } from "./types";
|
||||
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
let cachedLineBreakRegex: RegExp | undefined;
|
||||
let cachedEmojiRegex: RegExp | undefined;
|
||||
|
||||
/**
|
||||
* Test if a given text contains any CJK characters (including symbols, punctuation, etc,).
|
||||
*/
|
||||
export const containsCJK = (text: string) => {
|
||||
if (!cachedCjkRegex) {
|
||||
cachedCjkRegex = Regex.class(...Object.values(CJK));
|
||||
}
|
||||
|
||||
return cachedCjkRegex.test(text);
|
||||
};
|
||||
|
||||
const getLineBreakRegex = () => {
|
||||
if (!cachedLineBreakRegex) {
|
||||
try {
|
||||
cachedLineBreakRegex = getLineBreakRegexAdvanced();
|
||||
} catch {
|
||||
cachedLineBreakRegex = getLineBreakRegexSimple();
|
||||
}
|
||||
}
|
||||
|
||||
return cachedLineBreakRegex;
|
||||
};
|
||||
|
||||
const getEmojiRegex = () => {
|
||||
if (!cachedEmojiRegex) {
|
||||
cachedEmojiRegex = getEmojiRegexUnicode();
|
||||
}
|
||||
|
||||
return cachedEmojiRegex;
|
||||
};
|
||||
|
||||
/**
|
||||
* Common symbols used across different languages.
|
||||
*/
|
||||
const COMMON = {
|
||||
/**
|
||||
* Natural breaking points for any grammars.
|
||||
*
|
||||
* Hello world
|
||||
* ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
|
||||
* Hello-world
|
||||
* ↑ BREAK AFTER "-" → ["Hello-", "world"]
|
||||
*/
|
||||
WHITESPACE: /\s/u,
|
||||
HYPHEN: /-/u,
|
||||
/**
|
||||
* Generally do not break, unless closed symbol is followed by an opening symbol.
|
||||
*
|
||||
* Also, western punctation is often used in modern Korean and expects to be treated
|
||||
* similarly to the CJK opening and closing symbols.
|
||||
*
|
||||
* Hello(한글)→ ["Hello", "(한", "글)"]
|
||||
* ↑ BREAK BEFORE "("
|
||||
* ↑ BREAK AFTER ")"
|
||||
*/
|
||||
OPENING: /<\(\[\{/u,
|
||||
CLOSING: />\)\]\}.,:;!\?…\//u,
|
||||
};
|
||||
|
||||
/**
|
||||
* Characters and symbols used in Chinese, Japanese and Korean.
|
||||
*/
|
||||
const CJK = {
|
||||
/**
|
||||
* Every CJK breaks before and after, unless it's paired with an opening or closing symbol.
|
||||
*
|
||||
* Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation.
|
||||
*/
|
||||
CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}`'^〃〰〆#&*+-ー/\=|¦〒¬ ̄/u,
|
||||
/**
|
||||
* Opening and closing CJK punctuation breaks before and after all such characters (in case of many),
|
||||
* and creates pairs with neighboring characters.
|
||||
*
|
||||
* Hello た。→ ["Hello", "た。"]
|
||||
* ↑ DON'T BREAK "た。"
|
||||
* * Hello「た」 World → ["Hello", "「た」", "World"]
|
||||
* ↑ DON'T BREAK "「た"
|
||||
* ↑ DON'T BREAK "た"
|
||||
* ↑ BREAK BEFORE "「"
|
||||
* ↑ BREAK AFTER "」"
|
||||
*/
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
OPENING:/([{〈《⦅「「『【〖〔〘〚<〝/u,
|
||||
CLOSING: /)]}〉》⦆」」』】〗〕〙〛>。.,、〟‥?!:;・〜〞/u,
|
||||
/**
|
||||
* Currency symbols break before, not after
|
||||
*
|
||||
* Price¥100 → ["Price", "¥100"]
|
||||
* ↑ BREAK BEFORE "¥"
|
||||
*/
|
||||
CURRENCY: /¥₩£¢$/u,
|
||||
};
|
||||
|
||||
const EMOJI = {
|
||||
FLAG: /\p{RI}\p{RI}/u,
|
||||
JOINER:
|
||||
/(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u,
|
||||
ZWJ: /\u200D/u,
|
||||
ANY: /[\p{Emoji}]/u,
|
||||
MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u,
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
|
||||
*
|
||||
* Browser support as of 10/2024:
|
||||
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
|
||||
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
|
||||
*
|
||||
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
|
||||
*/
|
||||
const getLineBreakRegexSimple = () =>
|
||||
Regex.or(
|
||||
getEmojiRegex(),
|
||||
Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR),
|
||||
);
|
||||
|
||||
/**
|
||||
* Specifies the line breaking rules based for alphabetic-based languages,
|
||||
* Chinese, Japanese, Korean and Emojis.
|
||||
*
|
||||
* "Hello-world" → ["Hello-", "world"]
|
||||
* "Hello 「世界。」🌎🗺" → ["Hello", " ", "「世", "界。」", "🌎", "🗺"]
|
||||
*/
|
||||
const getLineBreakRegexAdvanced = () =>
|
||||
Regex.or(
|
||||
// Unicode-defined regex for (multi-codepoint) Emojis
|
||||
getEmojiRegex(),
|
||||
// Rules for whitespace and hyphen
|
||||
Break.Before(COMMON.WHITESPACE).Build(),
|
||||
Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(),
|
||||
// Rules for CJK (chars, symbols, currency)
|
||||
Break.Before(CJK.CHAR, CJK.CURRENCY)
|
||||
.NotPrecededBy(COMMON.OPENING, CJK.OPENING)
|
||||
.Build(),
|
||||
Break.After(CJK.CHAR)
|
||||
.NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING)
|
||||
.Build(),
|
||||
// Rules for opening and closing punctuation
|
||||
Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(),
|
||||
Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(),
|
||||
Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Matches various emoji types.
|
||||
*
|
||||
* 1. basic emojis (😀, 🌍)
|
||||
* 2. flags (🇨🇿)
|
||||
* 3. multi-codepoint emojis:
|
||||
* - skin tones (👍🏽)
|
||||
* - variation selectors (☂️)
|
||||
* - keycaps (1️⃣)
|
||||
* - tag sequences (🏴)
|
||||
* - emoji sequences (👨👩👧👦, 👩🚀, 🏳️🌈)
|
||||
*
|
||||
* Unicode points:
|
||||
* - \uFE0F: presentation selector
|
||||
* - \u20E3: enclosing keycap
|
||||
* - \u200D: zero width joiner
|
||||
* - \u{E0020}-\u{E007E}: tags
|
||||
* - \u{E007F}: cancel tag
|
||||
*
|
||||
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
|
||||
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
|
||||
* - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker)
|
||||
*/
|
||||
const getEmojiRegexUnicode = () =>
|
||||
Regex.group(
|
||||
Regex.or(
|
||||
EMOJI.FLAG,
|
||||
Regex.and(
|
||||
EMOJI.MOST,
|
||||
EMOJI.JOINER,
|
||||
Regex.build(
|
||||
`(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Regex utilities for unicode character classes.
|
||||
*/
|
||||
const Regex = {
|
||||
/**
|
||||
* Builds a regex from a string.
|
||||
*/
|
||||
build: (regex: string): RegExp => new RegExp(regex, "u"),
|
||||
/**
|
||||
* Joins regexes into a single string.
|
||||
*/
|
||||
join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""),
|
||||
/**
|
||||
* Joins regexes into a single regex as with "and" operator.
|
||||
*/
|
||||
and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)),
|
||||
/**
|
||||
* Joins regexes into a single regex with "or" operator.
|
||||
*/
|
||||
or: (...regexes: RegExp[]): RegExp =>
|
||||
Regex.build(regexes.map((x) => x.source).join("|")),
|
||||
/**
|
||||
* Puts regexes into a matching group.
|
||||
*/
|
||||
group: (...regexes: RegExp[]): RegExp =>
|
||||
Regex.build(`(${Regex.join(...regexes)})`),
|
||||
/**
|
||||
* Puts regexes into a character class.
|
||||
*/
|
||||
class: (...regexes: RegExp[]): RegExp =>
|
||||
Regex.build(`[${Regex.join(...regexes)}]`),
|
||||
};
|
||||
|
||||
/**
|
||||
* Human-readable lookahead and lookbehind utilities for defining line break
|
||||
* opportunities between pairs of character classes.
|
||||
*/
|
||||
const Break = {
|
||||
/**
|
||||
* Break on the given class of characters.
|
||||
*/
|
||||
On: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
return Regex.build(`([${joined}])`);
|
||||
},
|
||||
/**
|
||||
* Break before the given class of characters.
|
||||
*/
|
||||
Before: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?=[${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"FollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Break after the given class of characters.
|
||||
*/
|
||||
After: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<=[${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"PreceededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Break before one or multiple characters of the same class.
|
||||
*/
|
||||
BeforeMany: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"FollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Break after one or multiple character from the same class.
|
||||
*/
|
||||
AfterMany: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"PreceededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Do not break before the given class of characters.
|
||||
*/
|
||||
NotBefore: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?![${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotFollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Do not break after the given class of characters.
|
||||
*/
|
||||
NotAfter: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<![${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotPrecededBy"
|
||||
>;
|
||||
},
|
||||
Chain: (rootBuilder: () => RegExp) => ({
|
||||
/**
|
||||
* Build the root regex.
|
||||
*/
|
||||
Build: rootBuilder,
|
||||
/**
|
||||
* Specify additional class of characters that should precede the root regex.
|
||||
*/
|
||||
PreceededBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const preceeded = Break.After(...regexes).Build();
|
||||
const builder = () => Regex.and(preceeded, root);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"PreceededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Specify additional class of characters that should follow the root regex.
|
||||
*/
|
||||
FollowedBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const followed = Break.Before(...regexes).Build();
|
||||
const builder = () => Regex.and(root, followed);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"FollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Specify additional class of characters that should not precede the root regex.
|
||||
*/
|
||||
NotPrecededBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const notPreceeded = Break.NotAfter(...regexes).Build();
|
||||
const builder = () => Regex.and(notPreceeded, root);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotPrecededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Specify additional class of characters that should not follow the root regex.
|
||||
*/
|
||||
NotFollowedBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const notFollowed = Break.NotBefore(...regexes).Build();
|
||||
const builder = () => Regex.and(root, notFollowed);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotFollowedBy"
|
||||
>;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Breaks the line into the tokens based on the found line break opporutnities.
|
||||
*/
|
||||
export const parseTokens = (line: string) => {
|
||||
const breakLineRegex = getLineBreakRegex();
|
||||
|
||||
// normalizing to single-codepoint composed chars due to canonical equivalence
|
||||
// of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
|
||||
// filtering due to multi-codepoint chars like 👨👩👧👦, 👩🏽🦰
|
||||
return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the original text into the lines based on the given width.
|
||||
*/
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrappedLine = wrapLine(originalLine, font, maxWidth);
|
||||
lines.push(...wrappedLine);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the original line into the lines based on the given width.
|
||||
*/
|
||||
const wrapLine = (
|
||||
line: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string[] => {
|
||||
const lines: Array<string> = [];
|
||||
const tokens = parseTokens(line);
|
||||
const tokenIterator = tokens[Symbol.iterator]();
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidth = 0;
|
||||
|
||||
let iterator = tokenIterator.next();
|
||||
|
||||
while (!iterator.done) {
|
||||
const token = iterator.value;
|
||||
const testLine = currentLine + token;
|
||||
|
||||
// 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);
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||
currentLine = testLine;
|
||||
currentLineWidth = testLineWidth;
|
||||
iterator = tokenIterator.next();
|
||||
continue;
|
||||
}
|
||||
|
||||
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
|
||||
if (!currentLine) {
|
||||
const wrappedWord = wrapWord(token, font, maxWidth);
|
||||
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
|
||||
const precedingLines = wrappedWord.slice(0, -1);
|
||||
|
||||
lines.push(...precedingLines);
|
||||
|
||||
// trailing line of the wrapped word might still be joined with next token/s
|
||||
currentLine = trailingLine;
|
||||
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!
|
||||
lines.push(currentLine.trimEnd());
|
||||
|
||||
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
|
||||
currentLine = "";
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// iterator done, push the trailing line if exists
|
||||
if (currentLine) {
|
||||
const trailingLine = trimLine(currentLine, font, maxWidth);
|
||||
lines.push(trailingLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the word into the lines based on the given width.
|
||||
*/
|
||||
const wrapWord = (
|
||||
word: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): Array<string> => {
|
||||
// multi-codepoint emojis are already broken apart and shouldn't be broken further
|
||||
if (getEmojiRegex().test(word)) {
|
||||
return [word];
|
||||
}
|
||||
|
||||
satisfiesWordInvariant(word);
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const chars = Array.from(word);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidth = 0;
|
||||
|
||||
for (const char of chars) {
|
||||
const _charWidth = charWidth.calculate(char, font);
|
||||
const testLineWidth = currentLineWidth + _charWidth;
|
||||
|
||||
if (testLineWidth <= maxWidth) {
|
||||
currentLine = currentLine + char;
|
||||
currentLineWidth = testLineWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
currentLine = char;
|
||||
currentLineWidth = _charWidth;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
return line;
|
||||
}
|
||||
|
||||
// defensively default to `trimeEnd` in case the regex does not match
|
||||
let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [
|
||||
line,
|
||||
line.trimEnd(),
|
||||
"",
|
||||
];
|
||||
|
||||
let trimmedLineWidth = getLineWidth(trimmedLine, font);
|
||||
|
||||
for (const whitespace of Array.from(whitespaces)) {
|
||||
const _charWidth = charWidth.calculate(whitespace, font);
|
||||
const testLineWidth = trimmedLineWidth + _charWidth;
|
||||
|
||||
if (testLineWidth > maxWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
trimmedLine = trimmedLine + whitespace;
|
||||
trimmedLineWidth = testLineWidth;
|
||||
}
|
||||
|
||||
return trimmedLine;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given string is a single character.
|
||||
*
|
||||
* Handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨👩👧👦, 👩🏽🦰).
|
||||
*/
|
||||
const isSingleCharacter = (maybeSingleCharacter: string) => {
|
||||
return (
|
||||
maybeSingleCharacter.codePointAt(0) !== undefined &&
|
||||
maybeSingleCharacter.codePointAt(1) === undefined
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invariant for the word wrapping algorithm.
|
||||
*/
|
||||
const satisfiesWordInvariant = (word: string) => {
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
if (/\s/.test(word)) {
|
||||
throw new Error("Word should not contain any whitespaces!");
|
||||
}
|
||||
}
|
||||
};
|
||||
+77
-26
@@ -1,17 +1,35 @@
|
||||
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 { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointerType,
|
||||
} from "./types";
|
||||
|
||||
import { Bounds, getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "../constants";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
||||
| "s"
|
||||
@@ -38,6 +56,13 @@ const transformHandleSizes: { [k in PointerType]: number } = {
|
||||
|
||||
const ROTATION_RESIZE_HANDLE_GAP = 16;
|
||||
|
||||
export const DEFAULT_OMIT_SIDES = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
};
|
||||
|
||||
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
||||
e: true,
|
||||
s: true,
|
||||
@@ -53,13 +78,6 @@ export const OMIT_SIDES_FOR_FRAME = {
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_LINE_SLASH = {
|
||||
e: true,
|
||||
s: true,
|
||||
@@ -83,19 +101,44 @@ const generateTransformHandle = (
|
||||
height: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
angle: number,
|
||||
angle: Radians,
|
||||
): TransformHandle => {
|
||||
const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
|
||||
const [xx, yy] = pointRotateRads(
|
||||
pointFrom(x + width / 2, y + height / 2),
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
return [xx - width / 2, yy - height / 2, width, height];
|
||||
};
|
||||
|
||||
export const canResizeFromSides = (device: Device) => {
|
||||
if (device.viewport.isMobile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (device.isTouchScreen && (isAndroid || isIOS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getOmitSidesForDevice = (device: Device) => {
|
||||
if (canResizeFromSides(device)) {
|
||||
return DEFAULT_OMIT_SIDES;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const getTransformHandlesFromCoords = (
|
||||
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
||||
angle: number,
|
||||
angle: Radians,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||
margin = 4,
|
||||
spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
): TransformHandles => {
|
||||
const size = transformHandleSizes[pointerType];
|
||||
const handleWidth = size / zoom.value;
|
||||
@@ -107,8 +150,7 @@ export const getTransformHandlesFromCoords = (
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const dashedLineMargin = margin / zoom.value;
|
||||
const centeringOffset =
|
||||
(size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value);
|
||||
const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
|
||||
|
||||
const transformHandles: TransformHandles = {
|
||||
nw: omitSides.nw
|
||||
@@ -232,17 +274,20 @@ export const getTransformHandles = (
|
||||
element: ExcalidrawElement,
|
||||
zoom: Zoom,
|
||||
elementsMap: ElementsMap,
|
||||
|
||||
pointerType: PointerType = "mouse",
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
|
||||
): TransformHandles => {
|
||||
// so that when locked element is selected (especially when you toggle lock
|
||||
// via keyboard) the locked element is visually distinct, indicating
|
||||
// you can't move/resize
|
||||
if (element.locked) {
|
||||
if (
|
||||
element.locked ||
|
||||
// Elbow arrows cannot be rotated
|
||||
isElbowArrow(element)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
||||
if (element.type === "freedraw" || isLinearElement(element)) {
|
||||
if (element.points.length === 2) {
|
||||
// only check the last point because starting point is always (0,0)
|
||||
@@ -259,15 +304,16 @@ export const getTransformHandles = (
|
||||
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
||||
}
|
||||
}
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
} else if (isFrameLikeElement(element)) {
|
||||
omitSides = {
|
||||
...omitSides,
|
||||
rotation: true,
|
||||
};
|
||||
}
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
const margin = isLinearElement(element)
|
||||
? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
|
||||
: isImageElement(element)
|
||||
? 0
|
||||
: DEFAULT_TRANSFORM_HANDLE_SPACING;
|
||||
return getTransformHandlesFromCoords(
|
||||
getElementAbsoluteCoords(element, elementsMap, true),
|
||||
@@ -275,7 +321,8 @@ export const getTransformHandles = (
|
||||
zoom,
|
||||
pointerType,
|
||||
omitSides,
|
||||
dashedLineMargin,
|
||||
margin,
|
||||
isImageElement(element) ? 0 : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -290,6 +337,10 @@ export const shouldShowBoundingBox = (
|
||||
return true;
|
||||
}
|
||||
const element = elements[0];
|
||||
if (isElbowArrow(element)) {
|
||||
// Elbow arrows cannot be resized as single selected elements
|
||||
return false;
|
||||
}
|
||||
if (!isLinearElement(element)) {
|
||||
return true;
|
||||
}
|
||||
+158
-7
@@ -1,8 +1,13 @@
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { ElementOrToolType } from "../types";
|
||||
import { MarkNonNullable } from "../utility-types";
|
||||
import { assertNever } from "../utils";
|
||||
import {
|
||||
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
@@ -20,6 +25,13 @@ import {
|
||||
ExcalidrawIframeElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
@@ -99,12 +111,47 @@ export const isLinearElement = (
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isLineElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLineElement => {
|
||||
return element != null && element.type === "line";
|
||||
};
|
||||
|
||||
export const isArrowElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return element != null && element.type === "arrow";
|
||||
};
|
||||
|
||||
export const isElbowArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawElbowArrowElement => {
|
||||
return isArrowElement(element) && element.elbowed;
|
||||
};
|
||||
|
||||
/**
|
||||
* sharp or curved arrow, but not elbow
|
||||
*/
|
||||
export const isSimpleArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return isArrowElement(element) && !element.elbowed;
|
||||
};
|
||||
|
||||
export const isSharpArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return isArrowElement(element) && !element.elbowed && !element.roundness;
|
||||
};
|
||||
|
||||
export const isCurvedArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return (
|
||||
isArrowElement(element) && !element.elbowed && element.roundness !== null
|
||||
);
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: ElementOrToolType,
|
||||
): boolean => {
|
||||
@@ -131,7 +178,7 @@ export const isBindingElementType = (
|
||||
};
|
||||
|
||||
export const isBindableElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
element: ExcalidrawElement | null | undefined,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
@@ -149,6 +196,40 @@ export const isBindableElement = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isRectanguloidElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
(element.type === "text" && !element.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Remove this when proper distance calculation is introduced
|
||||
// @see binding.ts:distanceToBindableElement()
|
||||
export const isRectangularElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "image" ||
|
||||
element.type === "text" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
element.type === "freedraw")
|
||||
);
|
||||
};
|
||||
|
||||
export const isTextBindableContainer = (
|
||||
element: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
@@ -193,6 +274,16 @@ export const isExcalidrawElement = (
|
||||
}
|
||||
};
|
||||
|
||||
export const isFlowchartNodeElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawFlowchartNodeElement => {
|
||||
return (
|
||||
element.type === "rectangle" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond"
|
||||
);
|
||||
};
|
||||
|
||||
export const hasBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||
@@ -213,6 +304,10 @@ export const isBoundToContainer = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
|
||||
return !!element.startBinding || !!element.endBinding;
|
||||
};
|
||||
|
||||
export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "rectangle" ||
|
||||
type === "embeddable" ||
|
||||
@@ -262,3 +357,59 @@ export const getDefaultRoundnessTypeForElement = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isFixedPointBinding = (
|
||||
binding: PointBinding | FixedPointBinding,
|
||||
): binding is FixedPointBinding => {
|
||||
return (
|
||||
Object.hasOwn(binding, "fixedPoint") &&
|
||||
(binding as FixedPointBinding).fixedPoint != null
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Move this to @excalidraw/math
|
||||
export const isBounds = (box: unknown): box is Bounds =>
|
||||
Array.isArray(box) &&
|
||||
box.length === 4 &&
|
||||
typeof box[0] === "number" &&
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
|
||||
export const getLinearElementSubType = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): ExcalidrawLinearElementSubType => {
|
||||
if (isSharpArrow(element)) {
|
||||
return "sharpArrow";
|
||||
}
|
||||
if (isCurvedArrow(element)) {
|
||||
return "curvedArrow";
|
||||
}
|
||||
if (isElbowArrow(element)) {
|
||||
return "elbowArrow";
|
||||
}
|
||||
return "line";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if current element points meet all the conditions for polygon=true
|
||||
* (this isn't a element type check, for that use isLineElement).
|
||||
*
|
||||
* If you want to check if points *can* be turned into a polygon, use
|
||||
* canBecomePolygon(points).
|
||||
*/
|
||||
export const isValidPolygon = (
|
||||
points: ExcalidrawLineElement["points"],
|
||||
): boolean => {
|
||||
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
|
||||
};
|
||||
|
||||
export const canBecomePolygon = (
|
||||
points: ExcalidrawLineElement["points"],
|
||||
): boolean => {
|
||||
return (
|
||||
points.length > 3 ||
|
||||
// 3-point polygons can't have all points in a single line
|
||||
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
|
||||
);
|
||||
};
|
||||
+165
-20
@@ -1,13 +1,19 @@
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
TEXT_ALIGN,
|
||||
THEME,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
|
||||
import { MagicCacheData } from "../data/magic";
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
MakeBrand,
|
||||
MarkNonNullable,
|
||||
Merge,
|
||||
ValueOf,
|
||||
} from "@excalidraw/common/utility-types";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
@@ -24,6 +30,12 @@ export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||
|
||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||
export type FractionalIndex = string & { _brand: "franctionalIndex" };
|
||||
|
||||
export type BoundElement = Readonly<{
|
||||
id: ExcalidrawLinearElement["id"];
|
||||
type: "arrow" | "text";
|
||||
}>;
|
||||
|
||||
type _ExcalidrawElementBase = Readonly<{
|
||||
id: string;
|
||||
@@ -39,7 +51,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
angle: Radians;
|
||||
/** Random integer used to seed shape generation so that the roughjs shape
|
||||
doesn't differ across renders. */
|
||||
seed: number;
|
||||
@@ -50,18 +62,18 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
Used for deterministic reconciliation of updates during collaboration,
|
||||
in case the versions (see above) are identical. */
|
||||
versionNonce: number;
|
||||
/** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
|
||||
Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
|
||||
Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
|
||||
Could be null, i.e. for new elements which were not yet assigned to the scene. */
|
||||
index: FractionalIndex | null;
|
||||
isDeleted: boolean;
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
frameId: string | null;
|
||||
/** other elements that are bound to this element */
|
||||
boundElements:
|
||||
| readonly Readonly<{
|
||||
id: ExcalidrawLinearElement["id"];
|
||||
type: "arrow" | "text";
|
||||
}>[]
|
||||
| null;
|
||||
boundElements: readonly BoundElement[] | null;
|
||||
/** epoch (ms) timestamp of last element update */
|
||||
updated: number;
|
||||
link: string | null;
|
||||
@@ -90,11 +102,22 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
type: "embeddable";
|
||||
}>;
|
||||
|
||||
export type MagicGenerationData =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| { status: "done"; html: string }
|
||||
| {
|
||||
status: "error";
|
||||
message?: string;
|
||||
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||
};
|
||||
|
||||
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "iframe";
|
||||
// TODO move later to AI-specific frame
|
||||
customData?: { generationData?: MagicCacheData };
|
||||
customData?: { generationData?: MagicGenerationData };
|
||||
}>;
|
||||
|
||||
export type ExcalidrawIframeLikeElement =
|
||||
@@ -105,11 +128,21 @@ export type IframeData =
|
||||
| {
|
||||
intrinsicSize: { w: number; h: number };
|
||||
error?: Error;
|
||||
sandbox?: { allowSameOrigin?: boolean };
|
||||
} & (
|
||||
| { type: "video" | "generic"; link: string }
|
||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||
);
|
||||
|
||||
export type ImageCrop = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "image";
|
||||
@@ -118,6 +151,8 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
status: "pending" | "saved" | "error";
|
||||
/** X and Y scale factors <-1, 1>, used for image axis flipping */
|
||||
scale: [number, number];
|
||||
/** whether an element is cropped */
|
||||
crop: ImageCrop | null;
|
||||
}>;
|
||||
|
||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||
@@ -148,6 +183,21 @@ export type ExcalidrawGenericElement =
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawFlowchartNodeElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawSelectionElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||
@@ -157,6 +207,7 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement
|
||||
@@ -164,6 +215,17 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
export type ExcalidrawNonSelectionElement = Exclude<
|
||||
ExcalidrawElement,
|
||||
ExcalidrawSelectionElement
|
||||
>;
|
||||
|
||||
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
|
||||
index: FractionalIndex;
|
||||
};
|
||||
|
||||
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
};
|
||||
@@ -180,6 +242,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* If `true` the width will fit the text. If `false`, the text will
|
||||
* wrap to fit the width.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
autoResize: boolean;
|
||||
/**
|
||||
* Unitless line height (aligned to W3C). To get line height in px, multiply
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
@@ -208,12 +277,33 @@ export type ExcalidrawTextElementWithContainer = {
|
||||
containerId: ExcalidrawTextContainer["id"];
|
||||
} & ExcalidrawTextElement;
|
||||
|
||||
export type FixedPoint = [number, number];
|
||||
|
||||
export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
focus: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
export type FixedPointBinding = Merge<
|
||||
PointBinding,
|
||||
{
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
}
|
||||
>;
|
||||
|
||||
type Index = number;
|
||||
|
||||
export type PointsPositionUpdates = Map<
|
||||
Index,
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
@@ -223,31 +313,73 @@ export type Arrowhead =
|
||||
| "triangle"
|
||||
| "triangle_outline"
|
||||
| "diamond"
|
||||
| "diamond_outline";
|
||||
| "diamond_outline"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "line" | "arrow";
|
||||
points: readonly Point[];
|
||||
lastCommittedPoint: Point | null;
|
||||
points: readonly LocalPoint[];
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
startBinding: PointBinding | null;
|
||||
endBinding: PointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawLineElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "line";
|
||||
polygon: boolean;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: Index;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "arrow";
|
||||
elbowed: boolean;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawElbowArrowElement = Merge<
|
||||
ExcalidrawArrowElement,
|
||||
{
|
||||
elbowed: true;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | 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
|
||||
* the data from the points array. It allows creating the expected arrow
|
||||
* path when the arrow with fixed segments is bound on a horizontal side and
|
||||
* moved to a vertical and vica versa.
|
||||
*/
|
||||
startIsSpecial: boolean | null;
|
||||
/**
|
||||
* Marks that the 3rd point backwards from the end should be used as the 2nd
|
||||
* point of the arrow in order to temporarily hide the last segment of the
|
||||
* arrow without losing the data from the points array. It allows creating
|
||||
* the expected arrow path when the arrow with fixed segments is bound on a
|
||||
* horizontal side and moved to a vertical and vica versa.
|
||||
*/
|
||||
endIsSpecial: boolean | null;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "freedraw";
|
||||
points: readonly Point[];
|
||||
points: readonly LocalPoint[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: Point | null;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
@@ -275,7 +407,10 @@ export type NonDeletedElementsMap = Map<
|
||||
* Map of all excalidraw Scene elements, including deleted.
|
||||
* Not a subset. Use this type when you need access to current Scene elements.
|
||||
*/
|
||||
export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
|
||||
export type SceneElementsMap = Map<
|
||||
ExcalidrawElement["id"],
|
||||
Ordered<ExcalidrawElement>
|
||||
> &
|
||||
MakeBrand<"SceneElementsMap">;
|
||||
|
||||
/**
|
||||
@@ -284,10 +419,20 @@ export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
|
||||
*/
|
||||
export type NonDeletedSceneElementsMap = Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
Ordered<NonDeletedExcalidrawElement>
|
||||
> &
|
||||
MakeBrand<"NonDeletedSceneElementsMap">;
|
||||
|
||||
export type ElementsMapOrArray =
|
||||
| readonly ExcalidrawElement[]
|
||||
| Readonly<ElementsMap>;
|
||||
|
||||
export type ExcalidrawLinearElementSubType =
|
||||
| "line"
|
||||
| "sharpArrow"
|
||||
| "curvedArrow"
|
||||
| "elbowArrow";
|
||||
|
||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
|
||||
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
|
||||
@@ -0,0 +1,483 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
rectangle,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||
|
||||
const ElementShapesCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
|
||||
>();
|
||||
|
||||
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
offset: number,
|
||||
): ElementShape | undefined => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.delete(element);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return shapes.get(offset);
|
||||
};
|
||||
|
||||
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: ElementShape,
|
||||
offset: number,
|
||||
) => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
shapes.set(offset, shape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||
*
|
||||
* @param element The linear element to deconstruct
|
||||
* @returns The rotated in components.
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
|
||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||
const op = ops[idx];
|
||||
const prevPoint =
|
||||
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
|
||||
switch (op.op) {
|
||||
case "move":
|
||||
continue;
|
||||
case "lineTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
case "bcurveTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
curves.push(
|
||||
curve<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[2],
|
||||
element.y + op.data[3],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[4],
|
||||
element.y + op.data[5],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
default: {
|
||||
console.error("Unknown op type", op.op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shape = [lines, curves] as ElementShape;
|
||||
setElementShapesCacheEntry(element, shape, 0);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves **unrotated**.
|
||||
*
|
||||
* @param element Target rectanguloid element
|
||||
* @param offset Optional offset to expand the rectanguloid shape
|
||||
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
let radius = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
}
|
||||
|
||||
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] + radius, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||
);
|
||||
|
||||
const baseCorners = [
|
||||
curve(
|
||||
left[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
top[0],
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
top[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]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
),
|
||||
right[0],
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
right[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
bottom[1],
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
bottom[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
left[0],
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[0][corners[0].length - 1][3],
|
||||
corners[1][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[1][corners[1].length - 1][3],
|
||||
corners[2][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[2][corners[2].length - 1][3],
|
||||
corners[3][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[3][corners[3].length - 1][3],
|
||||
corners[0][0][0],
|
||||
),
|
||||
];
|
||||
const shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
||||
: (topX - leftX) * 0.01;
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
|
||||
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 baseCorners = [
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
right,
|
||||
right,
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
),
|
||||
), // RIGHT
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
bottom,
|
||||
bottom,
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
), // BOTTOM
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
left,
|
||||
left,
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
),
|
||||
), // LEFT
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
top,
|
||||
top,
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[0][corners[0].length - 1][3],
|
||||
corners[1][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[1][corners[1].length - 1][3],
|
||||
corners[2][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[2][corners[2].length - 1][3],
|
||||
corners[3][0][0],
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
corners[3][corners[3].length - 1][3],
|
||||
corners[0][0][0],
|
||||
),
|
||||
];
|
||||
|
||||
const shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (
|
||||
points: ExcalidrawLinearElement["points"],
|
||||
/** supply if you want the loop detection to account for current zoom */
|
||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||
): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [first, last] = [points[0], points[points.length - 1]];
|
||||
const distance = pointDistance(first, last);
|
||||
|
||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||
// really close we make the threshold smaller, and vice versa.
|
||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||
element.roundness?.type === ROUNDNESS.LEGACY
|
||||
) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||
|
||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||
|
||||
if (x <= CUTOFF_SIZE) {
|
||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||
}
|
||||
|
||||
return fixedRadiusSize;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
+80
-54
@@ -1,11 +1,18 @@
|
||||
import { bumpVersion } from "./element/mutateElement";
|
||||
import { isFrameLikeElement } from "./element/typeChecks";
|
||||
import { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./element/types";
|
||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isFrameLikeElement } from "./typeChecks";
|
||||
|
||||
import { getElementsInGroup } from "./groups";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import Scene from "./scene/Scene";
|
||||
import { AppState } from "./types";
|
||||
import { arrayToMap, findIndex, findLastIndex } from "./utils";
|
||||
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
|
||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||
return element.frameId === frameId || element.id === frameId;
|
||||
@@ -75,31 +82,38 @@ const getTargetIndexAccountingForBinding = (
|
||||
nextElement: ExcalidrawElement,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
direction: "left" | "right",
|
||||
scene: Scene,
|
||||
) => {
|
||||
if ("containerId" in nextElement && nextElement.containerId) {
|
||||
if (direction === "left") {
|
||||
const containerElement = Scene.getScene(nextElement)!.getElement(
|
||||
nextElement.containerId,
|
||||
);
|
||||
if (containerElement) {
|
||||
return elements.indexOf(containerElement);
|
||||
}
|
||||
} else {
|
||||
return elements.indexOf(nextElement);
|
||||
// TODO: why not to get the container from the nextElements?
|
||||
const containerElement = scene.getElement(nextElement.containerId);
|
||||
if (containerElement) {
|
||||
return direction === "left"
|
||||
? Math.min(
|
||||
elements.indexOf(containerElement),
|
||||
elements.indexOf(nextElement),
|
||||
)
|
||||
: Math.max(
|
||||
elements.indexOf(containerElement),
|
||||
elements.indexOf(nextElement),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const boundElementId = nextElement.boundElements?.find(
|
||||
(binding) => binding.type !== "arrow",
|
||||
)?.id;
|
||||
if (boundElementId) {
|
||||
if (direction === "left") {
|
||||
return elements.indexOf(nextElement);
|
||||
}
|
||||
|
||||
const boundTextElement =
|
||||
Scene.getScene(nextElement)!.getElement(boundElementId);
|
||||
const boundTextElement = scene.getElement(boundElementId);
|
||||
if (boundTextElement) {
|
||||
return elements.indexOf(boundTextElement);
|
||||
return direction === "left"
|
||||
? Math.min(
|
||||
elements.indexOf(boundTextElement),
|
||||
elements.indexOf(nextElement),
|
||||
)
|
||||
: Math.max(
|
||||
elements.indexOf(boundTextElement),
|
||||
elements.indexOf(nextElement),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,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];
|
||||
|
||||
@@ -178,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
|
||||
@@ -202,8 +221,12 @@ const getTargetIndex = (
|
||||
|
||||
if (!nextElement.groupIds.length) {
|
||||
return (
|
||||
getTargetIndexAccountingForBinding(nextElement, elements, direction) ??
|
||||
candidateIndex
|
||||
getTargetIndexAccountingForBinding(
|
||||
nextElement,
|
||||
elements,
|
||||
direction,
|
||||
scene,
|
||||
) ?? candidateIndex
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,18 +257,20 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
|
||||
) => {
|
||||
return indices.reduce((acc, index) => {
|
||||
const element = elements[index];
|
||||
acc[element.id] = element;
|
||||
acc.set(element.id, element);
|
||||
return acc;
|
||||
}, {} as Record<string, ExcalidrawElement>);
|
||||
}, new Map<string, ExcalidrawElement>());
|
||||
};
|
||||
|
||||
const shiftElementsByOne = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
direction: "left" | "right",
|
||||
scene: Scene,
|
||||
) => {
|
||||
const indicesToMove = getIndicesToMove(elements, appState);
|
||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||
|
||||
let groupedIndices = toContiguousGroups(indicesToMove);
|
||||
|
||||
if (direction === "right") {
|
||||
@@ -276,6 +301,7 @@ const shiftElementsByOne = (
|
||||
boundaryIndex,
|
||||
direction,
|
||||
containingFrame,
|
||||
scene,
|
||||
);
|
||||
|
||||
if (targetIndex === -1 || boundaryIndex === targetIndex) {
|
||||
@@ -312,12 +338,9 @@ const shiftElementsByOne = (
|
||||
];
|
||||
});
|
||||
|
||||
return elements.map((element) => {
|
||||
if (targetElementsMap[element.id]) {
|
||||
return bumpVersion(element);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
syncMovedIndices(elements, targetElementsMap);
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
const shiftElementsToEnd = (
|
||||
@@ -383,26 +406,27 @@ const shiftElementsToEnd = (
|
||||
}
|
||||
}
|
||||
|
||||
const targetElements = Object.values(targetElementsMap).map((element) => {
|
||||
return bumpVersion(element);
|
||||
});
|
||||
|
||||
const targetElements = Array.from(targetElementsMap.values());
|
||||
const leadingElements = elements.slice(0, leadingIndex);
|
||||
const trailingElements = elements.slice(trailingIndex + 1);
|
||||
const nextElements =
|
||||
direction === "left"
|
||||
? [
|
||||
...leadingElements,
|
||||
...targetElements,
|
||||
...displacedElements,
|
||||
...trailingElements,
|
||||
]
|
||||
: [
|
||||
...leadingElements,
|
||||
...displacedElements,
|
||||
...targetElements,
|
||||
...trailingElements,
|
||||
];
|
||||
|
||||
return direction === "left"
|
||||
? [
|
||||
...leadingElements,
|
||||
...targetElements,
|
||||
...displacedElements,
|
||||
...trailingElements,
|
||||
]
|
||||
: [
|
||||
...leadingElements,
|
||||
...displacedElements,
|
||||
...targetElements,
|
||||
...trailingElements,
|
||||
];
|
||||
syncMovedIndices(nextElements, targetElementsMap);
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
||||
function shiftElementsAccountingForFrames(
|
||||
@@ -491,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 = (
|
||||
@@ -0,0 +1,57 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when clicked on arrow and enter pressed 1`] = `
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when double clicked 1`] = `
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should match styles for text editor 1`] = `
|
||||
<textarea
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 2`] = `
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 6`] = `
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 2`] = `
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 5`] = `
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 1`] = `
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`;
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = `
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`;
|
||||
+51
-39
@@ -1,10 +1,5 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import { 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,
|
||||
@@ -13,9 +8,17 @@ import {
|
||||
actionAlignBottom,
|
||||
actionAlignLeft,
|
||||
actionAlignRight,
|
||||
} from "../actions";
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
const { h } = window;
|
||||
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");
|
||||
|
||||
@@ -32,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -49,17 +53,19 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
describe("aligning", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
unmountComponent();
|
||||
mouse.reset();
|
||||
|
||||
await setLanguage(defaultLang);
|
||||
await act(() => {
|
||||
return setLanguage(defaultLang);
|
||||
});
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
@@ -156,7 +162,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
// Check if x position did not change
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
@@ -175,7 +181,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(60);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(55);
|
||||
@@ -198,10 +204,11 @@ describe("aligning", () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
@@ -211,6 +218,7 @@ describe("aligning", () => {
|
||||
// Add the created group to the current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -222,7 +230,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignTop);
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
@@ -236,7 +244,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignBottom);
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
@@ -250,7 +258,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignLeft);
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
@@ -264,7 +272,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignRight);
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
@@ -278,7 +286,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
@@ -292,7 +300,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
@@ -312,10 +320,11 @@ describe("aligning", () => {
|
||||
// The second rectangle is already selected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
@@ -326,17 +335,18 @@ describe("aligning", () => {
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
mouse.restorePosition(200, 200);
|
||||
mouse.restorePosition(210, 200);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
// Select the first group.
|
||||
// The second group is already selected because it was the last group created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -349,7 +359,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignTop);
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
@@ -365,7 +375,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignBottom);
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(300);
|
||||
@@ -381,7 +391,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignLeft);
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
@@ -397,7 +407,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignRight);
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(300);
|
||||
@@ -413,7 +423,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
@@ -429,7 +439,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
@@ -450,11 +460,12 @@ describe("aligning", () => {
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
// Create first group of rectangles
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
@@ -462,13 +473,13 @@ describe("aligning", () => {
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
mouse.restorePosition(10, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
// Create the nested group
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
@@ -478,6 +489,7 @@ describe("aligning", () => {
|
||||
// Select the nested group, the rectangle is already selected
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
@@ -490,7 +502,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignTop);
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
@@ -506,7 +518,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignBottom);
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
@@ -522,7 +534,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignLeft);
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
@@ -538,7 +550,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignRight);
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
@@ -554,7 +566,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
@@ -570,7 +582,7 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
+167
-61
@@ -1,11 +1,20 @@
|
||||
import { fireEvent, render } from "./test-utils";
|
||||
import { Excalidraw } 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 { 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";
|
||||
import {
|
||||
getTextEditor,
|
||||
TEXT_EDITOR_SELECTOR,
|
||||
} from "../../excalidraw/tests/queries/dom";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -20,6 +29,7 @@ describe("element binding", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
@@ -30,40 +40,52 @@ describe("element binding", () => {
|
||||
width: 100,
|
||||
height: 1,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[100, 0],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, 0),
|
||||
pointFrom(100, 0),
|
||||
pointFrom(100, 0),
|
||||
],
|
||||
});
|
||||
h.elements = [rect, arrow];
|
||||
API.setElements([rect, arrow]);
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
|
||||
API.setSelectedElements([arrow]);
|
||||
// select arrow
|
||||
mouse.clickAt(150, 0);
|
||||
|
||||
expect(API.getSelectedElements()).toEqual([arrow]);
|
||||
// move arrow start to potential binding position
|
||||
mouse.downAt(100, 0);
|
||||
mouse.moveTo(55, 0);
|
||||
mouse.up(0, 0);
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
|
||||
mouse.downAt(100, 0);
|
||||
mouse.move(-45, 0);
|
||||
mouse.up();
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
|
||||
mouse.down();
|
||||
mouse.move(-50, 0);
|
||||
mouse.up();
|
||||
// Point selection is evaluated like the points are rendered,
|
||||
// from right to left. So clicking on the first point should move the joint,
|
||||
// not the start point.
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
|
||||
// Now that the start point is free, move it into overlapping position
|
||||
mouse.downAt(100, 0);
|
||||
mouse.moveTo(55, 0);
|
||||
mouse.up(0, 0);
|
||||
|
||||
expect(API.getSelectedElements()).toEqual([arrow]);
|
||||
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
|
||||
// Move the end point to the overlapping binding position
|
||||
mouse.downAt(200, 0);
|
||||
mouse.moveTo(55, 0);
|
||||
mouse.up(0, 0);
|
||||
|
||||
// Both the start and the end points should be bound
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
expect(arrow.endBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
@@ -143,7 +165,7 @@ describe("element binding", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("should bind/unbind arrow when moving it with keyboard", () => {
|
||||
it("should unbind arrow when moving it with keyboard", () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: 75,
|
||||
y: 0,
|
||||
@@ -154,16 +176,27 @@ describe("element binding", () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 50,
|
||||
size: 49,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
|
||||
mouse.downAt(49, 49);
|
||||
mouse.moveTo(51, 0);
|
||||
mouse.up(0, 0);
|
||||
|
||||
// Test sticky connection
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
|
||||
// Sever connection
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
@@ -198,7 +231,7 @@ describe("element binding", () => {
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [text];
|
||||
API.setElements([text]);
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
@@ -215,18 +248,12 @@ describe("element binding", () => {
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
const editor = await getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
@@ -240,7 +267,7 @@ describe("element binding", () => {
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [text];
|
||||
API.setElements([text]);
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
@@ -256,18 +283,14 @@ describe("element binding", () => {
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = await getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
|
||||
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||
});
|
||||
|
||||
@@ -286,38 +309,36 @@ describe("element binding", () => {
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -87.45777932247563],
|
||||
],
|
||||
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -87.45777932247563],
|
||||
],
|
||||
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
|
||||
startBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -331,13 +352,13 @@ describe("element binding", () => {
|
||||
],
|
||||
});
|
||||
|
||||
h.elements = [rectangle1, arrow1, arrow2, text1];
|
||||
API.setElements([rectangle1, arrow1, arrow2, text1]);
|
||||
|
||||
API.setSelectedElements([text1]);
|
||||
|
||||
expect(h.state.selectedElementIds[text1.id]).toBe(true);
|
||||
|
||||
h.app.actionManager.executeAction(actionWrapTextInContainer);
|
||||
API.executeAction(actionWrapTextInContainer);
|
||||
|
||||
// new text container will be placed before the text element
|
||||
const container = h.elements.at(-2)!;
|
||||
@@ -369,4 +390,89 @@ describe("element binding", () => {
|
||||
expect(arrow2.startBinding?.elementId).toBe(container.id);
|
||||
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
|
||||
});
|
||||
|
||||
// #6459
|
||||
it("should unbind arrow only from the latest element", () => {
|
||||
const rectLeft = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const rectRight = UI.createElement("rectangle", {
|
||||
x: 400,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 250,
|
||||
width: 180,
|
||||
height: 1,
|
||||
});
|
||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||
|
||||
// Drag arrow off of bound rectangle range
|
||||
const handles = getTransformHandles(
|
||||
arrow,
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
).se!;
|
||||
|
||||
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
||||
const elX = handles[0] + handles[2] / 2;
|
||||
const elY = handles[1] + handles[3] / 2;
|
||||
mouse.downAt(elX, elY);
|
||||
mouse.moveTo(300, 400);
|
||||
mouse.up();
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
it("should not unbind when duplicating via selection group", () => {
|
||||
const rectLeft = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const rectRight = UI.createElement("rectangle", {
|
||||
x: 400,
|
||||
y: 200,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 250,
|
||||
width: 177,
|
||||
height: 1,
|
||||
});
|
||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||
|
||||
mouse.downAt(-100, -100);
|
||||
mouse.moveTo(650, 750);
|
||||
mouse.up(0, 0);
|
||||
|
||||
expect(API.getSelectedElements().length).toBe(3);
|
||||
|
||||
mouse.moveTo(5, 5);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.downAt(5, 5);
|
||||
mouse.moveTo(1000, 1000);
|
||||
mouse.up(0, 0);
|
||||
|
||||
expect(window.h.elements.length).toBe(6);
|
||||
window.h.elements.forEach((element) => {
|
||||
if (isLinearElement(element)) {
|
||||
expect(element.startBinding).not.toBe(null);
|
||||
expect(element.endBinding).not.toBe(null);
|
||||
} else {
|
||||
expect(element.boundElements).not.toBe(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
-7
@@ -1,7 +1,12 @@
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { 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,
|
||||
@@ -123,9 +128,9 @@ describe("getElementBounds", () => {
|
||||
a: 0.6447741904932416,
|
||||
}),
|
||||
points: [
|
||||
[0, 0] as [number, number],
|
||||
[67.33984375, 92.48828125] as [number, number],
|
||||
[-102.7890625, 52.15625] as [number, number],
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(67.33984375, 92.48828125),
|
||||
pointFrom<LocalPoint>(-102.7890625, 52.15625),
|
||||
],
|
||||
} as ExcalidrawLinearElement;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import "@excalidraw/utils/test-utils";
|
||||
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { hitElementItself } from "../src/collision";
|
||||
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("arrow", () => {
|
||||
UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 124,
|
||||
height: 302,
|
||||
angle: 1.8700426423973724,
|
||||
points: [
|
||||
[0, 0],
|
||||
[120, -198],
|
||||
[-4, -302],
|
||||
] as LocalPoint[],
|
||||
});
|
||||
//const p = [120, -211];
|
||||
//const p = [0, 13];
|
||||
const hit = hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(88, -68),
|
||||
element: window.h.elements[0],
|
||||
threshold: 10,
|
||||
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||
});
|
||||
expect(hit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { KEYS, cloneJSON } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
Excalidraw,
|
||||
exportToCanvas,
|
||||
exportToSvg,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import {
|
||||
actionFlipHorizontal,
|
||||
actionFlipVertical,
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
GlobalTestState,
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { duplicateElement } from "../src/duplicate";
|
||||
|
||||
import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
unmountComponent();
|
||||
|
||||
mouse.reset();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
});
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
API.setAppState({
|
||||
zoom: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
},
|
||||
});
|
||||
|
||||
const image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||
API.setElements([image]);
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[image.id]: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const generateRandomNaturalWidthAndHeight = (image: ExcalidrawImageElement) => {
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const scale = 1 + Math.random() * 5;
|
||||
|
||||
return {
|
||||
naturalWidth: initialWidth * scale,
|
||||
naturalHeight: initialHeight * scale,
|
||||
};
|
||||
};
|
||||
|
||||
const compareCrops = (cropA: ImageCrop, cropB: ImageCrop) => {
|
||||
(Object.keys(cropA) as [keyof ImageCrop]).forEach((key) => {
|
||||
const propA = cropA[key];
|
||||
const propB = cropB[key];
|
||||
|
||||
expect(propA as number).toBeCloseTo(propB as number);
|
||||
});
|
||||
};
|
||||
|
||||
describe("Enter and leave the crop editor", () => {
|
||||
it("enter the editor by double clicking", () => {
|
||||
const image = h.elements[0];
|
||||
expect(h.state.croppingElementId).toBe(null);
|
||||
mouse.doubleClickOn(image);
|
||||
expect(h.state.croppingElementId).not.toBe(null);
|
||||
expect(h.state.croppingElementId).toBe(image.id);
|
||||
});
|
||||
|
||||
it("enter the editor by pressing enter", () => {
|
||||
const image = h.elements[0];
|
||||
expect(h.state.croppingElementId).toBe(null);
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
expect(h.state.croppingElementId).not.toBe(null);
|
||||
expect(h.state.croppingElementId).toBe(image.id);
|
||||
});
|
||||
|
||||
it("leave the editor by clicking outside", () => {
|
||||
const image = h.elements[0];
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
expect(h.state.croppingElementId).not.toBe(null);
|
||||
|
||||
mouse.click(image.x - 20, image.y - 20);
|
||||
expect(h.state.croppingElementId).toBe(null);
|
||||
});
|
||||
|
||||
it("leave the editor by pressing escape", () => {
|
||||
const image = h.elements[0];
|
||||
mouse.doubleClickOn(image);
|
||||
expect(h.state.croppingElementId).not.toBe(null);
|
||||
|
||||
Keyboard.keyDown(KEYS.ESCAPE);
|
||||
expect(h.state.croppingElementId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Crop an image", () => {
|
||||
it("Cropping changes the dimension", async () => {
|
||||
const image = h.elements[0] as ExcalidrawImageElement;
|
||||
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const { naturalWidth, naturalHeight } =
|
||||
generateRandomNaturalWidthAndHeight(image);
|
||||
|
||||
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 2, 0]);
|
||||
|
||||
expect(image.width).toBeLessThan(initialWidth);
|
||||
UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight / 2]);
|
||||
expect(image.height).toBeLessThan(initialHeight);
|
||||
});
|
||||
|
||||
it("Cropping has minimal sizes", async () => {
|
||||
const image = h.elements[0] as ExcalidrawImageElement;
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const { naturalWidth, naturalHeight } =
|
||||
generateRandomNaturalWidthAndHeight(image);
|
||||
|
||||
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth, 0]);
|
||||
expect(image.width).toBeLessThan(initialWidth);
|
||||
expect(image.width).toBeGreaterThan(0);
|
||||
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth, 0]);
|
||||
UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight]);
|
||||
expect(image.height).toBeLessThan(initialHeight);
|
||||
expect(image.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Preserve aspect ratio", async () => {
|
||||
let image = h.elements[0] as ExcalidrawImageElement;
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const { naturalWidth, naturalHeight } =
|
||||
generateRandomNaturalWidthAndHeight(image);
|
||||
|
||||
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 3, 0]);
|
||||
|
||||
let resizedWidth = image.width;
|
||||
let resizedHeight = image.height;
|
||||
|
||||
// max height, cropping should not change anything
|
||||
UI.crop(
|
||||
image,
|
||||
"w",
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
[-initialWidth / 3, 0],
|
||||
true,
|
||||
);
|
||||
expect(image.width).toBeCloseTo(resizedWidth, 10);
|
||||
expect(image.height).toBeCloseTo(resizedHeight, 10);
|
||||
|
||||
// re-crop to initial state
|
||||
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
|
||||
// change crop height and width
|
||||
UI.crop(image, "s", naturalWidth, naturalHeight, [0, -initialHeight / 2]);
|
||||
UI.crop(image, "e", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
|
||||
|
||||
resizedWidth = image.width;
|
||||
resizedHeight = image.height;
|
||||
|
||||
// test corner handle aspect ratio preserving
|
||||
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
|
||||
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
|
||||
expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
|
||||
expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
|
||||
|
||||
// reset
|
||||
image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||
API.setElements([image]);
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[image.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 50 x 50 square
|
||||
UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
|
||||
UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
|
||||
expect(image.width).toBeCloseTo(image.height);
|
||||
// image is at the corner, not space to its right to expand, should not be able to resize
|
||||
expect(image.height).toBeCloseTo(50);
|
||||
|
||||
UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
|
||||
expect(image.width).toBeCloseTo(image.height);
|
||||
// max height should be reached
|
||||
expect(image.height).toBeCloseTo(initialHeight);
|
||||
expect(image.width).toBeCloseTo(initialHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cropping and other features", async () => {
|
||||
it("Cropping works independently of duplication", async () => {
|
||||
const image = h.elements[0] as ExcalidrawImageElement;
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const { naturalWidth, naturalHeight } =
|
||||
generateRandomNaturalWidthAndHeight(image);
|
||||
|
||||
UI.crop(image, "nw", naturalWidth, naturalHeight, [
|
||||
initialWidth / 2,
|
||||
initialHeight / 2,
|
||||
]);
|
||||
Keyboard.keyDown(KEYS.ESCAPE);
|
||||
const duplicatedImage = duplicateElement(null, new Map(), image);
|
||||
act(() => {
|
||||
h.app.scene.insertElement(duplicatedImage);
|
||||
});
|
||||
|
||||
expect(duplicatedImage.width).toBe(image.width);
|
||||
expect(duplicatedImage.height).toBe(image.height);
|
||||
|
||||
UI.crop(duplicatedImage, "nw", naturalWidth, naturalHeight, [
|
||||
-initialWidth / 2,
|
||||
-initialHeight / 2,
|
||||
]);
|
||||
expect(duplicatedImage.width).toBe(initialWidth);
|
||||
expect(duplicatedImage.height).toBe(initialHeight);
|
||||
const resizedWidth = image.width;
|
||||
const resizedHeight = image.height;
|
||||
|
||||
expect(image.width).not.toBe(duplicatedImage.width);
|
||||
expect(image.height).not.toBe(duplicatedImage.height);
|
||||
UI.crop(duplicatedImage, "se", naturalWidth, naturalHeight, [
|
||||
-initialWidth / 1.5,
|
||||
-initialHeight / 1.5,
|
||||
]);
|
||||
expect(duplicatedImage.width).not.toBe(initialWidth);
|
||||
expect(image.width).toBe(resizedWidth);
|
||||
expect(duplicatedImage.height).not.toBe(initialHeight);
|
||||
expect(image.height).toBe(resizedHeight);
|
||||
});
|
||||
|
||||
it("Resizing should not affect crop", async () => {
|
||||
const image = h.elements[0] as ExcalidrawImageElement;
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const { naturalWidth, naturalHeight } =
|
||||
generateRandomNaturalWidthAndHeight(image);
|
||||
|
||||
UI.crop(image, "nw", naturalWidth, naturalHeight, [
|
||||
initialWidth / 2,
|
||||
initialHeight / 2,
|
||||
]);
|
||||
const cropBeforeResizing = image.crop;
|
||||
const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
|
||||
expect(cropBeforeResizing).not.toBe(null);
|
||||
|
||||
UI.crop(image, "e", naturalWidth, naturalHeight, [200, 0]);
|
||||
expect(cropBeforeResizing).toBe(image.crop);
|
||||
compareCrops(cropBeforeResizingCloned, image.crop!);
|
||||
|
||||
UI.resize(image, "s", [0, -100]);
|
||||
expect(cropBeforeResizing).toBe(image.crop);
|
||||
compareCrops(cropBeforeResizingCloned, image.crop!);
|
||||
|
||||
UI.resize(image, "ne", [-50, -50]);
|
||||
expect(cropBeforeResizing).toBe(image.crop);
|
||||
compareCrops(cropBeforeResizingCloned, image.crop!);
|
||||
});
|
||||
|
||||
it("Flipping does not change crop", async () => {
|
||||
const image = h.elements[0] as ExcalidrawImageElement;
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const { naturalWidth, naturalHeight } =
|
||||
generateRandomNaturalWidthAndHeight(image);
|
||||
|
||||
mouse.doubleClickOn(image);
|
||||
expect(h.state.croppingElementId).not.toBe(null);
|
||||
UI.crop(image, "nw", naturalWidth, naturalHeight, [
|
||||
initialWidth / 2,
|
||||
initialHeight / 2,
|
||||
]);
|
||||
Keyboard.keyDown(KEYS.ESCAPE);
|
||||
const cropBeforeResizing = image.crop;
|
||||
const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(image.crop).toBe(cropBeforeResizing);
|
||||
compareCrops(cropBeforeResizingCloned, image.crop!);
|
||||
|
||||
API.executeAction(actionFlipVertical);
|
||||
expect(image.crop).toBe(cropBeforeResizing);
|
||||
compareCrops(cropBeforeResizingCloned, image.crop!);
|
||||
});
|
||||
|
||||
it("Exports should preserve crops", async () => {
|
||||
const image = h.elements[0] as ExcalidrawImageElement;
|
||||
const initialWidth = image.width;
|
||||
const initialHeight = image.height;
|
||||
|
||||
const { naturalWidth, naturalHeight } =
|
||||
generateRandomNaturalWidthAndHeight(image);
|
||||
|
||||
mouse.doubleClickOn(image);
|
||||
expect(h.state.croppingElementId).not.toBe(null);
|
||||
UI.crop(image, "nw", naturalWidth, naturalHeight, [
|
||||
initialWidth / 2,
|
||||
initialHeight / 4,
|
||||
]);
|
||||
Keyboard.keyDown(KEYS.ESCAPE);
|
||||
const widthToHeightRatio = image.width / image.height;
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
elements: [image],
|
||||
// @ts-ignore
|
||||
appState: h.state,
|
||||
files: h.app.files,
|
||||
exportPadding: 0,
|
||||
});
|
||||
const exportedCanvasRatio = canvas.width / canvas.height;
|
||||
|
||||
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: [image],
|
||||
// @ts-ignore
|
||||
appState: h.state,
|
||||
files: h.app.files,
|
||||
exportPadding: 0,
|
||||
});
|
||||
const svgWidth = svg.getAttribute("width");
|
||||
const svgHeight = svg.getAttribute("height");
|
||||
|
||||
expect(svgWidth).toBeDefined();
|
||||
expect(svgHeight).toBeDefined();
|
||||
|
||||
const exportedSvgRatio = Number(svgWidth) / Number(svgHeight);
|
||||
expect(widthToHeightRatio).toBeCloseTo(exportedSvgRatio);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
editingLinearElementId: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
|
||||
it("should maintain stable order for selectedElementIds", () => {
|
||||
const commonAppState = {
|
||||
name: "",
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: { id5: true, id2: true, id4: true },
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: {
|
||||
id1: true,
|
||||
id2: true,
|
||||
id3: true,
|
||||
},
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: { id4: true, id2: true, id5: true },
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: {
|
||||
id3: true,
|
||||
id2: true,
|
||||
id1: true,
|
||||
},
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
|
||||
it("should maintain stable order for selectedGroupIds", () => {
|
||||
const commonAppState = {
|
||||
name: "",
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: {
|
||||
id0: true,
|
||||
id1: true,
|
||||
id2: false,
|
||||
id3: true,
|
||||
},
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: {
|
||||
id3: true,
|
||||
id2: false,
|
||||
id1: true,
|
||||
id0: true,
|
||||
},
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,849 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
ORIG_ID,
|
||||
ROUNDNESS,
|
||||
isPrimitive,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { UI, 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 { 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) {
|
||||
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
||||
expect(clone[key]).not.toBe(source[key]);
|
||||
if (source[key]) {
|
||||
assertCloneObjects(source[key], clone[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("duplicating single elements", () => {
|
||||
it("clones arrow element", () => {
|
||||
const element = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, new Map(), {
|
||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element, true);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
// assert we clone the object's prototype
|
||||
// @ts-ignore
|
||||
expect(copy.__proto__).toEqual({ hello: "world" });
|
||||
expect(copy.hasOwnProperty("hello")).toBe(false);
|
||||
|
||||
expect(copy.points).not.toBe(element.points);
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(copy.seed).not.toBe(element.seed);
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
expect(copy).toEqual({
|
||||
...element,
|
||||
id: copy.id,
|
||||
seed: copy.seed,
|
||||
version: copy.version,
|
||||
versionNonce: copy.versionNonce,
|
||||
});
|
||||
});
|
||||
|
||||
it("clones text element", () => {
|
||||
const element = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: null,
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
text: "hello",
|
||||
fontSize: 20,
|
||||
fontFamily: FONT_FAMILY.Virgil,
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
expect(copy).not.toHaveProperty("points");
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicating multiple elements", () => {
|
||||
it("duplicateElements should clone bindings", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
{ id: "text1", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle1",
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
boundElements: [{ id: "text2", type: "text" }],
|
||||
});
|
||||
|
||||
const text2 = API.createElement({
|
||||
type: "text",
|
||||
id: "text2",
|
||||
containerId: "arrow2",
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||
const { duplicatedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
});
|
||||
|
||||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
expect(origElements.map((e) => e.type)).toEqual(
|
||||
duplicatedElements.map((e) => e.type),
|
||||
);
|
||||
origElements.forEach((origElement, idx) => {
|
||||
const clonedElement = duplicatedElements[idx];
|
||||
expect(origElement).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(clonedElement.id),
|
||||
type: clonedElement.type,
|
||||
}),
|
||||
);
|
||||
if ("containerId" in origElement) {
|
||||
expect(origElement.containerId).not.toBe(
|
||||
(clonedElement as any).containerId,
|
||||
);
|
||||
}
|
||||
if ("endBinding" in origElement) {
|
||||
if (origElement.endBinding) {
|
||||
expect(origElement.endBinding.elementId).not.toBe(
|
||||
(clonedElement as any).endBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).endBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
if ("startBinding" in origElement) {
|
||||
if (origElement.startBinding) {
|
||||
expect(origElement.startBinding.elementId).not.toBe(
|
||||
(clonedElement as any).startBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).startBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const clonedArrows = duplicatedElements.filter(
|
||||
(e) => e.type === "arrow",
|
||||
) as ExcalidrawLinearElement[];
|
||||
|
||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
||||
duplicatedElements as any as typeof origElements;
|
||||
|
||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: clonedText1.id,
|
||||
type: clonedText1.type,
|
||||
}),
|
||||
);
|
||||
expect(clonedRectangle.type).toBe("rectangle");
|
||||
|
||||
clonedArrows.forEach((arrow) => {
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: arrow.id,
|
||||
type: arrow.type,
|
||||
}),
|
||||
);
|
||||
|
||||
if (arrow.endBinding) {
|
||||
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
if (arrow.startBinding) {
|
||||
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
});
|
||||
|
||||
expect(clonedArrow2.boundElements).toEqual([
|
||||
{ type: "text", id: clonedArrowLabel.id },
|
||||
]);
|
||||
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
|
||||
});
|
||||
|
||||
it("should remove id references of elements that aren't found", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
// should keep
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "arrow-not-exists", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "text-not-exists", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle-not-exists",
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const arrow3 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow3",
|
||||
startBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const duplicatedElements = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}).duplicatedElements as any as typeof origElements;
|
||||
|
||||
const [
|
||||
clonedRectangle,
|
||||
clonedText1,
|
||||
clonedArrow1,
|
||||
clonedArrow2,
|
||||
clonedArrow3,
|
||||
] = duplicatedElements;
|
||||
|
||||
expect(clonedRectangle.boundElements).toEqual([
|
||||
{ id: clonedArrow1.id, type: "arrow" },
|
||||
]);
|
||||
|
||||
expect(clonedText1.containerId).toBe(null);
|
||||
|
||||
expect(clonedArrow2.startBinding).toEqual({
|
||||
...arrow2.startBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
expect(clonedArrow2.endBinding).toBe(null);
|
||||
expect(clonedArrow3.startBinding).toBe(null);
|
||||
expect(clonedArrow3.endBinding).toEqual({
|
||||
...arrow3.endBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe("should duplicate all group ids", () => {
|
||||
it("should regenerate all group ids and keep them consistent across elements", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const { duplicatedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
});
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
duplicatedElements;
|
||||
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
||||
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
|
||||
|
||||
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
|
||||
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
|
||||
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
|
||||
});
|
||||
|
||||
it("should keep and regenerate ids of groups even if invalid", () => {
|
||||
// lone element shouldn't be able to be grouped with itself,
|
||||
// but hard to check against in a performant way so we ignore it
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
const {
|
||||
duplicatedElements: [clonedRectangle1],
|
||||
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
||||
|
||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("group-related duplication", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("action-duplicating within group", async () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2]);
|
||||
API.setSelectedElements([rectangle2], "group1");
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBe("group1");
|
||||
});
|
||||
|
||||
it("alt-duplicating within group", async () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2]);
|
||||
API.setSelectedElements([rectangle2], "group1");
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
||||
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBe("group1");
|
||||
});
|
||||
|
||||
it("alt-duplicating within group away outside frame", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["group1"],
|
||||
frameId: frame.id,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["group1"],
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle1, rectangle2]);
|
||||
API.setSelectedElements([rectangle2], "group1");
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
||||
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle1.id, frameId: frame.id },
|
||||
{ id: rectangle2.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
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, [
|
||||
{ id: rectangle1.id },
|
||||
{ [ORIG_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 },
|
||||
{ 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, [
|
||||
{ id: rectangle1.id },
|
||||
{ [ORIG_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, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating text container (in-order)", async () => {
|
||||
const [rectangle, text] = API.createTextContainer();
|
||||
API.setElements([rectangle, text]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle.id },
|
||||
{ id: text.id, containerId: rectangle.id },
|
||||
{ [ORIG_ID]: rectangle.id, selected: true },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating text container (out-of-order)", async () => {
|
||||
const [rectangle, text] = API.createTextContainer();
|
||||
API.setElements([text, rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle.id },
|
||||
{ id: text.id, containerId: rectangle.id },
|
||||
{ [ORIG_ID]: rectangle.id, selected: true },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating labeled arrows (in-order)", async () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([arrow, text]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
mouse.up(arrow.x + 15, arrow.y + 15);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
expect(h.state.selectedLinearElement).toEqual(
|
||||
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("alt-duplicating labeled arrows (out-of-order)", async () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([text, arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
mouse.up(arrow.x + 15, arrow.y + 15);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
|
||||
const rect = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -100,
|
||||
y: 50,
|
||||
width: 95,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(5, 5);
|
||||
mouse.up(15, 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{
|
||||
id: rect.id,
|
||||
boundElements: expect.arrayContaining([
|
||||
expect.objectContaining({ id: arrow.id }),
|
||||
]),
|
||||
},
|
||||
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
|
||||
{
|
||||
id: arrow.id,
|
||||
endBinding: expect.objectContaining({ elementId: rect.id }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { ARROW_TYPE } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
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 "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import "@excalidraw/utils/test-utils";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { bindLinearElement } from "../src/binding";
|
||||
|
||||
import { Scene } from "../src/Scene";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("elbow arrow segment move", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("can move the second segment of a fully connected elbow arrow", () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -100,
|
||||
y: -50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 200,
|
||||
y: 150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(0, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(200, 200);
|
||||
mouse.click();
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.down();
|
||||
mouse.moveTo(115, 100);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
|
||||
expect(arrow.fixedSegments?.length).toBe(1);
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[110, 0],
|
||||
[110, 200],
|
||||
[190, 200],
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(105, 74.275);
|
||||
mouse.doubleClick();
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[110, 0],
|
||||
[110, 200],
|
||||
[190, 200],
|
||||
]);
|
||||
});
|
||||
|
||||
it("can move the second segment of an unconnected elbow arrow", () => {
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(0, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(250, 200);
|
||||
mouse.click();
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(125, 100);
|
||||
mouse.down();
|
||||
mouse.moveTo(130, 100);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[130, 0],
|
||||
[130, 200],
|
||||
[250, 200],
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(130, 100);
|
||||
mouse.doubleClick();
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[125, 0],
|
||||
[125, 200],
|
||||
[250, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elbow arrow routing", () => {
|
||||
it("can properly generate orthogonal arrow points", () => {
|
||||
const scene = new Scene();
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||
],
|
||||
});
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.x).toEqual(-45);
|
||||
expect(arrow.y).toEqual(-100.1);
|
||||
expect(arrow.width).toEqual(90);
|
||||
expect(arrow.height).toEqual(200);
|
||||
});
|
||||
it("can generate proper points for bound elbow arrow", () => {
|
||||
const scene = new Scene();
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}) as ExcalidrawBindableElement;
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}) as ExcalidrawBindableElement;
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
x: -45,
|
||||
y: -100.1,
|
||||
width: 90,
|
||||
height: 200,
|
||||
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
scene.insertElement(arrow);
|
||||
|
||||
bindLinearElement(arrow, rectangle1, "start", scene);
|
||||
bindLinearElement(arrow, rectangle2, "end", scene);
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||
});
|
||||
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elbow arrow ui", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
});
|
||||
|
||||
it("can follow bound shapes", 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");
|
||||
|
||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(arrow.type).toBe("arrow");
|
||||
expect(arrow.elbowed).toBe(true);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
|
||||
it("can follow bound rotated shapes", 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;
|
||||
|
||||
mouse.click(51, 51);
|
||||
|
||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
UI.updateInput(inputAngle, String("40"));
|
||||
|
||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 165],
|
||||
[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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { KEYS, reseed } from "@excalidraw/common";
|
||||
|
||||
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");
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
mouse.reset();
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.state.width = 1000;
|
||||
h.state.height = 1000;
|
||||
|
||||
// The bounds of hand-drawn linear elements may change after flipping, so
|
||||
// removing this style for testing
|
||||
UI.clickTool("arrow");
|
||||
UI.clickByTitle("Architect");
|
||||
UI.clickTool("selection");
|
||||
});
|
||||
|
||||
describe("flow chart creation", () => {
|
||||
beforeEach(() => {
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
});
|
||||
|
||||
// multiple at once
|
||||
it("create multiple successor nodes at once", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
});
|
||||
|
||||
it("when directions are changed, only the last same directions will apply", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
});
|
||||
|
||||
it("when escaped, no nodes will be created", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("create nodes one at a time", () => {
|
||||
const initialNode = h.elements[0];
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
|
||||
|
||||
const firstChildNode = h.elements.filter(
|
||||
(el) => el.type === "rectangle" && el.id !== initialNode.id,
|
||||
)[0];
|
||||
expect(firstChildNode).not.toBe(null);
|
||||
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
|
||||
const secondChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id,
|
||||
)[0];
|
||||
expect(secondChildNode).not.toBe(null);
|
||||
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
|
||||
const thirdChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id &&
|
||||
el.id !== secondChildNode.id,
|
||||
)[0];
|
||||
|
||||
expect(thirdChildNode).not.toBe(null);
|
||||
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
expect(firstChildNode.x).toBe(secondChildNode.x);
|
||||
expect(secondChildNode.x).toBe(thirdChildNode.x);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flow chart navigation", () => {
|
||||
it("single node at each level", () => {
|
||||
/**
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
|
||||
|
||||
// all the way to the left, gets us to the first node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// all the way to the right, gets us to the last node
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode);
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("multiple nodes at each level", () => {
|
||||
/**
|
||||
* from the perspective of the first node, there're four layers, and
|
||||
* there are four nodes at the second layer
|
||||
*
|
||||
* -> ▨
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
* -> ▨
|
||||
* -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
const secondNode = h.elements[1];
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
// because of same level cycling,
|
||||
// going right five times should take us back to the second node again
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
|
||||
|
||||
// from the second node, going right three times should take us to the rightmost node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("take the most obvious link when possible", () => {
|
||||
/**
|
||||
* ▨ → ▨ ▨ → ▨
|
||||
* ↓ ↑
|
||||
* ▨ → ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
// last node should be the one that's selected
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// going any direction takes us to the predecessor as well
|
||||
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
||||
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
||||
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,817 @@
|
||||
/* eslint-disable no-lone-blocks */
|
||||
import { generateKeyBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { InvalidFractionalIndexError } from "../src/fractionalIndex";
|
||||
|
||||
describe("sync invalid indices with array order", () => {
|
||||
describe("should NOT sync empty array", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [],
|
||||
movedElements: [],
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
validInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [],
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
validInput: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should NOT sync when index is well defined", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [{ id: "A", index: "a1" }],
|
||||
movedElements: [],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
validInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [{ id: "A", index: "a1" }],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
validInput: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should NOT sync when indices are well defined", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a3" },
|
||||
],
|
||||
movedElements: [],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B", "C"],
|
||||
validInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a3" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B", "C"],
|
||||
validInput: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when fractional index is not defined", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [{ id: "A" }],
|
||||
movedElements: ["A"],
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [{ id: "A" }],
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when fractional indices are duplicated", () => {
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a1" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a1" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when a fractional index is out of order", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a1" },
|
||||
],
|
||||
movedElements: ["B"],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a1" },
|
||||
],
|
||||
movedElements: ["A"],
|
||||
expect: {
|
||||
unchangedElements: ["B"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a1" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when fractional indices are out of order", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a3" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
],
|
||||
movedElements: ["B", "C"],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a3" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when incorrect fractional index is in between correct ones ", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["B"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when incorrect fractional index is on top and duplicated below", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
],
|
||||
movedElements: ["C"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when given a mix of duplicate / invalid indices", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a0" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
{ id: "D", index: "a1" },
|
||||
{ id: "E", index: "a2" },
|
||||
],
|
||||
movedElements: ["C", "D", "E"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a0" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
{ id: "D", index: "a1" },
|
||||
{ id: "E", index: "a2" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when given a mix of undefined / invalid indices", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{ id: "C", index: "a0" },
|
||||
{ id: "D", index: "a2" },
|
||||
{ id: "E" },
|
||||
{ id: "F", index: "a3" },
|
||||
{ id: "G" },
|
||||
{ id: "H", index: "a1" },
|
||||
{ id: "I", index: "a2" },
|
||||
{ id: "J" },
|
||||
],
|
||||
movedElements: ["A", "B", "E", "G", "H", "I", "J"],
|
||||
expect: {
|
||||
unchangedElements: ["C", "D", "F"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{ id: "C", index: "a0" },
|
||||
{ id: "D", index: "a2" },
|
||||
{ id: "E" },
|
||||
{ id: "F", index: "a3" },
|
||||
{ id: "G" },
|
||||
{ id: "H", index: "a1" },
|
||||
{ id: "I", index: "a2" },
|
||||
{ id: "J" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["C", "D", "F"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync all moved elements regardless of their validity", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a4" },
|
||||
],
|
||||
movedElements: ["A"],
|
||||
expect: {
|
||||
validInput: true,
|
||||
unchangedElements: ["B"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a4" },
|
||||
],
|
||||
movedElements: ["B"],
|
||||
expect: {
|
||||
validInput: true,
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "C", index: "a2" },
|
||||
{ id: "D", index: "a3" },
|
||||
{ id: "A", index: "a0" },
|
||||
{ id: "B", index: "a1" },
|
||||
],
|
||||
movedElements: ["C", "D"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "D", index: "a4" },
|
||||
{ id: "C", index: "a3" },
|
||||
{ id: "F", index: "a6" },
|
||||
{ id: "E", index: "a5" },
|
||||
{ id: "H", index: "a8" },
|
||||
{ id: "G", index: "a7" },
|
||||
{ id: "I", index: "a9" },
|
||||
],
|
||||
movedElements: ["D", "F", "H"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B", "C", "E", "G", "I"],
|
||||
},
|
||||
});
|
||||
|
||||
{
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["B", "C"],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["A", "B"],
|
||||
expect: {
|
||||
unchangedElements: ["C"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a0" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
{ id: "D", index: "a1" },
|
||||
{ id: "E", index: "a2" },
|
||||
],
|
||||
movedElements: ["B", "D", "E"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{ id: "C", index: "a0" },
|
||||
{ id: "D", index: "a2" },
|
||||
{ id: "E" },
|
||||
{ id: "F", index: "a3" },
|
||||
{ id: "G" },
|
||||
{ id: "H", index: "a1" },
|
||||
{ id: "I", index: "a2" },
|
||||
{ id: "J" },
|
||||
],
|
||||
movedElements: ["A", "B", "D", "E", "F", "G", "J"],
|
||||
expect: {
|
||||
unchangedElements: ["C", "H", "I"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should generate fractions for explicitly moved elements", () => {
|
||||
describe("should generate a fraction between 'A' and 'C'", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
// doing actual fractions, without jitter 'a1' becomes 'a1V'
|
||||
// as V is taken as the charset's middle-right value
|
||||
{ id: "B", index: "a1" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["B"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a1" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
expect: {
|
||||
// as above, B will become fractional
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should generate fractions given duplicated indices", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a01" },
|
||||
{ id: "B", index: "a01" },
|
||||
{ id: "C", index: "a01" },
|
||||
{ id: "D", index: "a01" },
|
||||
{ id: "E", index: "a02" },
|
||||
{ id: "F", index: "a02" },
|
||||
{ id: "G", index: "a02" },
|
||||
],
|
||||
movedElements: ["B", "C", "D", "E", "F"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "G"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a01" },
|
||||
{ id: "B", index: "a01" },
|
||||
{ id: "C", index: "a01" },
|
||||
{ id: "D", index: "a01" },
|
||||
{ id: "E", index: "a02" },
|
||||
{ id: "F", index: "a02" },
|
||||
{ id: "G", index: "a02" },
|
||||
],
|
||||
movedElements: ["A", "C", "D", "E", "G"],
|
||||
expect: {
|
||||
unchangedElements: ["B", "F"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a01" },
|
||||
{ id: "B", index: "a01" },
|
||||
{ id: "C", index: "a01" },
|
||||
{ id: "D", index: "a01" },
|
||||
{ id: "E", index: "a02" },
|
||||
{ id: "F", index: "a02" },
|
||||
{ id: "G", index: "a02" },
|
||||
],
|
||||
movedElements: ["B", "C", "D", "F", "G"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "E"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a01" },
|
||||
{ id: "B", index: "a01" },
|
||||
{ id: "C", index: "a01" },
|
||||
{ id: "D", index: "a01" },
|
||||
{ id: "E", index: "a02" },
|
||||
{ id: "F", index: "a02" },
|
||||
{ id: "G", index: "a02" },
|
||||
],
|
||||
expect: {
|
||||
// notice fallback considers first item (E) as a valid one
|
||||
unchangedElements: ["A", "E"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("should be able to sync 20K invalid indices", () => {
|
||||
const length = 20_000;
|
||||
|
||||
describe("should sync all empty indices", () => {
|
||||
const elements = Array.from({ length }).map((_, index) => ({
|
||||
id: `A_${index}`,
|
||||
}));
|
||||
|
||||
testMovedIndicesSync({
|
||||
// elements without fractional index
|
||||
elements,
|
||||
movedElements: Array.from({ length }).map((_, index) => `A_${index}`),
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
// elements without fractional index
|
||||
elements,
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync all but last index given a growing array of indices", () => {
|
||||
let lastIndex: string | null = null;
|
||||
|
||||
const elements = Array.from({ length }).map((_, index) => {
|
||||
// going up from 'a0'
|
||||
lastIndex = generateKeyBetween(lastIndex, null);
|
||||
|
||||
return {
|
||||
id: `A_${index}`,
|
||||
// assigning the last generated index, so sync can go down from there
|
||||
// without jitter lastIndex is 'c4BZ' for 20000th element
|
||||
index: index === length - 1 ? lastIndex : undefined,
|
||||
};
|
||||
});
|
||||
const movedElements = Array.from({ length }).map(
|
||||
(_, index) => `A_${index}`,
|
||||
);
|
||||
// remove last element
|
||||
movedElements.pop();
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements,
|
||||
movedElements,
|
||||
expect: {
|
||||
unchangedElements: [`A_${length - 1}`],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements,
|
||||
expect: {
|
||||
unchangedElements: [`A_${length - 1}`],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync all but first index given a declining array of indices", () => {
|
||||
let lastIndex: string | null = null;
|
||||
|
||||
const elements = Array.from({ length }).map((_, index) => {
|
||||
// going down from 'a0'
|
||||
lastIndex = generateKeyBetween(null, lastIndex);
|
||||
|
||||
return {
|
||||
id: `A_${index}`,
|
||||
// without jitter lastIndex is 'XvoR' for 20000th element
|
||||
index: lastIndex,
|
||||
};
|
||||
});
|
||||
const movedElements = Array.from({ length }).map(
|
||||
(_, index) => `A_${index}`,
|
||||
);
|
||||
// remove first element
|
||||
movedElements.shift();
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements,
|
||||
movedElements,
|
||||
expect: {
|
||||
unchangedElements: [`A_0`],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements,
|
||||
expect: {
|
||||
unchangedElements: [`A_0`],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("should automatically fallback to fixing all invalid indices", () => {
|
||||
describe("should fallback to syncing duplicated indices when moved elements are empty", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a1" },
|
||||
{ id: "C", index: "a1" },
|
||||
],
|
||||
// the validation will throw as nothing was synced
|
||||
// therefore it will lead to triggering the fallback and fixing all invalid indices
|
||||
movedElements: [],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should fallback to syncing undefined / invalid indices when moved elements are empty", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B" },
|
||||
{ id: "C", index: "a0" },
|
||||
],
|
||||
// since elements are invalid, this will fail the validation
|
||||
// leading to fallback fixing "B" and "C"
|
||||
movedElements: [],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should fallback to syncing unordered indices when moved element is invalid", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
],
|
||||
movedElements: ["A"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should fallback when trying to generate an index in between unordered elements", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B" },
|
||||
{ id: "C", index: "a1" },
|
||||
],
|
||||
// 'B' is invalid, but so is 'C', which was not marked as moved
|
||||
// therefore it will try to generate a key between 'a2' and 'a1'
|
||||
// which it cannot do, thus will throw during generation and automatically fallback
|
||||
movedElements: ["B"],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should fallback when trying to generate an index in between duplicate indices", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a01" },
|
||||
{ id: "B" },
|
||||
{ id: "C" },
|
||||
{ id: "D", index: "a01" },
|
||||
{ id: "E", index: "a01" },
|
||||
{ id: "F", index: "a01" },
|
||||
{ id: "G" },
|
||||
{ id: "I", index: "a03" },
|
||||
{ id: "H" },
|
||||
],
|
||||
// missed "E" therefore upper bound for 'B' is a01, while lower bound is 'a02'
|
||||
// therefore, similarly to above, it will fail during key generation and lead to fallback
|
||||
movedElements: ["B", "C", "D", "F", "G", "H"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "I"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function testMovedIndicesSync(args: {
|
||||
elements: { id: string; index?: string }[];
|
||||
movedElements: string[];
|
||||
expect: {
|
||||
unchangedElements: string[];
|
||||
validInput?: true;
|
||||
};
|
||||
}) {
|
||||
const [elements, movedElements] = prepareArguments(
|
||||
args.elements,
|
||||
args.movedElements,
|
||||
);
|
||||
const expectUnchangedElements = arrayToMap(
|
||||
args.expect.unchangedElements.map((x) => ({ id: x })),
|
||||
);
|
||||
|
||||
test(
|
||||
"should sync invalid indices of moved elements or fallback",
|
||||
elements,
|
||||
movedElements,
|
||||
expectUnchangedElements,
|
||||
args.expect.validInput,
|
||||
);
|
||||
}
|
||||
|
||||
function testInvalidIndicesSync(args: {
|
||||
elements: { id: string; index?: string }[];
|
||||
expect: {
|
||||
unchangedElements: string[];
|
||||
validInput?: true;
|
||||
};
|
||||
}) {
|
||||
const [elements] = prepareArguments(args.elements);
|
||||
const expectUnchangedElements = arrayToMap(
|
||||
args.expect.unchangedElements.map((x) => ({ id: x })),
|
||||
);
|
||||
|
||||
test(
|
||||
"should sync invalid indices of all elements",
|
||||
elements,
|
||||
undefined,
|
||||
expectUnchangedElements,
|
||||
args.expect.validInput,
|
||||
);
|
||||
}
|
||||
|
||||
function prepareArguments(
|
||||
elementsLike: { id: string; index?: string }[],
|
||||
movedElementsIds?: string[],
|
||||
): [ExcalidrawElement[], ElementsMap | undefined] {
|
||||
const elements = elementsLike.map((x) =>
|
||||
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
|
||||
);
|
||||
const movedMap = arrayToMap(movedElementsIds || []);
|
||||
const movedElements = movedElementsIds
|
||||
? arrayToMap(elements.filter((x) => movedMap.has(x.id)))
|
||||
: undefined;
|
||||
|
||||
return [elements, movedElements];
|
||||
}
|
||||
|
||||
function test(
|
||||
name: string,
|
||||
elements: ExcalidrawElement[],
|
||||
movedElements: ElementsMap | undefined,
|
||||
expectUnchangedElements: Map<string, { id: string }>,
|
||||
expectValidInput?: boolean,
|
||||
) {
|
||||
it(name, () => {
|
||||
// ensure the input is invalid (unless the flag is on)
|
||||
if (!expectValidInput) {
|
||||
expect(() =>
|
||||
validateFractionalIndices(elements, {
|
||||
shouldThrow: true,
|
||||
includeBoundTextValidation: true,
|
||||
ignoreLogs: true,
|
||||
}),
|
||||
).toThrowError(InvalidFractionalIndexError);
|
||||
}
|
||||
|
||||
// clone due to mutation
|
||||
const clonedElements = elements.map((x) => deepCopyElement(x));
|
||||
|
||||
// act
|
||||
const syncedElements = movedElements
|
||||
? syncMovedIndices(clonedElements, movedElements)
|
||||
: syncInvalidIndices(clonedElements);
|
||||
|
||||
expect(syncedElements.length).toBe(elements.length);
|
||||
expect(() =>
|
||||
validateFractionalIndices(syncedElements, {
|
||||
shouldThrow: true,
|
||||
includeBoundTextValidation: true,
|
||||
ignoreLogs: true,
|
||||
}),
|
||||
).not.toThrowError(InvalidFractionalIndexError);
|
||||
|
||||
syncedElements.forEach((synced, index) => {
|
||||
const element = elements[index];
|
||||
// ensure the order hasn't changed
|
||||
expect(synced.id).toBe(element.id);
|
||||
|
||||
if (expectUnchangedElements.has(synced.id)) {
|
||||
// ensure we didn't mutate where we didn't want to mutate
|
||||
expect(synced.index).toBe(elements[index].index);
|
||||
expect(synced.version).toBe(elements[index].version);
|
||||
} else {
|
||||
expect(synced.index).not.toBe(elements[index].index);
|
||||
// ensure we mutated just once, even with fallback triggered
|
||||
expect(synced.version).toBe(elements[index].version + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+55
-45
@@ -1,8 +1,16 @@
|
||||
import { 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");
|
||||
@@ -122,7 +130,7 @@ describe("adding elements to frames", () => {
|
||||
) => {
|
||||
describe.skip("when frame is in a layer below", async () => {
|
||||
it("should add an element", async () => {
|
||||
h.elements = [frame, rect2];
|
||||
API.setElements([frame, rect2]);
|
||||
|
||||
func(frame, rect2);
|
||||
|
||||
@@ -131,7 +139,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it("should add elements", async () => {
|
||||
h.elements = [frame, rect2, rect3];
|
||||
API.setElements([frame, rect2, rect3]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -142,7 +150,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it("should add elements when there are other other elements in between", async () => {
|
||||
h.elements = [frame, rect1, rect2, rect4, rect3];
|
||||
API.setElements([frame, rect1, rect2, rect4, rect3]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -153,7 +161,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||
h.elements = [frame, rect3, rect4, rect2, rect1];
|
||||
API.setElements([frame, rect3, rect4, rect2, rect1]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -166,7 +174,7 @@ describe("adding elements to frames", () => {
|
||||
|
||||
describe.skip("when frame is in a layer above", async () => {
|
||||
it("should add an element", async () => {
|
||||
h.elements = [rect2, frame];
|
||||
API.setElements([rect2, frame]);
|
||||
|
||||
func(frame, rect2);
|
||||
|
||||
@@ -175,7 +183,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it("should add elements", async () => {
|
||||
h.elements = [rect2, rect3, frame];
|
||||
API.setElements([rect2, rect3, frame]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -186,7 +194,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it("should add elements when there are other other elements in between", async () => {
|
||||
h.elements = [rect1, rect2, rect4, rect3, frame];
|
||||
API.setElements([rect1, rect2, rect4, rect3, frame]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -197,7 +205,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||
h.elements = [rect3, rect4, rect2, rect1, frame];
|
||||
API.setElements([rect3, rect4, rect2, rect1, frame]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -210,7 +218,7 @@ describe("adding elements to frames", () => {
|
||||
|
||||
describe("when frame is in an inner layer", async () => {
|
||||
it.skip("should add elements", async () => {
|
||||
h.elements = [rect2, frame, rect3];
|
||||
API.setElements([rect2, frame, rect3]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -221,7 +229,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it.skip("should add elements when there are other other elements in between", async () => {
|
||||
h.elements = [rect2, rect1, frame, rect4, rect3];
|
||||
API.setElements([rect2, rect1, frame, rect4, rect3]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -232,7 +240,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||
h.elements = [rect3, rect4, frame, rect2, rect1];
|
||||
API.setElements([rect3, rect4, frame, rect2, rect1]);
|
||||
|
||||
func(frame, rect2);
|
||||
func(frame, rect3);
|
||||
@@ -253,20 +261,22 @@ describe("adding elements to frames", () => {
|
||||
|
||||
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
|
||||
|
||||
h.elements = reorderElements(
|
||||
[
|
||||
frame,
|
||||
...convertToExcalidrawElements([
|
||||
{
|
||||
type: containerType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
height: 10,
|
||||
label: { text: "xx" },
|
||||
},
|
||||
]),
|
||||
],
|
||||
initialOrder,
|
||||
API.setElements(
|
||||
reorderElements(
|
||||
[
|
||||
frame,
|
||||
...convertToExcalidrawElements([
|
||||
{
|
||||
type: containerType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
height: 10,
|
||||
label: { text: "xx" },
|
||||
},
|
||||
]),
|
||||
],
|
||||
initialOrder,
|
||||
),
|
||||
);
|
||||
|
||||
assertOrder(h.elements, initialOrder);
|
||||
@@ -337,7 +347,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it.skip("should add arrow bound with text when frame is in a layer below", async () => {
|
||||
h.elements = [frame, arrow, text];
|
||||
API.setElements([frame, arrow, text]);
|
||||
|
||||
resizeFrameOverElement(frame, arrow);
|
||||
|
||||
@@ -347,7 +357,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it("should add arrow bound with text when frame is in a layer above", async () => {
|
||||
h.elements = [arrow, text, frame];
|
||||
API.setElements([arrow, text, frame]);
|
||||
|
||||
resizeFrameOverElement(frame, arrow);
|
||||
|
||||
@@ -357,7 +367,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it.skip("should add arrow bound with text when frame is in an inner layer", async () => {
|
||||
h.elements = [arrow, frame, text];
|
||||
API.setElements([arrow, frame, text]);
|
||||
|
||||
resizeFrameOverElement(frame, arrow);
|
||||
|
||||
@@ -369,7 +379,7 @@ describe("adding elements to frames", () => {
|
||||
|
||||
describe("resizing frame over elements but downwards", async () => {
|
||||
it.skip("should add elements when frame is in a layer below", async () => {
|
||||
h.elements = [frame, rect1, rect2, rect3, rect4];
|
||||
API.setElements([frame, rect1, rect2, rect3, rect4]);
|
||||
|
||||
resizeFrameOverElement(frame, rect4);
|
||||
resizeFrameOverElement(frame, rect3);
|
||||
@@ -380,7 +390,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it.skip("should add elements when frame is in a layer above", async () => {
|
||||
h.elements = [rect1, rect2, rect3, rect4, frame];
|
||||
API.setElements([rect1, rect2, rect3, rect4, frame]);
|
||||
|
||||
resizeFrameOverElement(frame, rect4);
|
||||
resizeFrameOverElement(frame, rect3);
|
||||
@@ -391,7 +401,7 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
|
||||
it.skip("should add elements when frame is in an inner layer", async () => {
|
||||
h.elements = [rect1, rect2, frame, rect3, rect4];
|
||||
API.setElements([rect1, rect2, frame, rect3, rect4]);
|
||||
|
||||
resizeFrameOverElement(frame, rect4);
|
||||
resizeFrameOverElement(frame, rect3);
|
||||
@@ -406,29 +416,29 @@ describe("adding elements to frames", () => {
|
||||
await commonTestCases(dragElementIntoFrame);
|
||||
|
||||
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
|
||||
h.elements = [frame, rect2];
|
||||
API.setElements([frame, rect2]);
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
it.skip("should drag element inside, duplicate it and remove it from frame", () => {
|
||||
h.elements = [frame, rect2];
|
||||
API.setElements([frame, rect2]);
|
||||
|
||||
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]);
|
||||
@@ -490,7 +500,7 @@ describe("adding elements to frames", () => {
|
||||
frameId: frame3.id,
|
||||
});
|
||||
|
||||
h.elements = [
|
||||
API.setElements([
|
||||
frame1,
|
||||
rectangle4,
|
||||
rectangle1,
|
||||
@@ -498,7 +508,7 @@ describe("adding elements to frames", () => {
|
||||
frame3,
|
||||
rectangle2,
|
||||
frame2,
|
||||
];
|
||||
]);
|
||||
|
||||
API.setSelectedElements([rectangle2]);
|
||||
|
||||
@@ -541,7 +551,7 @@ describe("adding elements to frames", () => {
|
||||
frameId: frame2.id,
|
||||
});
|
||||
|
||||
h.elements = [rectangle1, rectangle2, frame1, frame2];
|
||||
API.setElements([rectangle1, rectangle2, frame1, frame2]);
|
||||
|
||||
API.setSelectedElements([rectangle2]);
|
||||
|
||||
+416
-257
File diff suppressed because it is too large
Load Diff
+414
-74
@@ -1,21 +1,35 @@
|
||||
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 { Point } from "../types";
|
||||
import { Bounds, 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";
|
||||
} from "../src/types";
|
||||
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@@ -176,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 () => {
|
||||
@@ -215,23 +229,23 @@ describe("generic element", () => {
|
||||
});
|
||||
|
||||
describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
||||
const points: Record<typeof type, Point[]> = {
|
||||
const points: Record<typeof type, LocalPoint[]> = {
|
||||
line: [
|
||||
[0, 0],
|
||||
[60, -20],
|
||||
[20, 40],
|
||||
[-40, 0],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(60, -20),
|
||||
pointFrom(20, 40),
|
||||
pointFrom(-40, 0),
|
||||
],
|
||||
freedraw: [
|
||||
[0, 0],
|
||||
[-2.474600807561444, 41.021700699972],
|
||||
[3.6627956000014024, 47.84174560617245],
|
||||
[40.495224145598115, 47.15909710753482],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(-2.474600807561444, 41.021700699972),
|
||||
pointFrom(3.6627956000014024, 47.84174560617245),
|
||||
pointFrom(40.495224145598115, 47.15909710753482),
|
||||
],
|
||||
};
|
||||
|
||||
it("resizes", async () => {
|
||||
const element = UI.createElement(type, { points: points[type] });
|
||||
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||
const bounds = getBoundsFromPoints(element);
|
||||
|
||||
UI.resize(element, "ne", [30, -60]);
|
||||
@@ -245,7 +259,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
||||
});
|
||||
|
||||
it("flips while resizing", async () => {
|
||||
const element = UI.createElement(type, { points: points[type] });
|
||||
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||
const bounds = getBoundsFromPoints(element);
|
||||
|
||||
UI.resize(element, "sw", [140, -80]);
|
||||
@@ -259,7 +273,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
||||
});
|
||||
|
||||
it("resizes with locked aspect ratio", async () => {
|
||||
const element = UI.createElement(type, { points: points[type] });
|
||||
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||
const bounds = getBoundsFromPoints(element);
|
||||
|
||||
UI.resize(element, "ne", [30, -60], { shift: true });
|
||||
@@ -276,7 +290,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
||||
});
|
||||
|
||||
it("resizes from center", async () => {
|
||||
const element = UI.createElement(type, { points: points[type] });
|
||||
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||
const bounds = getBoundsFromPoints(element);
|
||||
|
||||
UI.resize(element, "nw", [-20, -30], { alt: true });
|
||||
@@ -290,15 +304,156 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("line element", () => {
|
||||
const points: LocalPoint[] = [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(60, -20),
|
||||
pointFrom(20, 40),
|
||||
pointFrom(-40, 0),
|
||||
];
|
||||
|
||||
it("resizes", async () => {
|
||||
UI.createElement("line", { points });
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
const {
|
||||
x: prevX,
|
||||
y: prevY,
|
||||
width: prevWidth,
|
||||
height: prevHeight,
|
||||
} = element;
|
||||
|
||||
const nextWidth = prevWidth + 30;
|
||||
const nextHeight = prevHeight + 30;
|
||||
|
||||
resizeSingleElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
element,
|
||||
element,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app.scene,
|
||||
"ne",
|
||||
);
|
||||
|
||||
expect(element.x).not.toBe(prevX);
|
||||
expect(element.y).not.toBe(prevY);
|
||||
|
||||
expect(element.width).toBe(nextWidth);
|
||||
expect(element.height).toBe(nextHeight);
|
||||
|
||||
expect(element.points[0]).toEqual([0, 0]);
|
||||
|
||||
const { width, height } = getSizeFromPoints(element.points);
|
||||
expect(width).toBe(element.width);
|
||||
expect(height).toBe(element.height);
|
||||
});
|
||||
|
||||
it("flips while resizing", async () => {
|
||||
UI.createElement("line", { points });
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
const {
|
||||
width: prevWidth,
|
||||
height: prevHeight,
|
||||
points: prevPoints,
|
||||
} = element;
|
||||
|
||||
const nextWidth = prevWidth * -1;
|
||||
const nextHeight = prevHeight * -1;
|
||||
|
||||
resizeSingleElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
element,
|
||||
element,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app.scene,
|
||||
"se",
|
||||
);
|
||||
|
||||
expect(element.width).toBe(prevWidth);
|
||||
expect(element.height).toBe(prevHeight);
|
||||
|
||||
element.points.forEach((point, idx) => {
|
||||
expect(point[0]).toBeCloseTo(prevPoints[idx][0] * -1);
|
||||
expect(point[1]).toBeCloseTo(prevPoints[idx][1] * -1);
|
||||
});
|
||||
});
|
||||
|
||||
it("resizes with locked aspect ratio", async () => {
|
||||
UI.createElement("line", { points });
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
const { width: prevWidth, height: prevHeight } = element;
|
||||
|
||||
UI.resize(element, "ne", [30, -60], { shift: true });
|
||||
|
||||
const scaleHeight = element.width / prevWidth;
|
||||
const scaleWidth = element.height / prevHeight;
|
||||
|
||||
expect(scaleHeight).toBeCloseTo(scaleWidth);
|
||||
});
|
||||
|
||||
it("resizes from center", async () => {
|
||||
UI.createElement("line", {
|
||||
points: [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(338.05644048727373, -180.4761618151104),
|
||||
pointFrom(338.05644048727373, 180.4761618151104),
|
||||
pointFrom(-338.05644048727373, 180.4761618151104),
|
||||
pointFrom(-338.05644048727373, -180.4761618151104),
|
||||
],
|
||||
});
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
const {
|
||||
x: prevX,
|
||||
y: prevY,
|
||||
width: prevWidth,
|
||||
height: prevHeight,
|
||||
} = element;
|
||||
|
||||
const prevSmallestX = Math.min(...element.points.map((p) => p[0]));
|
||||
const prevBiggestX = Math.max(...element.points.map((p) => p[0]));
|
||||
|
||||
resizeSingleElement(
|
||||
prevWidth + 20,
|
||||
prevHeight,
|
||||
element,
|
||||
element,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app.scene,
|
||||
"e",
|
||||
{
|
||||
shouldResizeFromCenter: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(element.width).toBeCloseTo(prevWidth + 20);
|
||||
expect(element.height).toBeCloseTo(prevHeight);
|
||||
|
||||
expect(element.x).toBeCloseTo(prevX);
|
||||
expect(element.y).toBeCloseTo(prevY);
|
||||
|
||||
const smallestX = Math.min(...element.points.map((p) => p[0]));
|
||||
const biggestX = Math.max(...element.points.map((p) => p[0]));
|
||||
|
||||
expect(prevSmallestX - smallestX).toBeCloseTo(10);
|
||||
expect(biggestX - prevBiggestX).toBeCloseTo(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrow element", () => {
|
||||
it("resizes with a label", async () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
points: [
|
||||
[0, 0],
|
||||
[40, 140],
|
||||
[80, 60], // label's anchor
|
||||
[180, 20],
|
||||
[200, 120],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(40, 140),
|
||||
pointFrom(80, 60), // label's anchor
|
||||
pointFrom(180, 20),
|
||||
pointFrom(200, 120),
|
||||
],
|
||||
});
|
||||
const label = await UI.editText(arrow, "Hello");
|
||||
@@ -335,6 +490,61 @@ describe("arrow element", () => {
|
||||
expect(label.angle).toBeCloseTo(0);
|
||||
expect(label.fontSize).toEqual(20);
|
||||
});
|
||||
|
||||
it("flips the fixed point binding on negative resize for single bindable", () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: -100,
|
||||
y: -75,
|
||||
width: 95,
|
||||
height: 100,
|
||||
});
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
mouse.reset();
|
||||
mouse.moveTo(-5, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(120, 200);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
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?.[1]).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
it("flips the fixed point binding on negative resize for group selection", () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: -100,
|
||||
y: -75,
|
||||
width: 95,
|
||||
height: 100,
|
||||
});
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
mouse.reset();
|
||||
mouse.moveTo(-5, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(120, 200);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("text element", () => {
|
||||
@@ -425,12 +635,118 @@ describe("text element", () => {
|
||||
expect(text.fontSize).toBe(fontSize);
|
||||
});
|
||||
});
|
||||
|
||||
// text can be resized from sides
|
||||
it("can be resized from e", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const width = text.width;
|
||||
const height = text.height;
|
||||
|
||||
UI.resize(text, "e", [30, 0]);
|
||||
expect(text.width).toBe(width + 30);
|
||||
expect(text.height).toBe(height);
|
||||
|
||||
UI.resize(text, "e", [-30, 0]);
|
||||
expect(text.width).toBe(width);
|
||||
expect(text.height).toBe(height);
|
||||
});
|
||||
|
||||
it("can be resized from w", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const width = text.width;
|
||||
const height = text.height;
|
||||
|
||||
UI.resize(text, "w", [-50, 0]);
|
||||
expect(text.width).toBe(width + 50);
|
||||
expect(text.height).toBe(height);
|
||||
|
||||
UI.resize(text, "w", [50, 0]);
|
||||
expect(text.width).toBe(width);
|
||||
expect(text.height).toBe(height);
|
||||
});
|
||||
|
||||
it("wraps when width is narrower than texts inside", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const prevWidth = text.width;
|
||||
const prevHeight = text.height;
|
||||
const prevText = text.text;
|
||||
|
||||
UI.resize(text, "w", [50, 0]);
|
||||
expect(text.width).toBe(prevWidth - 50);
|
||||
expect(text.height).toBeGreaterThan(prevHeight);
|
||||
expect(text.text).not.toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "w", [-50, 0]);
|
||||
expect(text.width).toBe(prevWidth);
|
||||
expect(text.height).toEqual(prevHeight);
|
||||
expect(text.text).toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "e", [-20, 0]);
|
||||
expect(text.width).toBe(prevWidth - 20);
|
||||
expect(text.height).toBeGreaterThan(prevHeight);
|
||||
expect(text.text).not.toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "e", [20, 0]);
|
||||
expect(text.width).toBe(prevWidth);
|
||||
expect(text.height).toEqual(prevHeight);
|
||||
expect(text.text).toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps properties when wrapped", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const alignment = text.textAlign;
|
||||
const fontSize = text.fontSize;
|
||||
const fontFamily = text.fontFamily;
|
||||
|
||||
UI.resize(text, "e", [-60, 0]);
|
||||
expect(text.textAlign).toBe(alignment);
|
||||
expect(text.fontSize).toBe(fontSize);
|
||||
expect(text.fontFamily).toBe(fontFamily);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "e", [60, 0]);
|
||||
expect(text.textAlign).toBe(alignment);
|
||||
expect(text.fontSize).toBe(fontSize);
|
||||
expect(text.fontFamily).toBe(fontFamily);
|
||||
expect(text.autoResize).toBe(false);
|
||||
});
|
||||
|
||||
it("has a minimum width when wrapped", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const width = text.width;
|
||||
|
||||
UI.resize(text, "e", [-width, 0]);
|
||||
expect(text.width).not.toEqual(0);
|
||||
UI.resize(text, "e", [width - text.width, 0]);
|
||||
expect(text.width).toEqual(width);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "w", [width, 0]);
|
||||
expect(text.width).not.toEqual(0);
|
||||
UI.resize(text, "w", [text.width - width, 0]);
|
||||
expect(text.width).toEqual(width);
|
||||
expect(text.autoResize).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("image element", () => {
|
||||
it("resizes", async () => {
|
||||
const image = API.createElement({ type: "image", width: 100, height: 100 });
|
||||
h.elements = [image];
|
||||
API.setElements([image]);
|
||||
UI.resize(image, "ne", [-20, -30]);
|
||||
|
||||
expect(image.x).toBeCloseTo(0);
|
||||
@@ -443,7 +759,7 @@ describe("image element", () => {
|
||||
|
||||
it("flips while resizing", async () => {
|
||||
const image = API.createElement({ type: "image", width: 100, height: 100 });
|
||||
h.elements = [image];
|
||||
API.setElements([image]);
|
||||
UI.resize(image, "sw", [150, -150]);
|
||||
|
||||
expect(image.x).toBeCloseTo(100);
|
||||
@@ -456,7 +772,7 @@ describe("image element", () => {
|
||||
|
||||
it("resizes with locked/unlocked aspect ratio", async () => {
|
||||
const image = API.createElement({ type: "image", width: 100, height: 100 });
|
||||
h.elements = [image];
|
||||
API.setElements([image]);
|
||||
UI.resize(image, "ne", [30, -20]);
|
||||
|
||||
expect(image.x).toBeCloseTo(0);
|
||||
@@ -474,7 +790,7 @@ describe("image element", () => {
|
||||
|
||||
it("resizes from center", async () => {
|
||||
const image = API.createElement({ type: "image", width: 100, height: 100 });
|
||||
h.elements = [image];
|
||||
API.setElements([image]);
|
||||
UI.resize(image, "nw", [25, 15], { alt: true });
|
||||
|
||||
expect(image.x).toBeCloseTo(15);
|
||||
@@ -491,7 +807,7 @@ describe("image element", () => {
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
h.elements = [image];
|
||||
API.setElements([image]);
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -30,
|
||||
y: 50,
|
||||
@@ -503,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(30, 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -544,7 +861,9 @@ describe("multiple selection", () => {
|
||||
1 + move[1] / selectionHeight,
|
||||
);
|
||||
|
||||
UI.resize([rectangle, diamond, ellipse], "se", move);
|
||||
UI.resize([rectangle, diamond, ellipse], "se", move, {
|
||||
shift: true,
|
||||
});
|
||||
|
||||
expect(rectangle.x).toBeCloseTo(0);
|
||||
expect(rectangle.y).toBeCloseTo(0);
|
||||
@@ -584,24 +903,24 @@ describe("multiple selection", () => {
|
||||
x: 60,
|
||||
y: 40,
|
||||
points: [
|
||||
[0, 0],
|
||||
[-40, 40],
|
||||
[-60, 0],
|
||||
[0, -40],
|
||||
[40, 20],
|
||||
[0, 40],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(-40, 40),
|
||||
pointFrom(-60, 0),
|
||||
pointFrom(0, -40),
|
||||
pointFrom(40, 20),
|
||||
pointFrom(0, 40),
|
||||
],
|
||||
});
|
||||
const freedraw = UI.createElement("freedraw", {
|
||||
x: 63.56072661326618,
|
||||
y: 100,
|
||||
points: [
|
||||
[0, 0],
|
||||
[-43.56072661326618, 18.15048126846341],
|
||||
[-43.56072661326618, 29.041198460587566],
|
||||
[-38.115368017204105, 42.652452795512204],
|
||||
[-19.964886748740696, 66.24829266003775],
|
||||
[19.056612930986716, 77.1390098521619],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(-43.56072661326618, 18.15048126846341),
|
||||
pointFrom(-43.56072661326618, 29.041198460587566),
|
||||
pointFrom(-38.115368017204105, 42.652452795512204),
|
||||
pointFrom(-19.964886748740696, 66.24829266003775),
|
||||
pointFrom(19.056612930986716, 77.1390098521619),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -613,7 +932,9 @@ describe("multiple selection", () => {
|
||||
1 + move[1] / selectionHeight,
|
||||
);
|
||||
|
||||
UI.resize([line, freedraw], "se", move);
|
||||
UI.resize([line, freedraw], "se", move, {
|
||||
shift: true,
|
||||
});
|
||||
|
||||
expect(line.x).toBeCloseTo(60 * scale);
|
||||
expect(line.y).toBeCloseTo(40 * scale);
|
||||
@@ -653,7 +974,9 @@ describe("multiple selection", () => {
|
||||
1 - move[1] / selectionHeight,
|
||||
);
|
||||
|
||||
UI.resize([horizLine, vertLine, diagLine], "nw", move);
|
||||
UI.resize([horizLine, vertLine, diagLine], "nw", move, {
|
||||
shift: true,
|
||||
});
|
||||
|
||||
expect(horizLine.x).toBeCloseTo(selectionWidth * (1 - scale));
|
||||
expect(horizLine.y).toBeCloseTo(selectionHeight * (1 - scale));
|
||||
@@ -685,6 +1008,7 @@ describe("multiple selection", () => {
|
||||
width: 100,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const rightBoundArrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 50,
|
||||
@@ -703,7 +1027,9 @@ describe("multiple selection", () => {
|
||||
const rightArrowBinding = { ...rightBoundArrow.endBinding };
|
||||
delete rightArrowBinding.gap;
|
||||
|
||||
UI.resize([rectangle, rightBoundArrow], "nw", move);
|
||||
UI.resize([rectangle, rightBoundArrow], "nw", move, {
|
||||
shift: true,
|
||||
});
|
||||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
@@ -711,7 +1037,11 @@ describe("multiple selection", () => {
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
expect(leftBoundArrow.endBinding).toMatchObject(leftArrowBinding);
|
||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||
leftArrowBinding.elementId,
|
||||
);
|
||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||
|
||||
expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||
expect(rightBoundArrow.y).toBeCloseTo(
|
||||
@@ -721,7 +1051,13 @@ describe("multiple selection", () => {
|
||||
expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||
expect(rightBoundArrow.angle).toEqual(0);
|
||||
expect(rightBoundArrow.startBinding).toBeNull();
|
||||
expect(rightBoundArrow.endBinding).toMatchObject(rightArrowBinding);
|
||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||
rightArrowBinding.elementId,
|
||||
);
|
||||
expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
|
||||
rightArrowBinding.focus!,
|
||||
);
|
||||
});
|
||||
|
||||
it("resizes with labeled arrows", async () => {
|
||||
@@ -751,7 +1087,9 @@ describe("multiple selection", () => {
|
||||
const move = [80, 0] as [number, number];
|
||||
const scale = move[0] / selectionWidth + 1;
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
UI.resize([topArrow.get(), bottomArrow.get()], "se", move);
|
||||
UI.resize([topArrow.get(), bottomArrow.get()], "se", move, {
|
||||
shift: true,
|
||||
});
|
||||
const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
topArrow,
|
||||
topArrowLabel,
|
||||
@@ -815,7 +1153,7 @@ describe("multiple selection", () => {
|
||||
1 - move[1] / selectionHeight,
|
||||
);
|
||||
|
||||
UI.resize([topText, bottomText], "ne", move);
|
||||
UI.resize([topText, bottomText], "ne", move, { shift: true });
|
||||
|
||||
expect(topText.x).toBeCloseTo(0);
|
||||
expect(topText.y).toBeCloseTo(-selectionHeight * (scale - 1));
|
||||
@@ -828,7 +1166,7 @@ describe("multiple selection", () => {
|
||||
expect(bottomText.angle).toEqual(0);
|
||||
});
|
||||
|
||||
it("resizes with images", () => {
|
||||
it("resizes with images (proportional)", () => {
|
||||
const topImage = API.createElement({
|
||||
type: "image",
|
||||
x: 0,
|
||||
@@ -843,7 +1181,7 @@ describe("multiple selection", () => {
|
||||
width: 120,
|
||||
height: 80,
|
||||
});
|
||||
h.elements = [topImage, bottomImage];
|
||||
API.setElements([topImage, bottomImage]);
|
||||
|
||||
const selectionWidth = 200;
|
||||
const selectionHeight = 230;
|
||||
@@ -891,7 +1229,7 @@ describe("multiple selection", () => {
|
||||
1 + (2 * move[1]) / selectionHeight,
|
||||
);
|
||||
|
||||
UI.resize([rectangle, ellipse], "se", move, { alt: true });
|
||||
UI.resize([rectangle, ellipse], "se", move, { shift: true, alt: true });
|
||||
|
||||
expect(rectangle.x).toBeCloseTo(-200 * scale);
|
||||
expect(rectangle.y).toBeCloseTo(-140 * scale);
|
||||
@@ -915,19 +1253,19 @@ describe("multiple selection", () => {
|
||||
height: 100,
|
||||
angle: (Math.PI * 7) / 6,
|
||||
});
|
||||
h.elements = [image];
|
||||
API.setElements([image]);
|
||||
|
||||
const line = UI.createElement("line", {
|
||||
x: 60,
|
||||
y: 0,
|
||||
points: [
|
||||
[0, 0],
|
||||
[-40, 40],
|
||||
[-20, 60],
|
||||
[20, 20],
|
||||
[40, 40],
|
||||
[-20, 100],
|
||||
[-60, 60],
|
||||
pointFrom(0, 0),
|
||||
pointFrom(-40, 40),
|
||||
pointFrom(-20, 60),
|
||||
pointFrom(20, 20),
|
||||
pointFrom(40, 40),
|
||||
pointFrom(-20, 100),
|
||||
pointFrom(-60, 60),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -954,7 +1292,9 @@ describe("multiple selection", () => {
|
||||
const scaleY = -scaleX;
|
||||
const lineOrigBounds = getBoundsFromPoints(line);
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
UI.resize([line, image, rectangle, boundArrow], "se", move);
|
||||
UI.resize([line, image, rectangle, boundArrow], "se", move, {
|
||||
shift: true,
|
||||
});
|
||||
const lineNewBounds = getBoundsFromPoints(line);
|
||||
const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
boundArrow,
|
||||
@@ -979,7 +1319,7 @@ describe("multiple selection", () => {
|
||||
expect(image.width).toBeCloseTo(100 * -scaleX);
|
||||
expect(image.height).toBeCloseTo(100 * scaleY);
|
||||
expect(image.angle).toBeCloseTo((Math.PI * 5) / 6);
|
||||
expect(image.scale).toEqual([1, 1]);
|
||||
expect(image.scale).toEqual([-1, 1]);
|
||||
|
||||
expect(rectangle.x).toBeCloseTo((180 + 160) * scaleX);
|
||||
expect(rectangle.y).toBeCloseTo(60 * scaleY);
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { makeNextSelectedElementIds } from "./selection";
|
||||
import { makeNextSelectedElementIds } from "../src/selection";
|
||||
|
||||
describe("makeNextSelectedElementIds", () => {
|
||||
const _makeNextSelectedElementIds = (
|
||||
+3
-12
@@ -1,13 +1,13 @@
|
||||
import { vi } from "vitest";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import * as constants from "../constants";
|
||||
|
||||
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();
|
||||
@@ -55,13 +55,4 @@ describe("getPerfectElementSize", () => {
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
});
|
||||
});
|
||||
});
|
||||
+11
-7
@@ -1,8 +1,12 @@
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { normalizeElementOrder } from "./sortElements";
|
||||
import { ExcalidrawElement } from "./types";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
|
||||
import { normalizeElementOrder } from "../src/sortElements";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const assertOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
expectedOrder: string[],
|
||||
@@ -33,7 +37,7 @@ describe("normalizeElementsOrder", () => {
|
||||
boundElements: [],
|
||||
});
|
||||
|
||||
mutateElement(container, {
|
||||
mutateElement(container, new Map(), {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
@@ -350,7 +354,7 @@ describe("normalizeElementsOrder", () => {
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
mutateElement(container, {
|
||||
h.app.scene.mutateElement(container, {
|
||||
boundElements: [
|
||||
{ type: "text", id: boundText.id },
|
||||
{ type: "text", id: "xxx" },
|
||||
@@ -385,7 +389,7 @@ describe("normalizeElementsOrder", () => {
|
||||
boundElements: [],
|
||||
groupIds: ["C", "A"],
|
||||
});
|
||||
mutateElement(container, {
|
||||
h.app.scene.mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { getLineHeight } from "@excalidraw/common";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
} from "../src/textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||
|
||||
import type { ExcalidrawTextElementWithContainer } from "../src/types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
describe("Test getContainerCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 44.2893218813452455,
|
||||
y: 39.64466094067262,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 15,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 65,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeContainerDimensionForBoundText", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should compute container height correctly for rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
160,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
226,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
320,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(79);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: '"container-id',
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
height: 175,
|
||||
});
|
||||
|
||||
describe("Test detectLineHeight", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(detectLineHeight(textElement)).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getLineHeightInPx", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(
|
||||
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
|
||||
).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getLineHeight()).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return line height using default font family for unknown font", () => {
|
||||
const UNKNOWN_FONT = 5;
|
||||
expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return correct line height", () => {
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
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
|
||||
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
|
||||
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
|
||||
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
|
||||
|
||||
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello\nExcalidraw`);
|
||||
});
|
||||
|
||||
it("should return the text as is if max width is invalid", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
expect(wrapText(text, font, NaN)).toEqual(text);
|
||||
expect(wrapText(text, font, -1)).toEqual(text);
|
||||
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||
});
|
||||
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
it("should not wrap number when wrapping line", () => {
|
||||
const text = "don't wrap this number 99,100.99";
|
||||
const maxWidth = 300;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("don't wrap this number\n99,100.99");
|
||||
});
|
||||
|
||||
it("should trim all trailing whitespaces", () => {
|
||||
const text = "Hello ";
|
||||
const maxWidth = 50;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should trim all but one trailing whitespaces", () => {
|
||||
const text = "Hello ";
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello ");
|
||||
});
|
||||
|
||||
it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
|
||||
const text = " Hello World";
|
||||
const maxWidth = 90;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" Hello\nWorld");
|
||||
});
|
||||
|
||||
it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
|
||||
const text = " Hello World ";
|
||||
const maxWidth = 90;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" Hello\nWorld ");
|
||||
});
|
||||
|
||||
it("should trim keep those whitespace that fit in the trailing line", () => {
|
||||
const text = "Hello Wo rl d ";
|
||||
const maxWidth = 100;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello Wo\nrl d ");
|
||||
});
|
||||
|
||||
it("should support multiple (multi-codepoint) emojis", () => {
|
||||
const text = "😀🗺🔥👩🏽🦰👨👩👧👦🇨🇿";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿");
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when text contains hyphen", () => {
|
||||
let text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
const res = wrapText(text, font, 110);
|
||||
expect(res).toBe(
|
||||
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
|
||||
);
|
||||
|
||||
text = "Hello thereusing-now";
|
||||
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
|
||||
});
|
||||
|
||||
it("should support wrapping nested lists", () => {
|
||||
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
|
||||
|
||||
const maxWidth = 100;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
|
||||
});
|
||||
|
||||
describe("When text is CJK", () => {
|
||||
it("should break each CJK character when width is very small", () => {
|
||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(
|
||||
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
|
||||
);
|
||||
});
|
||||
|
||||
it("should break CJK text into longer segments when width is larger", () => {
|
||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||
const maxWidth = 30;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
|
||||
// measureText is mocked, so it's not precisely what would happen in prod
|
||||
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
|
||||
});
|
||||
|
||||
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
|
||||
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
|
||||
|
||||
const maxWidth = 150;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
|
||||
|
||||
const maxWidth3 = 30;
|
||||
const res3 = wrapText(text, font, maxWidth3);
|
||||
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
|
||||
});
|
||||
|
||||
it("should break before and after a regular CJK character", () => {
|
||||
const text = "HelloたWorld";
|
||||
const maxWidth1 = 50;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe("Hello\nた\nWorld");
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe("Helloた\nWorld");
|
||||
});
|
||||
|
||||
it("should break before and after certain CJK symbols", () => {
|
||||
const text = "こんにちは〃世界";
|
||||
const maxWidth1 = 50;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe("こんにちは\n〃世界");
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe("こんにちは〃\n世界");
|
||||
});
|
||||
|
||||
it("should break after, not before for certain CJK pairs", () => {
|
||||
const text = "Hello た。";
|
||||
const maxWidth = 70;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello\nた。");
|
||||
});
|
||||
|
||||
it("should break before, not after for certain CJK pairs", () => {
|
||||
const text = "Hello「たWorld」";
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello\n「た\nWorld」");
|
||||
});
|
||||
|
||||
it("should break after, not before for certain CJK character pairs", () => {
|
||||
const text = "「Helloた」World";
|
||||
const maxWidth = 70;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("「Hello\nた」World");
|
||||
});
|
||||
|
||||
it("should break Chinese sentences", () => {
|
||||
const text = `中国你好!这是一个测试。
|
||||
我们来看看:人民币¥1234「很贵」
|
||||
(括号)、逗号,句号。空格 换行 全角符号…—`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`中国你好!这是一\n个测试。
|
||||
我们来看看:人民\n币¥1234「很\n贵」
|
||||
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`中国你好!\n这是一个测\n试。
|
||||
我们来看\n看:人民币\n¥1234\n「很贵」
|
||||
(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
|
||||
});
|
||||
|
||||
it("should break Japanese sentences", () => {
|
||||
const text = `日本こんにちは!これはテストです。
|
||||
見てみましょう:円¥1234「高い」
|
||||
(括弧)、読点、句点。
|
||||
空白 改行 全角記号…ー`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
|
||||
見てみましょ\nう:円¥1234\n「高い」
|
||||
(括弧)、読\n点、句点。
|
||||
空白 改行\n全角記号…ー`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
|
||||
見てみ\nましょう:\n円\n¥1234\n「高い」
|
||||
(括\n弧)、読\n点、句点。
|
||||
空白\n改行 全角\n記号…ー`);
|
||||
});
|
||||
|
||||
it("should break Korean sentences", () => {
|
||||
const text = `한국 안녕하세요! 이것은 테스트입니다.
|
||||
우리 보자: 원화₩1234「비싸다」
|
||||
(괄호), 쉼표, 마침표.
|
||||
공백 줄바꿈 전각기호…—`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
|
||||
우리 보자: 원\n화₩1234「비\n싸다」
|
||||
(괄호), 쉼\n표, 마침표.
|
||||
공백 줄바꿈 전\n각기호…—`);
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
|
||||
우리 보자:\n원화\n₩1234\n「비싸다」
|
||||
(괄호),\n쉼표, 마침\n표.
|
||||
공백 줄바꿈\n전각기호…—`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contains leading whitespaces", () => {
|
||||
const text = " \t Hello world";
|
||||
|
||||
it("should preserve leading whitespaces", () => {
|
||||
const maxWidth = 120;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" \t Hello\nworld");
|
||||
});
|
||||
|
||||
it("should break and collapse leading whitespaces when line breaks", () => {
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("\nHello\nworld");
|
||||
});
|
||||
|
||||
it("should break and collapse leading whitespaces whe words break", () => {
|
||||
const maxWidth = 30;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("\nHel\nlo\nwor\nld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contains trailing whitespaces", () => {
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 190;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should ignore trailing whitespaces when line breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 400;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
|
||||
});
|
||||
|
||||
it("should not ignore trailing whitespaces when word breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 300;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
|
||||
});
|
||||
|
||||
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 180;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 70,
|
||||
res: `Hello\nwhats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 15,
|
||||
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `Hello whats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 240,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "push the word if its equal to max width",
|
||||
width: 50,
|
||||
res: `Hello\nwhats\nup`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello\n whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 70,
|
||||
res: `Hello\n whats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 15,
|
||||
res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
width: 140,
|
||||
res: `Hello\n whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 160,
|
||||
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
|
||||
},
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 120,
|
||||
res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 590,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test parseTokens", () => {
|
||||
it("should tokenize latin", () => {
|
||||
let text = "Excalidraw is a virtual collaborative whiteboard";
|
||||
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Excalidraw",
|
||||
" ",
|
||||
"is",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"virtual",
|
||||
" ",
|
||||
"collaborative",
|
||||
" ",
|
||||
"whiteboard",
|
||||
]);
|
||||
|
||||
text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Wikipedia",
|
||||
" ",
|
||||
"is",
|
||||
" ",
|
||||
"hosted",
|
||||
" ",
|
||||
"by",
|
||||
" ",
|
||||
"Wikimedia-",
|
||||
" ",
|
||||
"Foundation,",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"non-",
|
||||
"profit",
|
||||
" ",
|
||||
"organization",
|
||||
" ",
|
||||
"that",
|
||||
" ",
|
||||
"also",
|
||||
" ",
|
||||
"hosts",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"range-",
|
||||
"of",
|
||||
" ",
|
||||
"other",
|
||||
" ",
|
||||
"projects",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not tokenize number", () => {
|
||||
const text = "99,100.99";
|
||||
const tokens = parseTokens(text);
|
||||
expect(tokens).toEqual(["99,100.99"]);
|
||||
});
|
||||
|
||||
it("should tokenize joined emojis", () => {
|
||||
const text = `😬🌍🗺🔥☂️👩🏽🦰👨👩👧👦👩🏾🔬🏳️🌈🧔♀️🧑🤝🧑🙅🏽♂️✅0️⃣🇨🇿🦅`;
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
expect(tokens).toEqual([
|
||||
"😬",
|
||||
"🌍",
|
||||
"🗺",
|
||||
"🔥",
|
||||
"☂️",
|
||||
"👩🏽🦰",
|
||||
"👨👩👧👦",
|
||||
"👩🏾🔬",
|
||||
"🏳️🌈",
|
||||
"🧔♀️",
|
||||
"🧑🤝🧑",
|
||||
"🙅🏽♂️",
|
||||
"✅",
|
||||
"0️⃣",
|
||||
"🇨🇿",
|
||||
"🦅",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should tokenize emojis mixed with mixed text", () => {
|
||||
const text = `😬a🌍b🗺c🔥d☂️《👩🏽🦰》👨👩👧👦德👩🏾🔬こ🏳️🌈안🧔♀️g🧑🤝🧑h🙅🏽♂️e✅f0️⃣g🇨🇿10🦅#hash`;
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
expect(tokens).toEqual([
|
||||
"😬",
|
||||
"a",
|
||||
"🌍",
|
||||
"b",
|
||||
"🗺",
|
||||
"c",
|
||||
"🔥",
|
||||
"d",
|
||||
"☂️",
|
||||
"《",
|
||||
"👩🏽🦰",
|
||||
"》",
|
||||
"👨👩👧👦",
|
||||
"德",
|
||||
"👩🏾🔬",
|
||||
"こ",
|
||||
"🏳️🌈",
|
||||
"안",
|
||||
"🧔♀️",
|
||||
"g",
|
||||
"🧑🤝🧑",
|
||||
"h",
|
||||
"🙅🏽♂️",
|
||||
"e",
|
||||
"✅",
|
||||
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
|
||||
"🇨🇿",
|
||||
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
|
||||
"🦅",
|
||||
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
|
||||
]);
|
||||
});
|
||||
|
||||
it("should tokenize decomposed chars into their composed variants", () => {
|
||||
// each input character is in a decomposed form
|
||||
const text = "čでäぴέ다й한";
|
||||
expect(text.normalize("NFC").length).toEqual(8);
|
||||
expect(text).toEqual(text.normalize("NFD"));
|
||||
|
||||
const tokens = parseTokens(text);
|
||||
expect(tokens.length).toEqual(8);
|
||||
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
|
||||
});
|
||||
|
||||
it("should tokenize artificial CJK", () => {
|
||||
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
|
||||
// [
|
||||
// '《道', '德', '經》', '醫-',
|
||||
// '醫', 'こ', 'ん', 'に',
|
||||
// 'ち', 'は', '世', '界!',
|
||||
// '안', '녕', '하', '세',
|
||||
// '요', '세', '계;', '요』,',
|
||||
// '다.', '다...', '원/', '달',
|
||||
// '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
|
||||
// 'た…', '[Hello]', ' ', '\t',
|
||||
// ' ', 'World?', 'ニ', 'ュ',
|
||||
// 'ー', 'ヨ', 'ー', 'ク・',
|
||||
// '¥3700.55', 'す。', '090-', '1234-',
|
||||
// '5678', '¥1,000〜', '$5,000', '「素',
|
||||
// '晴', 'ら', 'し', 'い!」',
|
||||
// '〔重', '要〕', '#', '1:',
|
||||
// 'Taro', '君', '30%', 'は、',
|
||||
// '(た', 'な', 'ば', 'た)',
|
||||
// '〰', '¥110±', '¥570', 'で',
|
||||
// '20℃〜', '9:30〜', '10:00', '【一',
|
||||
// '番】'
|
||||
// ]
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
// Latin
|
||||
expect(tokens).toContain("[[1]]");
|
||||
expect(tokens).toContain("[Hello]");
|
||||
expect(tokens).toContain("World?");
|
||||
expect(tokens).toContain("Taro");
|
||||
|
||||
// Chinese
|
||||
expect(tokens).toContain("《道");
|
||||
expect(tokens).toContain("德");
|
||||
expect(tokens).toContain("經》");
|
||||
expect(tokens).toContain("醫-");
|
||||
expect(tokens).toContain("醫");
|
||||
|
||||
// Japanese
|
||||
expect(tokens).toContain("こ");
|
||||
expect(tokens).toContain("ん");
|
||||
expect(tokens).toContain("に");
|
||||
expect(tokens).toContain("ち");
|
||||
expect(tokens).toContain("は");
|
||||
expect(tokens).toContain("世");
|
||||
expect(tokens).toContain("ク・");
|
||||
expect(tokens).toContain("界!");
|
||||
expect(tokens).toContain("た…");
|
||||
expect(tokens).toContain("す。");
|
||||
expect(tokens).toContain("ュ");
|
||||
expect(tokens).toContain("「素");
|
||||
expect(tokens).toContain("晴");
|
||||
expect(tokens).toContain("ら");
|
||||
expect(tokens).toContain("し");
|
||||
expect(tokens).toContain("い!」");
|
||||
expect(tokens).toContain("君");
|
||||
expect(tokens).toContain("は、");
|
||||
expect(tokens).toContain("(た");
|
||||
expect(tokens).toContain("な");
|
||||
expect(tokens).toContain("ば");
|
||||
expect(tokens).toContain("た)");
|
||||
expect(tokens).toContain("で");
|
||||
expect(tokens).toContain("【一");
|
||||
expect(tokens).toContain("番】");
|
||||
|
||||
// Check for Korean
|
||||
expect(tokens).toContain("안");
|
||||
expect(tokens).toContain("녕");
|
||||
expect(tokens).toContain("하");
|
||||
expect(tokens).toContain("세");
|
||||
expect(tokens).toContain("요");
|
||||
expect(tokens).toContain("세");
|
||||
expect(tokens).toContain("계;");
|
||||
expect(tokens).toContain("요』,");
|
||||
expect(tokens).toContain("다.");
|
||||
expect(tokens).toContain("다...");
|
||||
expect(tokens).toContain("원/");
|
||||
expect(tokens).toContain("달");
|
||||
expect(tokens).toContain("(((다)))");
|
||||
expect(tokens).toContain("〚({((한))>)〛");
|
||||
expect(tokens).toContain("(「た」)");
|
||||
|
||||
// Numbers and units
|
||||
expect(tokens).toContain("¥3700.55");
|
||||
expect(tokens).toContain("090-");
|
||||
expect(tokens).toContain("1234-");
|
||||
expect(tokens).toContain("5678");
|
||||
expect(tokens).toContain("¥1,000〜");
|
||||
expect(tokens).toContain("$5,000");
|
||||
expect(tokens).toContain("1:");
|
||||
expect(tokens).toContain("30%");
|
||||
expect(tokens).toContain("¥110±");
|
||||
expect(tokens).toContain("20℃〜");
|
||||
expect(tokens).toContain("9:30〜");
|
||||
expect(tokens).toContain("10:00");
|
||||
|
||||
// Punctuation and symbols
|
||||
expect(tokens).toContain(" ");
|
||||
expect(tokens).toContain("\t");
|
||||
expect(tokens).toContain(" ");
|
||||
expect(tokens).toContain("ニ");
|
||||
expect(tokens).toContain("ー");
|
||||
expect(tokens).toContain("ヨ");
|
||||
expect(tokens).toContain("〰");
|
||||
expect(tokens).toContain("#");
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -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", () => {
|
||||
+111
-83
@@ -1,25 +1,34 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import { 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 { 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();
|
||||
@@ -46,6 +55,7 @@ const populateElements = (
|
||||
height?: number;
|
||||
containerId?: string;
|
||||
frameId?: ExcalidrawFrameElement["id"];
|
||||
index?: ExcalidrawElement["index"];
|
||||
}[],
|
||||
appState?: Partial<AppState>,
|
||||
) => {
|
||||
@@ -85,31 +95,35 @@ const populateElements = (
|
||||
);
|
||||
|
||||
// initialize `boundElements` on containers, if applicable
|
||||
h.elements = newElements.map((element, index, elements) => {
|
||||
const nextElement = elements[index + 1];
|
||||
if (
|
||||
nextElement &&
|
||||
"containerId" in nextElement &&
|
||||
element.id === nextElement.containerId
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
boundElements: [{ type: "text", id: nextElement.id }],
|
||||
};
|
||||
}
|
||||
return element;
|
||||
});
|
||||
API.setElements(
|
||||
newElements.map((element, index, elements) => {
|
||||
const nextElement = elements[index + 1];
|
||||
if (
|
||||
nextElement &&
|
||||
"containerId" in nextElement &&
|
||||
element.id === nextElement.containerId
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
boundElements: [{ type: "text", id: nextElement.id }],
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
);
|
||||
|
||||
h.setState({
|
||||
...selectGroupsForSelectedElements(
|
||||
{ ...h.state, ...appState, selectedElementIds },
|
||||
h.elements,
|
||||
h.state,
|
||||
null,
|
||||
),
|
||||
...appState,
|
||||
selectedElementIds,
|
||||
} as AppState);
|
||||
act(() => {
|
||||
h.setState({
|
||||
...selectGroupsForSelectedElements(
|
||||
{ ...h.state, ...appState, selectedElementIds },
|
||||
h.elements,
|
||||
h.state,
|
||||
null,
|
||||
),
|
||||
...appState,
|
||||
selectedElementIds,
|
||||
} as AppState);
|
||||
});
|
||||
|
||||
return selectedElementIds;
|
||||
};
|
||||
@@ -139,7 +153,7 @@ const assertZindex = ({
|
||||
}) => {
|
||||
const selectedElementIds = populateElements(elements, appState);
|
||||
operations.forEach(([action, expected]) => {
|
||||
h.app.actionManager.executeAction(action);
|
||||
API.executeAction(action);
|
||||
expect(h.elements.map((element) => element.id)).toEqual(expected);
|
||||
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
||||
});
|
||||
@@ -240,6 +254,20 @@ describe("z-index manipulation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// elements should not duplicate
|
||||
assertZindex({
|
||||
elements: [
|
||||
{ id: "A", containerId: "C" },
|
||||
{ id: "B" },
|
||||
{ id: "C", isSelected: true },
|
||||
],
|
||||
operations: [
|
||||
[actionSendBackward, ["A", "C", "B"]],
|
||||
// noop
|
||||
[actionSendBackward, ["A", "C", "B"]],
|
||||
],
|
||||
});
|
||||
|
||||
// grouped elements should be atomic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -893,29 +921,29 @@ describe("z-index manipulation", () => {
|
||||
{ id: "A", isSelected: true },
|
||||
{ id: "B", isSelected: true },
|
||||
]);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
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([
|
||||
{ id: "A", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "B", groupIds: ["g1"], isSelected: true },
|
||||
]);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements).toMatchObject([
|
||||
{ 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,}/)],
|
||||
},
|
||||
@@ -926,17 +954,17 @@ describe("z-index manipulation", () => {
|
||||
{ id: "B", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "C" },
|
||||
]);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements).toMatchObject([
|
||||
{ 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,}/)],
|
||||
},
|
||||
@@ -948,14 +976,14 @@ describe("z-index manipulation", () => {
|
||||
{ id: "B", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "C", isSelected: true },
|
||||
]);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
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([
|
||||
@@ -964,16 +992,16 @@ describe("z-index manipulation", () => {
|
||||
{ id: "C", groupIds: ["g2"], isSelected: true },
|
||||
{ id: "D", groupIds: ["g2"], isSelected: true },
|
||||
]);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
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(
|
||||
@@ -986,14 +1014,14 @@ describe("z-index manipulation", () => {
|
||||
selectedGroupIds: { g1: true },
|
||||
},
|
||||
);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
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(
|
||||
@@ -1006,14 +1034,14 @@ describe("z-index manipulation", () => {
|
||||
selectedGroupIds: { g2: true },
|
||||
},
|
||||
);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
]);
|
||||
|
||||
populateElements(
|
||||
@@ -1029,20 +1057,20 @@ describe("z-index manipulation", () => {
|
||||
selectedGroupIds: { g2: true, g4: true },
|
||||
},
|
||||
);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"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(
|
||||
@@ -1053,10 +1081,10 @@ describe("z-index manipulation", () => {
|
||||
],
|
||||
{ editingGroupId: "g1" },
|
||||
);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"A_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
"B",
|
||||
"C",
|
||||
]);
|
||||
@@ -1069,11 +1097,11 @@ describe("z-index manipulation", () => {
|
||||
],
|
||||
{ editingGroupId: "g1" },
|
||||
);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"B_copy",
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
]);
|
||||
|
||||
@@ -1085,12 +1113,12 @@ describe("z-index manipulation", () => {
|
||||
],
|
||||
{ editingGroupId: "g1" },
|
||||
);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"A_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
"B",
|
||||
"B_copy",
|
||||
getCloneByOrigId("B").id,
|
||||
"C",
|
||||
]);
|
||||
});
|
||||
@@ -1101,12 +1129,12 @@ describe("z-index manipulation", () => {
|
||||
{ id: "B" },
|
||||
{ id: "C", groupIds: ["g1"], isSelected: true },
|
||||
]);
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"C",
|
||||
"A_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"B",
|
||||
]);
|
||||
});
|
||||
@@ -1119,14 +1147,14 @@ describe("z-index manipulation", () => {
|
||||
{ id: "D" },
|
||||
]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ g1: true });
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
API.executeAction(actionDuplicateSelection);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"A_copy",
|
||||
"B_copy",
|
||||
"C_copy",
|
||||
getCloneByOrigId("A").id,
|
||||
getCloneByOrigId("B").id,
|
||||
getCloneByOrigId("C").id,
|
||||
"D",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
||||
@@ -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,61 +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
|
||||
|
||||
- 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 `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)
|
||||
- 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)
|
||||
|
||||
- `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.adapter` as the new recommended pattern to handle library initialization and persistence on library updates [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
||||
- 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 `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638)
|
||||
|
||||
- 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)
|
||||
|
||||
- `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)
|
||||
- Duplicating/removing frame while children selected [#9079](https://github.com/excalidraw/excalidraw/pull/9079)
|
||||
|
||||
- `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.
|
||||
- Elbow arrow z-index binding [#9067](https://github.com/excalidraw/excalidraw/pull/9067)
|
||||
|
||||
- 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
|
||||
- Library item checkbox style regression [#9080](https://github.com/excalidraw/excalidraw/pull/9080)
|
||||
|
||||
#### Bundler
|
||||
- Elbow arrow orthogonality [#9073](https://github.com/excalidraw/excalidraw/pull/9073)
|
||||
|
||||
- CSS needs to be imported so you will need to import the css along with the excalidraw component
|
||||
- Button bg CSS variable leaking into other styles [#9075](https://github.com/excalidraw/excalidraw/pull/9075)
|
||||
|
||||
```js
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
```
|
||||
- Fonts not loading on export (again) [#9064](https://github.com/excalidraw/excalidraw/pull/9064)
|
||||
|
||||
- The `types` path is updated
|
||||
- Merge server-side fonts with liberation sans [#9052](https://github.com/excalidraw/excalidraw/pull/9052)
|
||||
|
||||
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.
|
||||
- Hyperlinks html entities [#9063](https://github.com/excalidraw/excalidraw/pull/9063)
|
||||
|
||||
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.
|
||||
- Remove flushSync to fix flickering [#9057](https://github.com/excalidraw/excalidraw/pull/9057)
|
||||
|
||||
#### Browser
|
||||
- Excalidraw issue #9045 flowcharts: align attributes of new node [#9047](https://github.com/excalidraw/excalidraw/pull/9047)
|
||||
|
||||
- Since its `ESM` so now script type `module` can be used to load it and css needs to be loaded as well.
|
||||
- Align arrows bound to elements excalidraw#8833 [#8998](https://github.com/excalidraw/excalidraw/pull/8998)
|
||||
|
||||
```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>
|
||||
```
|
||||
- Update elbow arrow on font size change #8798 [#9002](https://github.com/excalidraw/excalidraw/pull/9002)
|
||||
|
||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||
- Undo for elbow arrows create incorrect routing [#9046](https://github.com/excalidraw/excalidraw/pull/9046)
|
||||
|
||||
- Flowchart clones the current arrowhead [#8581](https://github.com/excalidraw/excalidraw/pull/8581)
|
||||
|
||||
- Adding partial group to frame [#9014](https://github.com/excalidraw/excalidraw/pull/9014)
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -91,8 +771,6 @@ define: {
|
||||
|
||||
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||
|
||||
---
|
||||
|
||||
## 0.17.0 (2023-11-14)
|
||||
|
||||
### Features
|
||||
@@ -193,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,8 +1,11 @@
|
||||
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";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
@@ -17,7 +20,7 @@ export const actionAddToLibrary = register({
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
@@ -41,7 +44,7 @@ export const actionAddToLibrary = register({
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.addedToLibrary") },
|
||||
@@ -50,7 +53,7 @@ export const actionAddToLibrary = register({
|
||||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
@@ -58,5 +61,5 @@ export const actionAddToLibrary = register({
|
||||
};
|
||||
});
|
||||
},
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
label: "labels.addToLibrary",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
@@ -7,22 +23,17 @@ import {
|
||||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
_: unknown,
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
|
||||
export const alignActionsPredicate = (
|
||||
appState: UIAppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -40,13 +51,8 @@ const alignSelectedElements = (
|
||||
alignment: Alignment,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const updatedElements = alignElements(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
alignment,
|
||||
);
|
||||
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
@@ -59,8 +65,11 @@ const alignSelectedElements = (
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
label: "labels.alignTop",
|
||||
icon: AlignTopIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -68,14 +77,14 @@ export const actionAlignTop = register({
|
||||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -90,8 +99,11 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
label: "labels.alignBottom",
|
||||
icon: AlignBottomIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -99,14 +111,14 @@ export const actionAlignBottom = register({
|
||||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -121,8 +133,11 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
label: "labels.alignLeft",
|
||||
icon: AlignLeftIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -130,14 +145,14 @@ export const actionAlignLeft = register({
|
||||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -152,8 +167,11 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
label: "labels.alignRight",
|
||||
icon: AlignRightIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -161,14 +179,14 @@ export const actionAlignRight = register({
|
||||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -183,8 +201,11 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
label: "labels.centerVertically",
|
||||
icon: CenterVerticallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -192,12 +213,12 @@ export const actionAlignVerticallyCentered = register({
|
||||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -210,8 +231,11 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
label: "labels.centerHorizontally",
|
||||
icon: CenterHorizontallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -219,12 +243,12 @@ export const actionAlignHorizontallyCentered = register({
|
||||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
||||
@@ -1,42 +1,58 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
VERTICAL_ALIGN,
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "../element/containerCache";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { newElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { getFontString } from "../utils";
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
label: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -63,7 +79,7 @@ export const actionUnbindText = register({
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
@@ -71,7 +87,7 @@ export const actionUnbindText = register({
|
||||
x,
|
||||
y,
|
||||
});
|
||||
mutateElement(element, {
|
||||
app.scene.mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
@@ -84,14 +100,14 @@ export const actionUnbindText = register({
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
label: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -136,23 +152,21 @@ export const actionBindText = register({
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||
});
|
||||
mutateElement(container, {
|
||||
app.scene.mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
// overwritting the cache with original container height so
|
||||
// it can be restored when unbind
|
||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||
@@ -160,7 +174,7 @@ export const actionBindText = register({
|
||||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -180,6 +194,8 @@ const pushTextAboveContainer = (
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
@@ -198,17 +214,19 @@ const pushContainerBelowText = (
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 0, container);
|
||||
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
export const actionWrapTextInContainer = register({
|
||||
name: "wrapTextInContainer",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
label: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const areTextElements = selectedElements.every((el) => isTextElement(el));
|
||||
return selectedElements.length > 0 && areTextElements;
|
||||
const someTextElements = selectedElements.some((el) => isTextElement(el));
|
||||
return selectedElements.length > 0 && someTextElements;
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -278,32 +296,30 @@ export const actionWrapTextInContainer = register({
|
||||
}
|
||||
|
||||
if (startBinding || endBinding) {
|
||||
mutateElement(ele, { startBinding, endBinding }, false);
|
||||
app.scene.mutateElement(ele, {
|
||||
startBinding,
|
||||
endBinding,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(
|
||||
textElement,
|
||||
{
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
},
|
||||
false,
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
@@ -314,7 +330,7 @@ export const actionWrapTextInContainer = register({
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,30 +1,59 @@
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
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";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { SceneBounds } from "../element/bounds";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import {
|
||||
handIcon,
|
||||
LassoIcon,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
zoomAreaIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
ZoomResetIcon,
|
||||
} from "../components/icons";
|
||||
import { setCursor } from "../cursor";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState, Offsets } from "../types";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
label: "labels.canvasBackground",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@@ -35,7 +64,9 @@ export const actionChangeViewBackgroundColor = register({
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
captureUpdate: !!value.viewBackgroundColor
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
@@ -59,11 +90,14 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
label: "labels.clearCanvas",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.clearCanvas &&
|
||||
!appState.viewModeEnabled
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector"
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -81,21 +115,25 @@ export const actionClearCanvas = register({
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
gridStep: appState.gridStep,
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
stats: appState.stats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
label: "buttons.zoomIn",
|
||||
viewMode: true,
|
||||
icon: ZoomInIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
@@ -111,16 +149,17 @@ export const actionZoomIn = register({
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-in-button zoom-button"
|
||||
icon={ZoomInIcon}
|
||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||
aria-label={t("buttons.zoomIn")}
|
||||
disabled={appState.zoom.value >= MAX_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
@@ -133,6 +172,8 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
label: "buttons.zoomOut",
|
||||
icon: ZoomOutIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
@@ -149,16 +190,17 @@ export const actionZoomOut = register({
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-out-button zoom-button"
|
||||
icon={ZoomOutIcon}
|
||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||
aria-label={t("buttons.zoomOut")}
|
||||
disabled={appState.zoom.value <= MIN_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
@@ -171,6 +213,8 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
label: "buttons.resetZoom",
|
||||
icon: ZoomResetIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
@@ -187,7 +231,7 @@ export const actionResetZoom = register({
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
@@ -213,6 +257,7 @@ export const actionResetZoom = register({
|
||||
const zoomValueToFitBoundsOnViewport = (
|
||||
bounds: SceneBounds,
|
||||
viewportDimensions: { width: number; height: number },
|
||||
viewportZoomFactor: number = 1, // default to 1 if not provided
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
@@ -220,118 +265,124 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
const clampedZoomValueToFitElements = Math.min(
|
||||
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
||||
1,
|
||||
);
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
|
||||
const adjustedZoomValue =
|
||||
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
return Math.min(adjustedZoomValue, 1);
|
||||
};
|
||||
|
||||
export const zoomToFitBounds = ({
|
||||
bounds,
|
||||
appState,
|
||||
canvasOffsets,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 0.7,
|
||||
viewportZoomFactor = 1,
|
||||
minZoom = -Infinity,
|
||||
maxZoom = Infinity,
|
||||
}: {
|
||||
bounds: SceneBounds;
|
||||
canvasOffsets?: Offsets;
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
||||
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
||||
const canvasOffsetRight = canvasOffsets?.right ?? 0;
|
||||
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
|
||||
|
||||
const effectiveCanvasWidth =
|
||||
appState.width - canvasOffsetLeft - canvasOffsetRight;
|
||||
const effectiveCanvasHeight =
|
||||
appState.height - canvasOffsetTop - canvasOffsetBottom;
|
||||
|
||||
let adjustedZoomValue;
|
||||
|
||||
if (fitToViewport) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
adjustedZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
||||
|
||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||
newZoomValue = Math.min(
|
||||
Math.max(newZoomValue, 0.1),
|
||||
30.0,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
let appStateWidth = appState.width;
|
||||
|
||||
if (appState.openSidebar) {
|
||||
const sidebarDOMElem = document.querySelector(
|
||||
".sidebar",
|
||||
) as HTMLElement | null;
|
||||
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
appStateWidth = !isRTL
|
||||
? appState.width - sidebarWidth
|
||||
: appState.width + sidebarWidth;
|
||||
}
|
||||
|
||||
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
effectiveCanvasWidth / commonBoundsWidth,
|
||||
effectiveCanvasHeight / commonBoundsHeight,
|
||||
) * viewportZoomFactor;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
||||
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
bounds,
|
||||
{
|
||||
width: effectiveCanvasWidth,
|
||||
height: effectiveCanvasHeight,
|
||||
},
|
||||
viewportZoomFactor,
|
||||
);
|
||||
}
|
||||
|
||||
const newZoomValue = getNormalizedZoom(
|
||||
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
|
||||
);
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
});
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
scrollX = centerScroll.scrollX;
|
||||
scrollY = centerScroll.scrollY;
|
||||
}
|
||||
},
|
||||
offsets: canvasOffsets,
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
scrollX,
|
||||
scrollY,
|
||||
scrollX: centerScroll.scrollX,
|
||||
scrollY: centerScroll.scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
};
|
||||
|
||||
export const zoomToFit = ({
|
||||
canvasOffsets,
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
}: {
|
||||
canvasOffsets?: Offsets;
|
||||
targetElements: readonly ExcalidrawElement[];
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
return zoomToFitBounds({
|
||||
canvasOffsets,
|
||||
bounds: commonBounds,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -340,6 +391,8 @@ export const zoomToFit = ({
|
||||
// size, it won't be zoomed in.
|
||||
export const actionZoomToFitSelectionInViewport = register({
|
||||
name: "zoomToFitSelectionInViewport",
|
||||
label: "labels.zoomToFitViewport",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -350,6 +403,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
@@ -363,6 +417,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
label: "helpDialog.zoomToSelection",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -373,6 +429,7 @@ export const actionZoomToFitSelection = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: true,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
@@ -385,9 +442,11 @@ export const actionZoomToFitSelection = register({
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
label: "helpDialog.zoomToFit",
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
zoomToFit({
|
||||
targetElements: elements,
|
||||
appState: {
|
||||
@@ -395,6 +454,7 @@ export const actionZoomToFit = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
}),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
@@ -405,6 +465,13 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
label: (_, appState) => {
|
||||
return appState.theme === THEME.DARK
|
||||
? "buttons.lightMode"
|
||||
: "buttons.darkMode";
|
||||
},
|
||||
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
@@ -414,7 +481,7 @@ export const actionToggleTheme = register({
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
@@ -425,6 +492,7 @@ export const actionToggleTheme = register({
|
||||
|
||||
export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
@@ -451,15 +519,51 @@ export const actionToggleEraserTool = register({
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
});
|
||||
|
||||
export const actionToggleLassoTool = register({
|
||||
name: "toggleLassoTool",
|
||||
label: "toolBar.lasso",
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (appState.activeTool.type !== "lasso") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "lasso",
|
||||
fromSelection: false,
|
||||
});
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
label: "toolBar.hand",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: handIcon,
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
@@ -486,7 +590,7 @@ export const actionToggleHandTool = register({
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
import { getTextFromElements } from "@excalidraw/element";
|
||||
|
||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
@@ -8,14 +13,17 @@ import {
|
||||
probablySupportsClipboardWriteText,
|
||||
readSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
label: "labels.copy",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
@@ -28,7 +36,7 @@ export const actionCopy = register({
|
||||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
@@ -37,16 +45,16 @@ export const actionCopy = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionPaste = register({
|
||||
name: "paste",
|
||||
label: "labels.paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, data, app) => {
|
||||
let types;
|
||||
@@ -63,7 +71,7 @@ export const actionPaste = register({
|
||||
|
||||
if (isFirefox) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
@@ -72,7 +80,7 @@ export const actionPaste = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
@@ -85,7 +93,7 @@ export const actionPaste = register({
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
@@ -94,32 +102,34 @@ export const actionPaste = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
label: "labels.cut",
|
||||
icon: cutIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
actionCopy.perform(elements, appState, event, app);
|
||||
return actionDeleteSelected.perform(elements, appState, null, app);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
label: "labels.copyAsSvg",
|
||||
icon: svgIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,33 +151,53 @@ export const actionCopyAsSvg = register({
|
||||
name: app.getName(),
|
||||
},
|
||||
);
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
toast: {
|
||||
message: t("toast.copyToClipboardAsSvg", {
|
||||
exportSelection: selectedElements.length
|
||||
? t("toast.selection")
|
||||
: t("toast.canvas"),
|
||||
exportColorScheme: appState.exportWithDarkMode
|
||||
? t("buttons.darkMode")
|
||||
: t("buttons.lightMode"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
keywords: ["svg", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
label: "labels.copyAsPng",
|
||||
icon: pngIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
@@ -201,7 +231,7 @@ export const actionCopyAsPng = register({
|
||||
}),
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@@ -210,19 +240,20 @@ export const actionCopyAsPng = register({
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardBlob && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
keywords: ["png", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
label: "labels.copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
@@ -230,21 +261,13 @@ export const copyText = register({
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
try {
|
||||
copyTextToSystemClipboard(text);
|
||||
copyTextToSystemClipboard(getTextFromElements(selectedElements));
|
||||
} catch (e) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
return {
|
||||
commitToHistory: false,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
@@ -258,5 +281,5 @@ export const copyText = register({
|
||||
.some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
keywords: ["text", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user