Compare commits

..

23 Commits

Author SHA1 Message Date
Aakansha Doshi d5b406b055 path fixes 2024-05-09 13:38:37 +05:30
Aakansha Doshi 7522285869 watch utils folder and build it when updated 2024-05-09 13:31:07 +05:30
Aakansha Doshi 58567dd2b6 move the files to src and tests folders under utils 2024-05-09 13:30:44 +05:30
Aakansha Doshi a76999e9a2 lint 2024-05-08 19:19:14 +05:30
Aakansha Doshi 5af532d229 Merge remote-tracking branch 'origin/master' into aakansha/esm 2024-05-08 19:15:37 +05:30
Aakansha Doshi 96fc3ac5bb lint 2024-05-06 19:09:09 +05:30
Aakansha Doshi 906652bac2 lint 2024-05-06 19:05:36 +05:30
Aakansha Doshi 53a0428705 update remaining paths to use utils workspace 2024-05-06 18:56:54 +05:30
Aakansha Doshi 927e36c7b4 fix tests 2024-05-06 18:42:07 +05:30
Aakansha Doshi e187faee77 update test script 2024-05-06 17:35:33 +05:30
Aakansha Doshi d12d97bfcb add cleanup workspaces script and test-utils 2024-05-06 17:28:58 +05:30
Aakansha Doshi 5acb5c9d91 update test script 2024-05-06 17:11:02 +05:30
Aakansha Doshi 23ee054025 fix lint 2024-05-06 17:05:19 +05:30
Aakansha Doshi d91b234db1 fix typo 2024-05-06 16:24:35 +05:30
Aakansha Doshi b2b03af1ec fix script 2024-05-06 16:22:18 +05:30
Aakansha Doshi f0876e3c03 tweaks 2024-05-06 16:18:05 +05:30
Aakansha Doshi 6d0ee330b7 update build scripts 2024-05-06 15:35:34 +05:30
Aakansha Doshi e48eed6b21 add utils to external 2024-05-06 15:25:44 +05:30
Aakansha Doshi 549786a504 add utils to external 2024-05-06 15:15:48 +05:30
Aakansha Doshi 73c53a3c7c extend ts config 2024-05-06 13:55:02 +05:30
Aakansha Doshi 72a98da527 ignore types in tests 2024-05-03 17:18:42 +05:30
Aakansha Doshi 45ff9d1053 add @excalidraw/utils to external and fixes 2024-05-03 17:08:07 +05:30
Aakansha Doshi 6a1477a55c feat: use @excalidraw/utils as a workspace in the codebase 2024-05-03 15:54:06 +05:30
102 changed files with 1391 additions and 3457 deletions
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
- name: Install and test - name: Install and test
run: | run: |
yarn install yarn install
yarn test:app yarn test
+3 -3
View File
@@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", "build:workspaces": "yarn workspace @excalidraw/utils run build:esm && yarn workspace @excalidraw/excalidraw run build:esm",
"dev": "yarn build:workspace && next dev -p 3005", "dev": "yarn build:workspaces && next dev -p 3005",
"build": "yarn build:workspace && next build", "build": "yarn build:workspaces && next build",
"start": "next start -p 3006", "start": "next start -p 3006",
"lint": "next lint" "lint": "next lint"
}, },
@@ -12,8 +12,9 @@
"typescript": "^5" "typescript": "^5"
}, },
"scripts": { "scripts": {
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", "build:workspaces": "yarn workspace @excalidraw/utils run build:esm && yarn workspace @excalidraw/excalidraw run build:esm",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", "start": "yarn build:workspaces && vite",
"build": "yarn build:workspaces && vite build",
"build:preview": "yarn build && vite preview --port 5002" "build:preview": "yarn build && vite preview --port 5002"
} }
} }
-47
View File
@@ -126,38 +126,6 @@ polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true; window.EXCALIDRAW_THROTTLE_RENDER = true;
declare global {
interface BeforeInstallPromptEventChoiceResult {
outcome: "accepted" | "dismissed";
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
}
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
let pwaEvent: BeforeInstallPromptEvent | null = null;
// Adding a listener outside of the component as it may (?) need to be
// subscribed early to catch the event.
//
// Also note that it will fire only if certain heuristics are met (user has
// used the app for some time, etc.)
window.addEventListener(
"beforeinstallprompt",
(event: BeforeInstallPromptEvent) => {
// prevent Chrome <= 67 from automatically showing the prompt
event.preventDefault();
// cache for later use
pwaEvent = event;
},
);
let isSelfEmbedding = false; let isSelfEmbedding = false;
if (window.self !== window.top) { if (window.self !== window.top) {
@@ -1132,21 +1100,6 @@ const ExcalidrawWrapper = () => {
); );
}, },
}, },
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!pwaEvent,
perform: () => {
if (pwaEvent) {
pwaEvent.prompt();
pwaEvent.userChoice.then(() => {
// event cannot be reused, but we'll hopefully
// grab new one as the event should be fired again
pwaEvent = null;
});
}
},
},
]} ]}
/> />
</Excalidraw> </Excalidraw>
+3 -3
View File
@@ -20,7 +20,7 @@
name="description" name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<meta name="image" content="https://excalidraw.com/og-image-3.png" /> <meta name="image" content="https://excalidraw.com/og-image-2.png" />
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:site_name" content="Excalidraw" /> <meta property="og:site_name" content="Excalidraw" />
@@ -35,7 +35,7 @@
property="og:description" property="og:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/> />
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" /> <meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
@@ -51,7 +51,7 @@
/> />
<meta <meta
property="twitter:image" property="twitter:image"
content="https://excalidraw.com/og-image-3.png" content="https://excalidraw.com/og-twitter-v2.png"
/> />
<!-- General tags --> <!-- General tags -->
+17
View File
@@ -0,0 +1,17 @@
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { STORAGE_KEYS } from "../app_constants";
export const initLocalStorage = (data: ImportedDataState) => {
if (data.elements) {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(data.elements),
);
}
if (data.appState) {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(data.appState),
);
}
};
+5 -5
View File
@@ -1,7 +1,6 @@
{ {
"private": true, "private": true,
"name": "excalidraw-monorepo", "name": "excalidraw-monorepo",
"packageManager": "yarn@1.22.22",
"workspaces": [ "workspaces": [
"excalidraw-app", "excalidraw-app",
"packages/excalidraw", "packages/excalidraw",
@@ -64,7 +63,8 @@
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker", "build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app", "build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version", "build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build", "build": "yarn workspace @excalidraw/utils build:esm && yarn --cwd ./excalidraw-app build",
"clear:workspaces": "yarn workspace @excalidraw/utils run clear && yarn workspace @excalidraw/excalidraw run clear",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code", "fix": "yarn fix:other && yarn fix:code",
@@ -72,7 +72,7 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js", "locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install", "prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start", "start": "yarn clear:workspaces && yarn workspace @excalidraw/utils run build:esm && yarn --cwd ./excalidraw-app start & node scripts/watchUtils.js",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest", "test:app": "vitest",
@@ -80,8 +80,8 @@
"test:other": "yarn prettier --list-different", "test:other": "yarn prettier --list-different",
"test:typecheck": "tsc", "test:typecheck": "tsc",
"test:update": "yarn test:app --update --watch=false", "test:update": "yarn test:app --update --watch=false",
"test": "yarn test:app", "test": "yarn clear:workspaces && yarn workspace @excalidraw/utils build:esm && yarn test:app",
"test:coverage": "vitest --coverage", "test:coverage": "yarn clear:workspaces && yarn workspace @excalidraw/utils build:esm && yarn test:app --coverage",
"test:coverage:watch": "vitest --coverage --watch", "test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true", "test:ui": "yarn test --ui --coverage.enabled=true",
"autorelease": "node scripts/autorelease.js", "autorelease": "node scripts/autorelease.js",
@@ -1,8 +1,8 @@
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
ROUNDNESS, ROUNDNESS,
TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { isTextElement, newElement } from "../element"; import { isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
@@ -142,7 +142,6 @@ export const actionBindText = register({
containerId: container.id, containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER, textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
}); });
mutateElement(container, { mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
@@ -297,7 +296,6 @@ export const actionWrapTextInContainer = register({
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null, boundElements: null,
textAlign: TEXT_ALIGN.CENTER, textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
}, },
false, false,
); );
+2 -10
View File
@@ -65,10 +65,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(),
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
); );
return ( return (
@@ -79,7 +76,6 @@ export const createUndoAction: ActionCreator = (history, store) => ({
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isUndoStackEmpty} disabled={isUndoStackEmpty}
data-testid="button-undo"
/> />
); );
}, },
@@ -107,10 +103,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(),
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
); );
return ( return (
@@ -121,7 +114,6 @@ export const createRedoAction: ActionCreator = (history, store) => ({
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isRedoStackEmpty} disabled={isRedoStackEmpty}
data-testid="button-redo"
/> />
); );
}, },
@@ -167,7 +167,7 @@ const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement, prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement,
) => { ) => {
if (isBoundToContainer(nextElement) || !nextElement.autoResize) { if (isBoundToContainer(nextElement)) {
return nextElement; return nextElement;
} }
return mutateElement( return mutateElement(
@@ -1,48 +0,0 @@
import { isTextElement } from "../element";
import { newElementWith } from "../element/mutateElement";
import { measureText } from "../element/textElement";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import type { AppClassProperties } from "../types";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
!selectedElements[0].autoResize
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return {
appState,
elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText(
element.originalText,
getFontString(element),
element.lineHeight,
);
return newElementWith(element, {
autoResize: true,
width: metrics.width,
height: metrics.height,
text: element.originalText,
});
}
return element;
}),
storeAction: StoreAction.CAPTURE,
};
},
});
+1 -3
View File
@@ -134,9 +134,7 @@ export type ActionName =
| "setEmbeddableAsActiveTool" | "setEmbeddableAsActiveTool"
| "createContainerFromText" | "createContainerFromText"
| "wrapTextInContainer" | "wrapTextInContainer"
| "commandPalette" | "commandPalette";
| "autoResize"
| "elementStats";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
+8 -17
View File
@@ -1477,28 +1477,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
return elements; return elements;
} }
const unordered = Array.from(elements.values()); const previous = Array.from(elements.values());
const ordered = orderByFractionalIndex([...unordered]); const reordered = orderByFractionalIndex([...previous]);
const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
(acc, arrayIndex) => {
const candidate = unordered[Number(arrayIndex)];
if (candidate && changed.has(candidate.id)) {
acc.set(candidate.id, candidate);
}
return acc; if (
}, !flags.containsVisibleDifference &&
new Map(), Delta.isRightDifferent(previous, reordered, true)
); ) {
if (!flags.containsVisibleDifference && moved.size) {
// we found a difference in order! // we found a difference in order!
flags.containsVisibleDifference = true; flags.containsVisibleDifference = true;
} }
// synchronize all elements that were actually moved // let's synchronize all invalid indices of moved elements
// could fallback to synchronizing all invalid indices return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
} }
/** /**
@@ -468,7 +468,6 @@ export const ExitZenModeAction = ({
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
}) => ( }) => (
<button <button
type="button"
className={clsx("disable-zen-mode", { className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn, "disable-zen-mode--visible": showExitZenModeBtn,
})} })}
+57 -107
View File
@@ -88,7 +88,6 @@ import {
isIOS, isIOS,
supportsResizeObserver, supportsResizeObserver,
DEFAULT_COLLISION_THRESHOLD, DEFAULT_COLLISION_THRESHOLD,
DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
@@ -115,7 +114,7 @@ import {
newTextElement, newTextElement,
newImageElement, newImageElement,
transformElements, transformElements,
refreshTextDimensions, updateTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "../element"; } from "../element";
@@ -225,7 +224,7 @@ import type {
} from "../scene/types"; } from "../scene/types";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes"; import { findShapeByKey } from "../shapes";
import type { GeometricShape } from "../../utils/geometry/shape"; import type { GeometricShape } from "@excalidraw/utils";
import { import {
getClosedCurveShape, getClosedCurveShape,
getCurveShape, getCurveShape,
@@ -233,8 +232,8 @@ import {
getFreedrawShape, getFreedrawShape,
getPolygonShape, getPolygonShape,
getSelectionBoxShape, getSelectionBoxShape,
} from "../../utils/geometry/shape"; isPointInShape,
import { isPointInShape } from "../../utils/collision"; } from "@excalidraw/utils";
import type { import type {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
@@ -332,8 +331,6 @@ import {
getLineHeightInPx, getLineHeightInPx,
isMeasureTextSupported, isMeasureTextSupported,
isValidTextContainer, isValidTextContainer,
measureText,
wrapText,
} from "../element/textElement"; } from "../element/textElement";
import { import {
showHyperlinkTooltip, showHyperlinkTooltip,
@@ -406,7 +403,7 @@ import { Emitter } from "../emitter";
import { ElementCanvasButtons } from "../element/ElementCanvasButtons"; import { ElementCanvasButtons } from "../element/ElementCanvasButtons";
import type { MagicCacheData } from "../data/magic"; import type { MagicCacheData } from "../data/magic";
import { diagramToHTML } from "../data/magic"; import { diagramToHTML } from "../data/magic";
import { exportToBlob } from "../../utils/export"; import { exportToBlob } from "@excalidraw/utils";
import { COLOR_PALETTE } from "../colors"; import { COLOR_PALETTE } from "../colors";
import { ElementCanvasButton } from "./MagicButton"; import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
@@ -432,9 +429,6 @@ import {
isPointHittingLinkIcon, isPointHittingLinkIcon,
} from "./hyperlink/helpers"; } from "./hyperlink/helpers";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { Stats } from "./Stats";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -546,7 +540,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
public id: string; public id: string;
store: Store; private store: Store;
private history: History; private history: History;
private excalidrawContainerValue: { private excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
@@ -720,7 +714,10 @@ class App extends React.Component<AppProps, AppState> {
id: this.id, id: this.id,
}; };
this.fonts = new Fonts({ scene: this.scene }); this.fonts = new Fonts({
scene: this.scene,
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History(); this.history = new History();
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
@@ -943,7 +940,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
if (updated) { if (updated) {
this.scene.triggerUpdate(); this.scene.informMutation();
} }
// GC // GC
@@ -1455,10 +1452,10 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
const { renderTopRightUI, renderCustomStats } = this.props; const { renderTopRightUI, renderCustomStats } = this.props;
const sceneNonce = this.scene.getSceneNonce(); const versionNonce = this.scene.getVersionNonce();
const { elementsMap, visibleElements } = const { elementsMap, visibleElements } =
this.renderer.getRenderableElements({ this.renderer.getRenderableElements({
sceneNonce, versionNonce,
zoom: this.state.zoom, zoom: this.state.zoom,
offsetLeft: this.state.offsetLeft, offsetLeft: this.state.offsetLeft,
offsetTop: this.state.offsetTop, offsetTop: this.state.offsetTop,
@@ -1670,26 +1667,13 @@ class App extends React.Component<AppProps, AppState> {
}} }}
/> />
)} )}
{this.state.showStats && (
<Stats
appState={this.state}
setAppState={this.setState}
scene={this.scene}
onClose={() => {
this.actionManager.executeAction(
actionToggleStats,
);
}}
renderCustomStats={renderCustomStats}
/>
)}
<StaticCanvas <StaticCanvas
canvas={this.canvas} canvas={this.canvas}
rc={this.rc} rc={this.rc}
elementsMap={elementsMap} elementsMap={elementsMap}
allElementsMap={allElementsMap} allElementsMap={allElementsMap}
visibleElements={visibleElements} visibleElements={visibleElements}
sceneNonce={sceneNonce} versionNonce={versionNonce}
selectionNonce={ selectionNonce={
this.state.selectionElement?.versionNonce this.state.selectionElement?.versionNonce
} }
@@ -1711,7 +1695,7 @@ class App extends React.Component<AppProps, AppState> {
elementsMap={elementsMap} elementsMap={elementsMap}
visibleElements={visibleElements} visibleElements={visibleElements}
selectedElements={selectedElements} selectedElements={selectedElements}
sceneNonce={sceneNonce} versionNonce={versionNonce}
selectionNonce={ selectionNonce={
this.state.selectionElement?.versionNonce this.state.selectionElement?.versionNonce
} }
@@ -1835,7 +1819,7 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
this.magicGenerations.set(frameElement.id, data); this.magicGenerations.set(frameElement.id, data);
this.triggerRender(); this.onSceneUpdated();
}; };
private getTextFromElements(elements: readonly ExcalidrawElement[]) { private getTextFromElements(elements: readonly ExcalidrawElement[]) {
@@ -2460,7 +2444,7 @@ class App extends React.Component<AppProps, AppState> {
this.history.record(increment.elementsChange, increment.appStateChange); this.history.record(increment.elementsChange, increment.appStateChange);
}); });
this.scene.onUpdate(this.triggerRender); this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners(); this.addEventListeners();
if (this.props.autoFocus && this.excalidrawContainerRef.current) { if (this.props.autoFocus && this.excalidrawContainerRef.current) {
@@ -2505,7 +2489,6 @@ class App extends React.Component<AppProps, AppState> {
public componentWillUnmount() { public componentWillUnmount() {
this.renderer.destroy(); this.renderer.destroy();
this.scene = new Scene(); this.scene = new Scene();
this.fonts = new Fonts({ scene: this.scene });
this.renderer = new Renderer(this.scene); this.renderer = new Renderer(this.scene);
this.files = {}; this.files = {};
this.imageCache.clear(); this.imageCache.clear();
@@ -2583,7 +2566,7 @@ class App extends React.Component<AppProps, AppState> {
addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }), addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
addEventListener( addEventListener(
document, document,
EVENT.POINTER_MOVE, EVENT.MOUSE_MOVE,
this.updateCurrentCursorPosition, this.updateCurrentCursorPosition,
), ),
// rerender text elements on font load to fix #637 && #1553 // rerender text elements on font load to fix #637 && #1553
@@ -2612,9 +2595,6 @@ class App extends React.Component<AppProps, AppState> {
), ),
addEventListener(window, EVENT.FOCUS, () => { addEventListener(window, EVENT.FOCUS, () => {
this.maybeCleanupAfterMissingPointerUp(null); this.maybeCleanupAfterMissingPointerUp(null);
// browsers (chrome?) tend to free up memory a lot, which results
// in canvas context being cleared. Thus re-render on focus.
this.triggerRender(true);
}), }),
); );
@@ -3359,53 +3339,32 @@ class App extends React.Component<AppProps, AppState> {
text, text,
fontSize: this.state.currentItemFontSize, fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily: this.state.currentItemFontFamily,
textAlign: DEFAULT_TEXT_ALIGN, textAlign: this.state.currentItemTextAlign,
verticalAlign: DEFAULT_VERTICAL_ALIGN, verticalAlign: DEFAULT_VERTICAL_ALIGN,
locked: false, locked: false,
}; };
const fontString = getFontString({
fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
});
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
const [x1, , x2] = getVisibleSceneBounds(this.state);
// long texts should not go beyond 800 pixels in width nor should it go below 200 px
const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
const LINE_GAP = 10; const LINE_GAP = 10;
let currentY = y; let currentY = y;
const lines = isPlainPaste ? [text] : text.split("\n"); const lines = isPlainPaste ? [text] : text.split("\n");
const textElements = lines.reduce( const textElements = lines.reduce(
(acc: ExcalidrawTextElement[], line, idx) => { (acc: ExcalidrawTextElement[], line, idx) => {
const originalText = line.trim(); const text = line.trim();
if (originalText.length) {
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) {
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x, x,
y: currentY, y: currentY,
}); });
let metrics = measureText(originalText, fontString, lineHeight);
const isTextWrapped = metrics.width > maxTextWidth;
const text = isTextWrapped
? wrapText(originalText, fontString, maxTextWidth)
: originalText;
metrics = isTextWrapped
? measureText(text, fontString, lineHeight)
: metrics;
const startX = x - metrics.width / 2;
const startY = currentY - metrics.height / 2;
const element = newTextElement({ const element = newTextElement({
...textElementProps, ...textElementProps,
x: startX, x,
y: startY, y: currentY,
text, text,
originalText,
lineHeight, lineHeight,
autoResize: !isTextWrapped,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
acc.push(element); acc.push(element);
@@ -3711,7 +3670,7 @@ class App extends React.Component<AppProps, AppState> {
ShapeCache.delete(element); ShapeCache.delete(element);
} }
}); });
this.scene.triggerUpdate(); this.scene.informMutation();
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
}, },
@@ -3722,7 +3681,7 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"]; elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null; appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"]; collaborators?: SceneData["collaborators"];
/** @default StoreAction.NONE */ /** @default StoreAction.CAPTURE */
storeAction?: SceneData["storeAction"]; storeAction?: SceneData["storeAction"];
}) => { }) => {
const nextElements = syncInvalidIndices(sceneData.elements ?? []); const nextElements = syncInvalidIndices(sceneData.elements ?? []);
@@ -3771,15 +3730,8 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
private triggerRender = ( private onSceneUpdated = () => {
/** force always re-renders canvas even if no change */ this.setState({});
force?: boolean,
) => {
if (force === true) {
this.scene.triggerUpdate();
} else {
this.setState({});
}
}; };
/** /**
@@ -4348,22 +4300,25 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
const updateElement = (nextOriginalText: string, isDeleted: boolean) => { const updateElement = (
text: string,
originalText: string,
isDeleted: boolean,
) => {
this.scene.replaceAllElements([ this.scene.replaceAllElements([
// Not sure why we include deleted elements as well hence using deleted elements map // Not sure why we include deleted elements as well hence using deleted elements map
...this.scene.getElementsIncludingDeleted().map((_element) => { ...this.scene.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id && isTextElement(_element)) { if (_element.id === element.id && isTextElement(_element)) {
return newElementWith(_element, { return updateTextElement(
originalText: nextOriginalText, _element,
isDeleted: isDeleted ?? _element.isDeleted, getContainerElement(_element, elementsMap),
// returns (wrapped) text and new dimensions elementsMap,
...refreshTextDimensions( {
_element, text,
getContainerElement(_element, elementsMap), isDeleted,
elementsMap, originalText,
nextOriginalText, },
), );
});
} }
return _element; return _element;
}), }),
@@ -4386,15 +4341,15 @@ class App extends React.Component<AppProps, AppState> {
viewportY - this.state.offsetTop, viewportY - this.state.offsetTop,
]; ];
}, },
onChange: withBatchedUpdates((nextOriginalText) => { onChange: withBatchedUpdates((text) => {
updateElement(nextOriginalText, false); updateElement(text, text, false);
if (isNonDeletedElement(element)) { if (isNonDeletedElement(element)) {
updateBoundElements(element, elementsMap); updateBoundElements(element, elementsMap);
} }
}), }),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => {
const isDeleted = !nextOriginalText.trim(); const isDeleted = !text.trim();
updateElement(nextOriginalText, isDeleted); updateElement(text, originalText, isDeleted);
// select the created text element only if submitting via keyboard // select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect) // (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) { if (!isDeleted && viaKeyboard) {
@@ -4439,7 +4394,7 @@ class App extends React.Component<AppProps, AppState> {
// do an initial update to re-initialize element position since we were // do an initial update to re-initialize element position since we were
// modifying element's x/y for sake of editor (case: syncing to remote) // modifying element's x/y for sake of editor (case: syncing to remote)
updateElement(element.originalText, false); updateElement(element.text, element.originalText, false);
} }
private deselectElements() { private deselectElements() {
@@ -5146,11 +5101,8 @@ class App extends React.Component<AppProps, AppState> {
this.translateCanvas({ this.translateCanvas({
zoom: zoomState.zoom, zoom: zoomState.zoom,
// 2x multiplier is just a magic number that makes this work correctly scrollX: zoomState.scrollX + deltaX / nextZoom,
// on touchscreen devices (note: if we get report that panning is slower/faster scrollY: zoomState.scrollY + deltaY / nextZoom,
// than actual movement, consider swapping with devicePixelRatio)
scrollX: zoomState.scrollX + 2 * (deltaX / nextZoom),
scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom),
shouldCacheIgnoreZoom: true, shouldCacheIgnoreZoom: true,
}); });
}); });
@@ -5625,7 +5577,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.elementsPendingErasure = new Set(this.elementsPendingErasure); this.elementsPendingErasure = new Set(this.elementsPendingErasure);
this.triggerRender(); this.onSceneUpdated();
} }
}; };
@@ -8117,7 +8069,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
this.scene.triggerUpdate(); this.scene.informMutation();
} }
} }
} }
@@ -8612,7 +8564,7 @@ class App extends React.Component<AppProps, AppState> {
private restoreReadyToEraseElements = () => { private restoreReadyToEraseElements = () => {
this.elementsPendingErasure = new Set(); this.elementsPendingErasure = new Set();
this.triggerRender(); this.onSceneUpdated();
}; };
private eraseElements = () => { private eraseElements = () => {
@@ -9026,7 +8978,7 @@ class App extends React.Component<AppProps, AppState> {
files, files,
); );
if (updatedFiles.size) { if (updatedFiles.size) {
this.scene.triggerUpdate(); this.scene.informMutation();
} }
} }
}; };
@@ -9681,7 +9633,6 @@ class App extends React.Component<AppProps, AppState> {
} }
return [ return [
CONTEXT_MENU_SEPARATOR,
actionCut, actionCut,
actionCopy, actionCopy,
actionPaste, actionPaste,
@@ -9694,7 +9645,6 @@ class App extends React.Component<AppProps, AppState> {
actionPasteStyles, actionPasteStyles,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionGroup, actionGroup,
actionTextAutoResize,
actionUnbindText, actionUnbindText,
actionBindText, actionBindText,
actionWrapTextInContainer, actionWrapTextInContainer,
@@ -28,7 +28,6 @@ export const ButtonIconSelect = <T extends Object>(
{props.options.map((option) => {props.options.map((option) =>
props.type === "button" ? ( props.type === "button" ? (
<button <button
type="button"
key={option.text} key={option.text}
onClick={(event) => props.onClick(option.value, event)} onClick={(event) => props.onClick(option.value, event)}
className={clsx({ className={clsx({
@@ -22,12 +22,7 @@ export const CheckboxItem: React.FC<{
).focus(); ).focus();
}} }}
> >
<button <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
type="button"
className="Checkbox-box"
role="checkbox"
aria-checked={checked}
>
{checkIcon} {checkIcon}
</button> </button>
<div className="Checkbox-label">{children}</div> <div className="Checkbox-label">{children}</div>
@@ -540,7 +540,7 @@ function CommandPaletteInner({
...command, ...command,
icon: command.icon || boltIcon, icon: command.icon || boltIcon,
order: command.order ?? getCategoryOrder(command.category), order: command.order ?? getCategoryOrder(command.category),
haystack: `${deburr(command.label.toLocaleLowerCase())} ${ haystack: `${deburr(command.label)} ${
command.keywords?.join(" ") || "" command.keywords?.join(" ") || ""
}`, }`,
}; };
@@ -777,9 +777,7 @@ function CommandPaletteInner({
return; return;
} }
const _query = deburr( const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
commandSearch.toLocaleLowerCase().replace(/[<>_| -]/g, ""),
);
matchingCommands = fuzzy matchingCommands = fuzzy
.filter(_query, matchingCommands, { .filter(_query, matchingCommands, {
extract: (command) => command.haystack, extract: (command) => command.haystack,
@@ -105,7 +105,6 @@ export const ContextMenu = React.memo(
}} }}
> >
<button <button
type="button"
className={clsx("context-menu-item", { className={clsx("context-menu-item", {
dangerous: actionName === "deleteSelectedElements", dangerous: actionName === "deleteSelectedElements",
checkmark: item.checked?.(appState), checkmark: item.checked?.(appState),
@@ -123,7 +123,6 @@ export const Dialog = (props: DialogProps) => {
onClick={onClose} onClick={onClose}
title={t("buttons.close")} title={t("buttons.close")}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
type="button"
> >
{CloseIcon} {CloseIcon}
</button> </button>
@@ -27,11 +27,7 @@ const FollowMode = ({
{userToFollow.username} {userToFollow.username}
</span> </span>
</div> </div>
<button <button onClick={onDisconnect} className="follow-mode__disconnect-btn">
type="button"
onClick={onDisconnect}
className="follow-mode__disconnect-btn"
>
{CloseIcon} {CloseIcon}
</button> </button>
</div> </div>
@@ -108,7 +108,6 @@ function Picker<T>({
<div className="picker-content" ref={rGallery}> <div className="picker-content" ref={rGallery}>
{options.map((option, i) => ( {options.map((option, i) => (
<button <button
type="button"
className={clsx("picker-option", { className={clsx("picker-option", {
active: value === option.value, active: value === option.value,
})} })}
@@ -172,7 +171,6 @@ export function IconPicker<T>({
<div> <div>
<button <button
name={group} name={group}
type="button"
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
@@ -23,7 +23,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement } from "../element/types"; import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../../utils/export"; import { exportToCanvas } from "@excalidraw/utils";
import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
+14 -2
View File
@@ -39,6 +39,8 @@ import { JSONExportDialog } from "./JSONExportDialog";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useDevice } from "./App"; import { useDevice } from "./App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer"; import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
@@ -442,7 +444,7 @@ const LayerUI = ({
); );
ShapeCache.delete(element); ShapeCache.delete(element);
} }
Scene.getScene(selectedElements[0])?.triggerUpdate(); Scene.getScene(selectedElements[0])?.informMutation();
} else if (colorPickerType === "elementBackground") { } else if (colorPickerType === "elementBackground") {
setAppState({ setAppState({
currentItemBackgroundColor: color, currentItemBackgroundColor: color,
@@ -540,9 +542,19 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn} showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
{appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
{appState.scrolledOutside && ( {appState.scrolledOutside && (
<button <button
type="button"
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState((appState) => ({ setAppState((appState) => ({
+13 -1
View File
@@ -21,6 +21,8 @@ import { Section } from "./Section";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
@@ -155,6 +157,17 @@ export const MobileMenu = ({
<> <>
{renderSidebars()} {renderSidebars()}
{!appState.viewModeEnabled && renderToolbar()} {!appState.viewModeEnabled && renderToolbar()}
{!appState.openMenu && appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
@@ -181,7 +194,6 @@ export const MobileMenu = ({
!appState.openMenu && !appState.openMenu &&
!appState.openSidebar && ( !appState.openSidebar && (
<button <button
type="button"
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState((appState) => ({ setAppState((appState) => ({
@@ -65,7 +65,6 @@ const ChartPreviewBtn = (props: {
return ( return (
<button <button
type="button"
className="ChartPreview" className="ChartPreview"
onClick={() => { onClick={() => {
if (chartElements) { if (chartElements) {
@@ -7,7 +7,7 @@ import { t } from "../i18n";
import Trans from "./Trans"; import Trans from "./Trans";
import type { LibraryItems, LibraryItem, UIAppState } from "../types"; import type { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../../utils/export"; import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
import { import {
EDITOR_LS_KEYS, EDITOR_LS_KEYS,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
@@ -1,8 +1,7 @@
@import "../../css/variables.module.scss"; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Stats { .Stats {
width: 204px;
position: absolute; position: absolute;
top: 64px; top: 64px;
right: 12px; right: 12px;
@@ -10,38 +9,6 @@
z-index: 10; z-index: 10;
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-bottom: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.statsItem {
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 { h3 {
margin: 0 24px 8px 0; margin: 0 24px 8px 0;
white-space: nowrap; white-space: nowrap;
@@ -72,12 +39,6 @@
} }
} }
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & { :root[dir="rtl"] & {
left: 12px; left: 12px;
right: initial; right: initial;
+108
View File
@@ -0,0 +1,108 @@
import React from "react";
import { getCommonBounds } from "../element/bounds";
import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getTargetElements } from "../scene";
import type { ExcalidrawProps, UIAppState } from "../types";
import { CloseIcon } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
export const Stats = (props: {
appState: UIAppState;
setAppState: React.Component<any, UIAppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => {
const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements);
return (
<div className="Stats">
<Island padding={2}>
<div className="close" onClick={props.onClose}>
{CloseIcon}
</div>
<h3>{t("stats.title")}</h3>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{props.elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
</tr>
{selectedElements.length === 1 && (
<tr>
<th colSpan={2}>{t("stats.element")}</th>
</tr>
)}
{selectedElements.length > 1 && (
<>
<tr>
<th colSpan={2}>{t("stats.selected")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{selectedElements.length}</td>
</tr>
</>
)}
{selectedElements.length > 0 && (
<>
<tr>
<td>{"x"}</td>
<td>{Math.round(selectedBoundingBox[0])}</td>
</tr>
<tr>
<td>{"y"}</td>
<td>{Math.round(selectedBoundingBox[1])}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>
{Math.round(
selectedBoundingBox[2] - selectedBoundingBox[0],
)}
</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>
{Math.round(
selectedBoundingBox[3] - selectedBoundingBox[1],
)}
</td>
</tr>
</>
)}
{selectedElements.length === 1 && (
<tr>
<td>{t("stats.angle")}</td>
<td>
{`${Math.round(
(selectedElements[0].angle * 180) / Math.PI,
)}°`}
</td>
</tr>
)}
{props.renderCustomStats?.(props.elements, props.appState)}
</tbody>
</table>
</Island>
</div>
);
};
@@ -1,75 +0,0 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
interface AngleProps {
element: ExcalidrawElement;
elementsMap: ElementsMap;
}
const STEP_SIZE = 15;
const Angle = ({ element, elementsMap }: AngleProps) => {
const handleDegreeChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
shouldChangeByStepSize,
nextValue,
}) => {
const _stateAtStart = stateAtStart[0];
if (_stateAtStart) {
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
mutateElement(element, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
return;
}
const originalAngleInDegrees =
Math.round(radianToDegree(_stateAtStart.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(element, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
}
};
return (
<DragInput
label="A"
value={Math.round(radianToDegree(element.angle) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
/>
);
};
export default Angle;
@@ -1,247 +0,0 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { getFontString } from "../../utils";
import { updateBoundElements } from "../../element/binding";
interface DimensionDragInputProps {
property: "width" | "height";
element: ExcalidrawElement;
elementsMap: ElementsMap;
}
const STEP_SIZE = 10;
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image";
};
export const newOrigin = (
x1: number,
y1: number,
w1: number,
h1: number,
w2: number,
h2: number,
angle: number,
) => {
/**
* The formula below is the result of solving
* rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
* where rotate is the function defined in math.ts
*
* This is so that the new origin (x2, y2),
* when rotated against the new center (cx2, cy2),
* coincides with (x1, y1) rotated against (cx1, cy1)
*
* The reason for doing this computation is so the element's top left corner
* on the canvas remains fixed after any changes in its dimension.
*/
return {
x:
x1 +
(w1 - w2) / 2 +
((w2 - w1) / 2) * Math.cos(angle) +
((h1 - h2) / 2) * Math.sin(angle),
y:
y1 +
(h1 - h2) / 2 +
((w2 - w1) / 2) * Math.sin(angle) +
((h2 - h1) / 2) * Math.cos(angle),
};
};
const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
latestState: ExcalidrawElement,
stateAtStart: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: Map<string, ExcalidrawElement>,
) => {
mutateElement(latestState, {
...newOrigin(
latestState.x,
latestState.y,
latestState.width,
latestState.height,
nextWidth,
nextHeight,
latestState.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, true),
});
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestState, elementsMap);
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestState,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
} else {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
}
updateBoundElements(latestState, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestState, elementsMap, "e", keepAspectRatio);
};
const DimensionDragInput = ({
property,
element,
elementsMap,
}: DimensionDragInputProps) => {
const handleDimensionChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}) => {
const _stateAtStart = stateAtStart[0];
if (_stateAtStart) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
const aspectRatio = _stateAtStart.width / _stateAtStart.height;
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
? nextValue
: keepAspectRatio
? nextValue * aspectRatio
: _stateAtStart.width,
0,
);
const nextHeight = Math.max(
property === "height"
? nextValue
: keepAspectRatio
? nextValue / aspectRatio
: _stateAtStart.height,
0,
);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
_stateAtStart,
elementsMap,
originalElementsMap,
);
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, _stateAtStart.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, _stateAtStart.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
_stateAtStart,
elementsMap,
originalElementsMap,
);
}
};
return (
<DragInput
label={property === "width" ? "W" : "H"}
elements={[element]}
dragInputCallback={handleDimensionChange}
value={
Math.round(
(property === "width" ? element.width : element.height) * 100,
) / 100
}
editable={isPropertyEditable(element, property)}
/>
);
};
export default DimensionDragInput;
@@ -1,75 +0,0 @@
.excalidraw {
.drag-input-container {
display: flex;
width: 100%;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.drag-input-label {
height: var(--default-button-size);
flex-shrink: 0;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid var(--default-border-color);
border-right: 0;
box-sizing: border-box;
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
color: var(--input-label-color);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.drag-input {
box-sizing: border-box;
width: 100%;
margin: 0;
font-size: 0.875rem;
font-family: inherit;
background-color: transparent;
color: var(--text-primary-color);
border: 0;
outline: none;
height: var(--default-button-size);
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;
&:focus-visible {
box-shadow: none;
}
}
}
@@ -1,208 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import throttle from "lodash.throttle";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { deepCopyElement } from "../../element/newElement";
import "./DragInput.scss";
import clsx from "clsx";
import { useApp } from "../App";
export type DragInputCallbackType = ({
accumulatedChange,
instantChange,
stateAtStart,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}: {
accumulatedChange: number;
instantChange: number;
stateAtStart: ExcalidrawElement[];
originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean;
nextValue?: number;
}) => void;
interface StatsDragInputProps {
label: string | React.ReactNode;
value: number;
elements: ExcalidrawElement[];
editable?: boolean;
shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType;
}
const StatsDragInput = ({
label,
dragInputCallback,
value,
elements,
editable = true,
shouldKeepAspectRatio,
}: StatsDragInputProps) => {
const app = useApp();
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const cbThrottled = useMemo(() => {
return throttle(dragInputCallback, 16);
}, [dragInputCallback]);
const [inputValue, setInputValue] = useState(value.toString());
useEffect(() => {
setInputValue(value.toString());
}, [value]);
return (
<div className={clsx("drag-input-container", !editable && "disabled")}>
<div
className="drag-input-label"
ref={labelRef}
onPointerDown={(event) => {
if (inputRef.current && editable) {
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
}
let lastPointer: {
x: number;
y: number;
} | null = null;
let stateAtStart: ExcalidrawElement[] | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null =
null;
let accumulatedChange: number | null = null;
document.body.classList.add("dragResize");
const onPointerMove = (event: PointerEvent) => {
if (!stateAtStart) {
stateAtStart = elements.map((element) =>
deepCopyElement(element),
);
}
if (!originalElementsMap) {
originalElementsMap = app.scene
.getNonDeletedElements()
.reduce((acc, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map() as ElementsMap);
}
if (!accumulatedChange) {
accumulatedChange = 0;
}
if (lastPointer && stateAtStart && accumulatedChange !== null) {
const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
cbThrottled({
accumulatedChange,
instantChange,
stateAtStart,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
});
}
lastPointer = {
x: event.clientX,
y: event.clientY,
};
};
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.store.shouldCaptureIncrement();
lastPointer = null;
accumulatedChange = null;
stateAtStart = null;
originalElementsMap = null;
document.body.classList.remove("dragResize");
},
false,
);
}
}}
onPointerEnter={() => {
if (labelRef.current) {
labelRef.current.style.cursor = "ew-resize";
}
}}
>
{label}
</div>
<input
className="drag-input"
autoComplete="off"
spellCheck="false"
onKeyDown={(event) => {
if (editable) {
const eventTarget = event.target;
if (
eventTarget instanceof HTMLInputElement &&
event.key === KEYS.ENTER
) {
const v = Number(eventTarget.value);
if (isNaN(v)) {
setInputValue(value.toString());
return;
}
dragInputCallback({
accumulatedChange: 0,
instantChange: 0,
stateAtStart: elements,
originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false,
nextValue: v,
});
app.store.shouldCaptureIncrement();
eventTarget.blur();
}
}
}}
ref={inputRef}
value={inputValue}
onChange={(event) => {
const eventTarget = event.target;
if (eventTarget instanceof HTMLInputElement) {
setInputValue(event.target.value);
}
}}
onBlur={() => {
if (!inputValue) {
setInputValue(value.toString());
}
}}
disabled={!editable}
></input>
</div>
);
};
export default StatsDragInput;
@@ -1,73 +0,0 @@
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
import { refreshTextDimensions } from "../../element/newElement";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { mutateElement } from "../../element/mutateElement";
import { getStepSizedValue } from "./utils";
interface FontSizeProps {
element: ExcalidrawTextElement;
elementsMap: ElementsMap;
}
const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4;
const FontSize = ({ element, elementsMap }: FontSizeProps) => {
const handleFontSizeChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
shouldChangeByStepSize,
nextValue,
}) => {
const _stateAtStart = stateAtStart[0];
if (_stateAtStart) {
if (nextValue) {
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
const newElement = {
...element,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
return;
}
if (_stateAtStart.type === "text") {
const originalFontSize = Math.round(_stateAtStart.fontSize);
const changeInFontSize = Math.round(accumulatedChange);
let nextFontSize = Math.max(
originalFontSize + changeInFontSize,
MIN_FONT_SIZE,
);
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
const newElement = {
...element,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
}
}
};
return (
<StatsDragInput
label="F"
value={Math.round(element.fontSize * 10) / 10}
elements={[element]}
dragInputCallback={handleFontSizeChange}
/>
);
};
export default FontSize;
@@ -1,210 +0,0 @@
import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import { rescalePointsInElement } from "../../element/resizeElements";
import {
getBoundTextElement,
handleBindTextResize,
} from "../../element/textElement";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue } from "./utils";
interface MultiDimensionProps {
property: "width" | "height";
elements: ExcalidrawElement[];
elementsMap: ElementsMap;
}
const STEP_SIZE = 10;
const getResizedUpdates = (
anchorX: number,
anchorY: number,
scale: number,
stateAtStart: ExcalidrawElement,
) => {
const offsetX = stateAtStart.x - anchorX;
const offsetY = stateAtStart.y - anchorY;
const nextWidth = stateAtStart.width * scale;
const nextHeight = stateAtStart.height * scale;
const x = anchorX + offsetX * scale;
const y = anchorY + offsetY * scale;
return {
width: nextWidth,
height: nextHeight,
x,
y,
...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, false),
...(isTextElement(stateAtStart)
? { fontSize: stateAtStart.fontSize * scale }
: {}),
};
};
const resizeElement = (
anchorX: number,
anchorY: number,
property: MultiDimensionProps["property"],
scale: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
shouldInformMutation: boolean,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
mutateElement(latestElement, updates, shouldInformMutation);
const boundTextElement = getBoundTextElement(
origElement,
originalElementsMap,
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
mutateElement(
latestBoundTextElement,
{
fontSize: newFontSize,
},
shouldInformMutation,
);
handleBindTextResize(
latestElement,
elementsMap,
property === "width" ? "e" : "s",
true,
);
}
}
};
const MultiDimension = ({
property,
elements,
elementsMap,
}: MultiDimensionProps) => {
const handleDimensionChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
}) => {
const [x1, y1, x2, y2] = getCommonBounds(stateAtStart);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const keepAspectRatio = true;
const aspectRatio = initialWidth / initialHeight;
if (nextValue !== undefined) {
const nextHeight =
property === "height" ? nextValue : nextValue / aspectRatio;
const scale = nextHeight / initialHeight;
const anchorX = property === "width" ? x1 : x1 + width / 2;
const anchorY = property === "height" ? y1 : y1 + height / 2;
let i = 0;
while (i < stateAtStart.length) {
const latestElement = elements[i];
const origElement = stateAtStart[i];
// it should never happen that element and origElement are different
// but check just in case
if (latestElement.id === origElement.id) {
resizeElement(
anchorX,
anchorY,
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
i === stateAtStart.length - 1,
);
}
i++;
}
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, initialWidth + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, initialHeight + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
const scale = nextHeight / initialHeight;
const anchorX = property === "width" ? x1 : x1 + width / 2;
const anchorY = property === "height" ? y1 : y1 + height / 2;
let i = 0;
while (i < stateAtStart.length) {
const latestElement = elements[i];
const origElement = stateAtStart[i];
if (latestElement.id === origElement.id) {
resizeElement(
anchorX,
anchorY,
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
i === stateAtStart.length - 1,
);
}
i++;
}
};
const [x1, y1, x2, y2] = getCommonBounds(elements);
const width = x2 - x1;
const height = y2 - y1;
return (
<DragInput
label={property === "width" ? "W" : "H"}
elements={elements}
dragInputCallback={handleDimensionChange}
value={Math.round((property === "width" ? width : height) * 100) / 100}
/>
);
};
export default MultiDimension;
@@ -1,175 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import { getSelectedElements } from "../../scene";
import type Scene from "../../scene/Scene";
import type { AppState, ExcalidrawProps } from "../../types";
import { CloseIcon } from "../icons";
import { Island } from "../Island";
import { throttle } from "lodash";
import Dimension from "./Dimension";
import Angle from "./Angle";
import "./index.scss";
import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups";
interface StatsProps {
appState: AppState;
scene: Scene;
setAppState: React.Component<any, AppState>["setState"];
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}
const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => {
const elements = props.scene.getNonDeletedElements();
const elementsMap = props.scene.getNonDeletedElementsMap();
const sceneNonce = props.scene.getSceneNonce();
// const selectedElements = getTargetElements(elements, props.appState);
const selectedElements = getSelectedElements(
props.scene.getNonDeletedElementsMap(),
props.appState,
{
includeBoundTextElement: false,
},
);
const singleElement =
selectedElements.length === 1 ? selectedElements[0] : null;
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
}>({
width: 0,
height: 0,
});
const throttledSetSceneDimension = useMemo(
() =>
throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
const boundingBox = getCommonBounds(elements);
setSceneDimension({
width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
});
}, STATS_TIMEOUT),
[],
);
useEffect(() => {
throttledSetSceneDimension(elements);
}, [sceneNonce, elements, throttledSetSceneDimension]);
useEffect(
() => () => throttledSetSceneDimension.cancel(),
[throttledSetSceneDimension],
);
return (
<div className="Stats">
<Island padding={3}>
<div className="section">
<div className="close" onClick={props.onClose}>
{CloseIcon}
</div>
<h3>{t("stats.generalStats")}</h3>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{sceneDimension.height}</td>
</tr>
{props.renderCustomStats?.(elements, props.appState)}
</tbody>
</table>
</div>
{selectedElements.length > 0 && (
<div
className="section"
style={{
marginTop: 12,
}}
>
<h3>{t("stats.elementStats")}</h3>
{singleElement && (
<div className="sectionContent">
<div className="elementType">
{t(`element.${singleElement.type}`)}
</div>
<div className="statsItem">
<Dimension
property="width"
element={singleElement}
elementsMap={elementsMap}
/>
<Dimension
property="height"
element={singleElement}
elementsMap={elementsMap}
/>
<Angle element={singleElement} elementsMap={elementsMap} />
{singleElement.type === "text" && (
<FontSize
element={singleElement}
elementsMap={elementsMap}
/>
)}
</div>
{singleElement.type === "text" && <div></div>}
</div>
)}
{multipleElements && (
<div className="sectionContent">
{elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div>
)}
<div className="elementsCount">
<div>{t("stats.elements")}</div>
<div>{selectedElements.length}</div>
</div>
<div className="statsItem">
<MultiDimension
property="width"
elements={multipleElements}
elementsMap={elementsMap}
/>
<MultiDimension
property="height"
elements={multipleElements}
elementsMap={elementsMap}
/>
</div>
</div>
)}
</div>
)}
</Island>
</div>
);
};
@@ -1,23 +0,0 @@
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
export const isPropertyEditable = (
element: ExcalidrawElement,
property: keyof ExcalidrawElement,
) => {
if (property === "height" && isTextElement(element)) {
return false;
}
if (property === "width" && isTextElement(element)) {
return false;
}
if (property === "angle" && isFrameLikeElement(element)) {
return false;
}
return true;
};
export const getStepSizedValue = (value: number, stepSize: number) => {
const v = value + stepSize / 2;
return v - (v % stepSize);
};
@@ -19,7 +19,7 @@ type InteractiveCanvasProps = {
elementsMap: RenderableElementsMap; elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[];
sceneNonce: number | undefined; versionNonce: number | undefined;
selectionNonce: number | undefined; selectionNonce: number | undefined;
scale: number; scale: number;
appState: InteractiveCanvasAppState; appState: InteractiveCanvasAppState;
@@ -206,10 +206,10 @@ const areEqual = (
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
if ( if (
prevProps.selectionNonce !== nextProps.selectionNonce || prevProps.selectionNonce !== nextProps.selectionNonce ||
prevProps.sceneNonce !== nextProps.sceneNonce || prevProps.versionNonce !== nextProps.versionNonce ||
prevProps.scale !== nextProps.scale || prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed // we need to memoize on elementsMap because they may have renewed
// even if sceneNonce didn't change (e.g. we filter elements out based // even if versionNonce didn't change (e.g. we filter elements out based
// on appState) // on appState)
prevProps.elementsMap !== nextProps.elementsMap || prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements || prevProps.visibleElements !== nextProps.visibleElements ||
@@ -19,7 +19,7 @@ type StaticCanvasProps = {
elementsMap: RenderableElementsMap; elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap; allElementsMap: NonDeletedSceneElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
sceneNonce: number | undefined; versionNonce: number | undefined;
selectionNonce: number | undefined; selectionNonce: number | undefined;
scale: number; scale: number;
appState: StaticCanvasAppState; appState: StaticCanvasAppState;
@@ -112,10 +112,10 @@ const areEqual = (
nextProps: StaticCanvasProps, nextProps: StaticCanvasProps,
) => { ) => {
if ( if (
prevProps.sceneNonce !== nextProps.sceneNonce || prevProps.versionNonce !== nextProps.versionNonce ||
prevProps.scale !== nextProps.scale || prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed // we need to memoize on elementsMap because they may have renewed
// even if sceneNonce didn't change (e.g. we filter elements out based // even if versionNonce didn't change (e.g. we filter elements out based
// on appState) // on appState)
prevProps.elementsMap !== nextProps.elementsMap || prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements prevProps.visibleElements !== nextProps.visibleElements
+2 -6
View File
@@ -698,18 +698,14 @@ export const BringForwardIcon = createIcon(arrownNarrowUpJSX, tablerIconProps);
export const SendBackwardIcon = createIcon(arrownNarrowUpJSX, { export const SendBackwardIcon = createIcon(arrownNarrowUpJSX, {
...tablerIconProps, ...tablerIconProps,
style: { transform: "rotate(180)",
transform: "rotate(180deg)",
},
}); });
export const BringToFrontIcon = createIcon(arrowBarToTopJSX, tablerIconProps); export const BringToFrontIcon = createIcon(arrowBarToTopJSX, tablerIconProps);
export const SendToBackIcon = createIcon(arrowBarToTopJSX, { export const SendToBackIcon = createIcon(arrowBarToTopJSX, {
...tablerIconProps, ...tablerIconProps,
style: { transform: "rotate(180)",
transform: "rotate(180deg)",
},
}); });
// //
-6
View File
@@ -22,12 +22,6 @@
--sat: env(safe-area-inset-top); --sat: env(safe-area-inset-top);
} }
body.dragResize,
body.dragResize a:hover,
body.dragResize * {
cursor: ew-resize;
}
.excalidraw { .excalidraw {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif; Roboto, Helvetica, Arial, sans-serif;
@@ -228,7 +228,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
@@ -274,7 +273,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
@@ -380,7 +378,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id48", "containerId": "id48",
@@ -481,7 +478,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id37", "containerId": "id37",
@@ -656,7 +652,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id41", "containerId": "id41",
@@ -697,7 +692,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
@@ -743,7 +737,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
@@ -1201,7 +1194,6 @@ exports[`Test Transform > should transform regular shapes 6`] = `
exports[`Test Transform > should transform text element 1`] = ` exports[`Test Transform > should transform text element 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@@ -1242,7 +1234,6 @@ exports[`Test Transform > should transform text element 1`] = `
exports[`Test Transform > should transform text element 2`] = ` exports[`Test Transform > should transform text element 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@@ -1575,7 +1566,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "B", "containerId": "B",
@@ -1618,7 +1608,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "A", "containerId": "A",
@@ -1661,7 +1650,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Alice", "containerId": "Alice",
@@ -1704,7 +1692,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Bob", "containerId": "Bob",
@@ -1747,7 +1734,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Bob_Alice", "containerId": "Bob_Alice",
@@ -1788,7 +1774,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Bob_B", "containerId": "Bob_B",
@@ -2037,7 +2022,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id25", "containerId": "id25",
@@ -2078,7 +2062,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id26", "containerId": "id26",
@@ -2119,7 +2102,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id27", "containerId": "id27",
@@ -2161,7 +2143,6 @@ LABELLED ARROW",
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id28", "containerId": "id28",
@@ -2425,7 +2406,6 @@ exports[`Test Transform > should transform to text containers when label provide
exports[`Test Transform > should transform to text containers when label provided 7`] = ` exports[`Test Transform > should transform to text containers when label provided 7`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id13", "containerId": "id13",
@@ -2466,7 +2446,6 @@ exports[`Test Transform > should transform to text containers when label provide
exports[`Test Transform > should transform to text containers when label provided 8`] = ` exports[`Test Transform > should transform to text containers when label provided 8`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id14", "containerId": "id14",
@@ -2508,7 +2487,6 @@ CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 9`] = ` exports[`Test Transform > should transform to text containers when label provided 9`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id15", "containerId": "id15",
@@ -2552,7 +2530,6 @@ CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 10`] = ` exports[`Test Transform > should transform to text containers when label provided 10`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id16", "containerId": "id16",
@@ -2594,7 +2571,6 @@ TEXT CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 11`] = ` exports[`Test Transform > should transform to text containers when label provided 11`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id17", "containerId": "id17",
@@ -2637,7 +2613,6 @@ CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 12`] = ` exports[`Test Transform > should transform to text containers when label provided 12`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id18", "containerId": "id18",
+1 -1
View File
@@ -208,7 +208,7 @@ const restoreElement = (
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || text, originalText: element.originalText || text,
autoResize: element.autoResize ?? true,
lineHeight, lineHeight,
}); });
+1 -1
View File
@@ -26,7 +26,7 @@ import type {
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import type { AppClassProperties, AppState, Point } from "../types"; import type { AppClassProperties, AppState, Point } from "../types";
import { isPointOnShape } from "../../utils/collision"; import { isPointOnShape } from "@excalidraw/utils";
import { getElementAtPosition } from "../scene"; import { getElementAtPosition } from "../scene";
import { import {
isArrowElement, isArrowElement,
+6 -3
View File
@@ -8,9 +8,12 @@ import type {
import { getElementBounds } from "./bounds"; import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types"; import type { FrameNameBounds } from "../types";
import type { Polygon, GeometricShape } from "../../utils/geometry/shape"; import type { Polygon, GeometricShape } from "@excalidraw/utils";
import { getPolygonShape } from "../../utils/geometry/shape"; import {
import { isPointInShape, isPointOnShape } from "../../utils/collision"; getPolygonShape,
isPointInShape,
isPointOnShape,
} from "@excalidraw/utils";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
import { import {
hasBoundTextElement, hasBoundTextElement,
+1
View File
@@ -9,6 +9,7 @@ import { isLinearElementType } from "./typeChecks";
export { export {
newElement, newElement,
newTextElement, newTextElement,
updateTextElement,
refreshTextDimensions, refreshTextDimensions,
newLinearElement, newLinearElement,
newImageElement, newImageElement,
+2 -4
View File
@@ -98,7 +98,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
if (informMutation) { if (informMutation) {
Scene.getScene(element)?.triggerUpdate(); Scene.getScene(element)?.informMutation();
} }
return element; return element;
@@ -107,8 +107,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>( export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
/** pass `true` to always regenerate */
force = false,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
for (const key in updates) { for (const key in updates) {
@@ -125,7 +123,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
} }
} }
if (!didChange && !force) { if (!didChange) {
return element; return element;
} }
+41 -35
View File
@@ -215,7 +215,6 @@ const getTextElementPositionOffsets = (
export const newTextElement = ( export const newTextElement = (
opts: { opts: {
text: string; text: string;
originalText?: string;
fontSize?: number; fontSize?: number;
fontFamily?: FontFamilyValues; fontFamily?: FontFamilyValues;
textAlign?: TextAlign; textAlign?: TextAlign;
@@ -223,7 +222,6 @@ export const newTextElement = (
containerId?: ExcalidrawTextContainer["id"] | null; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"]; strokeWidth?: ExcalidrawTextElement["strokeWidth"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@@ -242,28 +240,24 @@ export const newTextElement = (
metrics, metrics,
); );
const textElementProps: ExcalidrawTextElement = { const textElement = newElementWith(
..._newElementBase<ExcalidrawTextElement>("text", opts), {
text, ..._newElementBase<ExcalidrawTextElement>("text", opts),
fontSize, text,
fontFamily, fontSize,
textAlign, fontFamily,
verticalAlign, textAlign,
x: opts.x - offsets.x, verticalAlign,
y: opts.y - offsets.y, x: opts.x - offsets.x,
width: metrics.width, y: opts.y - offsets.y,
height: metrics.height, width: metrics.width,
containerId: opts.containerId || null, height: metrics.height,
originalText: opts.originalText ?? text, containerId: opts.containerId || null,
autoResize: opts.autoResize ?? true, originalText: text,
lineHeight, lineHeight,
}; },
const textElement: ExcalidrawTextElement = newElementWith(
textElementProps,
{}, {},
); );
return textElement; return textElement;
}; };
@@ -277,25 +271,18 @@ const getAdjustedDimensions = (
width: number; width: number;
height: number; height: number;
} => { } => {
let { width: nextWidth, height: nextHeight } = measureText( const { width: nextWidth, height: nextHeight } = measureText(
nextText, nextText,
getFontString(element), getFontString(element),
element.lineHeight, element.lineHeight,
); );
// wrapped text
if (!element.autoResize) {
nextWidth = element.width;
}
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
let y: number; let y: number;
if ( if (
textAlign === "center" && textAlign === "center" &&
verticalAlign === VERTICAL_ALIGN.MIDDLE && verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId && !element.containerId
element.autoResize
) { ) {
const prevMetrics = measureText( const prevMetrics = measureText(
element.text, element.text,
@@ -356,19 +343,38 @@ export const refreshTextDimensions = (
if (textElement.isDeleted) { if (textElement.isDeleted) {
return; return;
} }
if (container || !textElement.autoResize) { if (container) {
text = wrapText( text = wrapText(
text, text,
getFontString(textElement), getFontString(textElement),
container getBoundTextMaxWidth(container, textElement),
? getBoundTextMaxWidth(container, textElement)
: textElement.width,
); );
} }
const dimensions = getAdjustedDimensions(textElement, elementsMap, text); const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
return { text, ...dimensions }; return { text, ...dimensions };
}; };
export const updateTextElement = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
elementsMap: ElementsMap,
{
text,
isDeleted,
originalText,
}: {
text: string;
isDeleted?: boolean;
originalText: string;
},
): ExcalidrawTextElement => {
return newElementWith(textElement, {
originalText,
isDeleted: isDeleted ?? textElement.isDeleted,
...refreshTextDimensions(textElement, container, elementsMap, originalText),
});
};
export const newFreeDrawElement = ( export const newFreeDrawElement = (
opts: { opts: {
type: "freedraw"; type: "freedraw";
+24 -125
View File
@@ -1,8 +1,4 @@
import { import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
BOUND_TEXT_PADDING,
MIN_FONT_SIZE,
SHIFT_LOCKING_ANGLE,
} from "../constants";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math"; import { rotate, centerPoint, rotatePoint } from "../math";
@@ -49,9 +45,6 @@ import {
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
wrapText,
measureText,
getMinCharWidth,
} from "./textElement"; } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups"; import { isInGroup } from "../groups";
@@ -91,9 +84,14 @@ export const transformElements = (
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
); );
updateBoundElements(element, elementsMap); updateBoundElements(element, elementsMap);
} else if (isTextElement(element) && transformHandleType) { } else if (
isTextElement(element) &&
(transformHandleType === "nw" ||
transformHandleType === "ne" ||
transformHandleType === "sw" ||
transformHandleType === "se")
) {
resizeSingleTextElement( resizeSingleTextElement(
originalElements,
element, element,
elementsMap, elementsMap,
transformHandleType, transformHandleType,
@@ -182,7 +180,7 @@ const rotateSingleElement = (
} }
}; };
export const rescalePointsInElement = ( const rescalePointsInElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
width: number, width: number,
height: number, height: number,
@@ -199,7 +197,7 @@ export const rescalePointsInElement = (
} }
: {}; : {};
export const measureFontSizeFromWidth = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nextWidth: number, nextWidth: number,
@@ -225,10 +223,9 @@ export const measureFontSizeFromWidth = (
}; };
const resizeSingleTextElement = ( const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"],
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
transformHandleType: TransformHandleDirection, transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
@@ -248,19 +245,17 @@ const resizeSingleTextElement = (
let scaleX = 0; let scaleX = 0;
let scaleY = 0; let scaleY = 0;
if (transformHandleType !== "e" && transformHandleType !== "w") { if (transformHandleType.includes("e")) {
if (transformHandleType.includes("e")) { scaleX = (rotatedX - x1) / (x2 - x1);
scaleX = (rotatedX - x1) / (x2 - x1); }
} if (transformHandleType.includes("w")) {
if (transformHandleType.includes("w")) { scaleX = (x2 - rotatedX) / (x2 - x1);
scaleX = (x2 - rotatedX) / (x2 - x1); }
} if (transformHandleType.includes("n")) {
if (transformHandleType.includes("n")) { scaleY = (y2 - rotatedY) / (y2 - y1);
scaleY = (y2 - rotatedY) / (y2 - y1); }
} if (transformHandleType.includes("s")) {
if (transformHandleType.includes("s")) { scaleY = (rotatedY - y1) / (y2 - y1);
scaleY = (rotatedY - y1) / (y2 - y1);
}
} }
const scale = Math.max(scaleX, scaleY); const scale = Math.max(scaleX, scaleY);
@@ -323,102 +318,6 @@ const resizeSingleTextElement = (
y: nextY, y: nextY,
}); });
} }
if (transformHandleType === "e" || transformHandleType === "w") {
const stateAtResizeStart = originalElements.get(element.id)!;
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtResizeStart,
stateAtResizeStart.width,
stateAtResizeStart.height,
true,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
startCenter,
-stateAtResizeStart.angle,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
element,
element.width,
element.height,
true,
);
const boundsCurrentWidth = esx2 - esx1;
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const minWidth =
getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2;
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
if (transformHandleType.includes("e")) {
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (transformHandleType.includes("w")) {
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
const newWidth =
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
const text = wrapText(
element.originalText,
getFontString(element),
Math.abs(newWidth),
);
const metrics = measureText(
text,
getFontString(element),
element.lineHeight,
);
const eleNewHeight = metrics.height;
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(
stateAtResizeStart,
newWidth,
eleNewHeight,
true,
);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startTopLeft[1],
];
}
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
height: Math.abs(metrics.height),
x: newTopLeft[0],
y: newTopLeft[1],
text,
autoResize: false,
};
mutateElement(element, resizedElement);
}
}; };
export const resizeSingleElement = ( export const resizeSingleElement = (
@@ -977,7 +876,7 @@ export const resizeMultipleElements = (
} }
} }
Scene.getScene(elementsAndUpdates[0].element)?.triggerUpdate(); Scene.getScene(elementsAndUpdates[0].element)?.informMutation();
}; };
const rotateMultipleElements = ( const rotateMultipleElements = (
@@ -1039,7 +938,7 @@ const rotateMultipleElements = (
} }
}); });
Scene.getScene(elements[0])?.triggerUpdate(); Scene.getScene(elements[0])?.informMutation();
}; };
export const getResizeOffsetXY = ( export const getResizeOffsetXY = (
+8 -8
View File
@@ -20,12 +20,8 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants"; import { SIDE_RESIZING_THRESHOLD } from "../constants";
import { import { angleToDegrees, pointOnLine, pointRotate } from "@excalidraw/utils";
angleToDegrees, import type { Line, Point } from "@excalidraw/utils";
pointOnLine,
pointRotate,
} from "../../utils/geometry/geometry";
import type { Line, Point } from "../../utils/geometry/shape";
import { isLinearElement } from "./typeChecks"; import { isLinearElement } from "./typeChecks";
const isInsideTransformHandle = ( const isInsideTransformHandle = (
@@ -87,8 +83,12 @@ export const resizeTest = (
elementsMap, elementsMap,
); );
// do not resize from the sides for linear elements with only two points // Note that for a text element, when "resized" from the side
if (!(isLinearElement(element) && element.points.length <= 2)) { // we should make it wrap/unwrap
if (
element.type !== "text" &&
!(isLinearElement(element) && element.points.length <= 2)
) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING], [x1 - SPACING, y1 - SPACING],
+4 -10
View File
@@ -48,7 +48,7 @@ export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
elementsMap: ElementsMap, elementsMap: ElementsMap,
informMutation = true, informMutation: boolean = true,
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
const boundTextUpdates = { const boundTextUpdates = {
@@ -62,27 +62,21 @@ export const redrawTextBoundingBox = (
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
if (container || !textElement.autoResize) { if (container) {
maxWidth = container maxWidth = getBoundTextMaxWidth(container, textElement);
? getBoundTextMaxWidth(container, textElement)
: textElement.width;
boundTextUpdates.text = wrapText( boundTextUpdates.text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
maxWidth, maxWidth,
); );
} }
const metrics = measureText( const metrics = measureText(
boundTextUpdates.text, boundTextUpdates.text,
getFontString(textElement), getFontString(textElement),
textElement.lineHeight, textElement.lineHeight,
); );
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true) boundTextUpdates.width = metrics.width;
if (textElement.autoResize) {
boundTextUpdates.width = metrics.width;
}
boundTextUpdates.height = metrics.height; boundTextUpdates.height = metrics.height;
if (container) { if (container) {
+15 -115
View File
@@ -236,117 +236,6 @@ describe("textWysiwyg", () => {
}); });
}); });
describe("Test text wrapping", () => {
const { h } = window;
const dimensions = { height: 400, width: 800 };
beforeAll(() => {
mockBoundingClientRect(dimensions);
});
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// @ts-ignore
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
h.elements = [];
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should keep width when editing a wrapped text", async () => {
const text = API.createElement({
type: "text",
text: "Excalidraw\nEditor",
});
h.elements = [text];
const prevWidth = text.width;
const prevHeight = text.height;
const prevText = text.text;
// text is wrapped
UI.resize(text, "e", [-20, 0]);
expect(text.width).not.toEqual(prevWidth);
expect(text.height).not.toEqual(prevHeight);
expect(text.text).not.toEqual(prevText);
expect(text.autoResize).toBe(false);
const wrappedWidth = text.width;
const wrappedHeight = text.height;
const wrappedText = text.text;
// edit text
UI.clickTool("selection");
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
const editor = await getTextEditor(textEditorSelector);
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1);
const nextText = `${wrappedText} is great!`;
updateTextEditor(editor, nextText);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
expect(h.elements[0].width).toEqual(wrappedWidth);
expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
// remove all texts and then add it back editing
updateTextEditor(editor, "");
await new Promise((cb) => setTimeout(cb, 0));
updateTextEditor(editor, nextText);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
expect(h.elements[0].width).toEqual(wrappedWidth);
});
it("should restore original text after unwrapping a wrapped text", async () => {
const originalText = "Excalidraw\neditor\nis great!";
const text = API.createElement({
type: "text",
text: originalText,
});
h.elements = [text];
// wrap
UI.resize(text, "e", [-40, 0]);
// enter text editing mode
UI.clickTool("selection");
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
const editor = await getTextEditor(textEditorSelector);
editor.blur();
// restore after unwrapping
UI.resize(text, "e", [40, 0]);
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
// wrap again and add a new line
UI.resize(text, "e", [-30, 0]);
const wrappedText = text.text;
UI.clickTool("selection");
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
updateTextEditor(editor, `${wrappedText}\nA new line!`);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
// remove the newly added line
UI.clickTool("selection");
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
updateTextEditor(editor, wrappedText);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
// unwrap
UI.resize(text, "e", [30, 0]);
// expect the text to be restored the same
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
});
});
describe("Test container-unbound text", () => { describe("Test container-unbound text", () => {
const { h } = window; const { h } = window;
const dimensions = { height: 400, width: 800 }; const dimensions = { height: 400, width: 800 };
@@ -911,15 +800,26 @@ describe("textWysiwyg", () => {
mouse.down(); mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
const editor = await getTextEditor(textEditorSelector, true); let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
editor = await getTextEditor(textEditorSelector, true);
editor.select();
fireEvent.click(screen.getByTitle(/code/i)); fireEvent.click(screen.getByTitle(/code/i));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia); ).toEqual(FONT_FAMILY.Cascadia);
@@ -1064,7 +964,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[ [
85, 85,
"5.00000", 4.999999999999986,
] ]
`); `);
@@ -1109,8 +1009,8 @@ describe("textWysiwyg", () => {
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[ [
"375.00000", 374.99999999999994,
"-535.00000", -535.0000000000001,
] ]
`); `);
}); });
+15 -14
View File
@@ -79,14 +79,12 @@ export const textWysiwyg = ({
app, app,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** onChange?: (text: string) => void;
* textWysiwyg only deals with `originalText` onSubmit: (data: {
* text: string;
* Note: `text`, which can be wrapped and therefore different from `originalText`, viaKeyboard: boolean;
* is derived from `originalText` originalText: string;
*/ }) => void;
onChange?: (nextOriginalText: string) => void;
onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
getViewportCoords: (x: number, y: number) => [number, number]; getViewportCoords: (x: number, y: number) => [number, number];
element: ExcalidrawTextElement; element: ExcalidrawTextElement;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@@ -131,8 +129,11 @@ export const textWysiwyg = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
let maxWidth = updatedTextElement.width; let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height; let maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width; let textElementWidth = updatedTextElement.width;
// Set to element height by default since that's
// what is going to be used for unbounded text
const textElementHeight = updatedTextElement.height; const textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
@@ -225,8 +226,6 @@ export const textWysiwyg = ({
if (!container) { if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth); textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
} }
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
@@ -261,7 +260,6 @@ export const textWysiwyg = ({
if (isTestEnv()) { if (isTestEnv()) {
editable.style.fontFamily = getFontFamilyString(updatedTextElement); editable.style.fontFamily = getFontFamilyString(updatedTextElement);
} }
mutateElement(updatedTextElement, { x: coordX, y: coordY }); mutateElement(updatedTextElement, { x: coordX, y: coordY });
} }
}; };
@@ -278,7 +276,7 @@ export const textWysiwyg = ({
let whiteSpace = "pre"; let whiteSpace = "pre";
let wordBreak = "normal"; let wordBreak = "normal";
if (isBoundToContainer(element) || !element.autoResize) { if (isBoundToContainer(element)) {
whiteSpace = "pre-wrap"; whiteSpace = "pre-wrap";
wordBreak = "break-word"; wordBreak = "break-word";
} }
@@ -501,12 +499,14 @@ export const textWysiwyg = ({
if (!updateElement) { if (!updateElement) {
return; return;
} }
let text = editable.value;
const container = getContainerElement( const container = getContainerElement(
updateElement, updateElement,
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
if (container) { if (container) {
text = updateElement.text;
if (editable.value.trim()) { if (editable.value.trim()) {
const boundTextElementId = getBoundTextElementId(container); const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) { if (!boundTextElementId || boundTextElementId !== element.id) {
@@ -538,8 +538,9 @@ export const textWysiwyg = ({
} }
onSubmit({ onSubmit({
text,
viaKeyboard: submittedViaKeyboard, viaKeyboard: submittedViaKeyboard,
nextOriginalText: editable.value, originalText: editable.value,
}); });
}; };
@@ -643,7 +644,7 @@ export const textWysiwyg = ({
}; };
// handle updates of textElement properties of editing element // handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => { const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
updateWysiwygStyle(); updateWysiwygStyle();
const isColorPickerActive = !!document.activeElement?.closest( const isColorPickerActive = !!document.activeElement?.closest(
".color-picker-content", ".color-picker-content",
@@ -9,6 +9,7 @@ import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
import { isFrameLikeElement, isLinearElement } from "./typeChecks"; import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import { import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
@@ -64,6 +65,13 @@ export const OMIT_SIDES_FOR_FRAME = {
rotation: true, rotation: true,
}; };
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
e: true,
s: true,
n: true,
w: true,
};
const OMIT_SIDES_FOR_LINE_SLASH = { const OMIT_SIDES_FOR_LINE_SLASH = {
e: true, e: true,
s: true, s: true,
@@ -282,6 +290,8 @@ export const getTransformHandles = (
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
} }
} }
} else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameLikeElement(element)) { } else if (isFrameLikeElement(element)) {
omitSides = { omitSides = {
...omitSides, ...omitSides,
-7
View File
@@ -193,13 +193,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null; containerId: ExcalidrawGenericElement["id"] | null;
originalText: string; 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 * Unitless line height (aligned to W3C). To get line height in px, multiply
* with font size (using `getLineHeightInPx` helper). * with font size (using `getLineHeightInPx` helper).
+18 -2
View File
@@ -133,11 +133,27 @@ const getMovedIndicesGroups = (
let i = 0; let i = 0;
while (i < elements.length) { while (i < elements.length) {
if (movedElements.has(elements[i].id)) { if (
movedElements.has(elements[i].id) &&
!isValidFractionalIndex(
elements[i]?.index,
elements[i - 1]?.index,
elements[i + 1]?.index,
)
) {
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
while (++i < elements.length) { while (++i < elements.length) {
if (!movedElements.has(elements[i].id)) { if (
!(
movedElements.has(elements[i].id) &&
!isValidFractionalIndex(
elements[i]?.index,
elements[i - 1]?.index,
elements[i + 1]?.index,
)
)
) {
break; break;
} }
+4 -1
View File
@@ -27,7 +27,10 @@ import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds"; import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/"; import {
doLineSegmentsIntersect,
elementsOverlappingBBox,
} from "@excalidraw/utils";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
import type { ReadonlySetLike } from "./utility-types"; import type { ReadonlySetLike } from "./utility-types";
@@ -2,7 +2,7 @@ import { atom, useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "../colors"; import { COLOR_PALETTE } from "../colors";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { exportToSvg } from "../../utils/export"; import { exportToSvg } from "@excalidraw/utils";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>; export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
+2 -2
View File
@@ -227,7 +227,7 @@ export {
exportToBlob, exportToBlob,
exportToSvg, exportToSvg,
exportToClipboard, exportToClipboard,
} from "../utils/export"; } from "@excalidraw/utils";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export { export {
@@ -283,4 +283,4 @@ export {
elementsOverlappingBBox, elementsOverlappingBBox,
isElementInsideBBox, isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox, elementPartiallyOverlapsWithOrContainsBBox,
} from "../utils/withinBounds"; } from "@excalidraw/utils";
+2 -22
View File
@@ -148,9 +148,7 @@
"discordChat": "Discord chat", "discordChat": "Discord chat",
"zoomToFitViewport": "Zoom to fit in viewport", "zoomToFitViewport": "Zoom to fit in viewport",
"zoomToFitSelection": "Zoom to fit selection", "zoomToFitSelection": "Zoom to fit selection",
"zoomToFit": "Zoom to fit all elements", "zoomToFit": "Zoom to fit all elements"
"installPWA": "Install Excalidraw locally (PWA)",
"autoResize": "Enable text auto-resizing"
}, },
"library": { "library": {
"noItems": "No items added yet...", "noItems": "No items added yet...",
@@ -270,22 +268,6 @@
"mermaidToExcalidraw": "Mermaid to Excalidraw", "mermaidToExcalidraw": "Mermaid to Excalidraw",
"magicSettings": "AI settings" "magicSettings": "AI settings"
}, },
"element": {
"rectangle": "Rectangle",
"diamond": "Diamond",
"ellipse": "Ellipse",
"arrow": "Arrow",
"line": "Line",
"freedraw": "Freedraw",
"text": "Text",
"image": "Image",
"group": "Group",
"frame": "Frame",
"magicframe": "Wireframe to code",
"embeddable": "Web Embed",
"selection": "Selection",
"iframe": "IFrame"
},
"headings": { "headings": {
"canvasActions": "Canvas actions", "canvasActions": "Canvas actions",
"selectedShapeActions": "Selected shape actions", "selectedShapeActions": "Selected shape actions",
@@ -459,9 +441,7 @@
"scene": "Scene", "scene": "Scene",
"selected": "Selected", "selected": "Selected",
"storage": "Storage", "storage": "Storage",
"title": "Stats", "title": "Stats for nerds",
"generalStats": "General stats",
"elementStats": "Element stats",
"total": "Total", "total": "Total",
"version": "Version", "version": "Version",
"versionCopy": "Click to copy", "versionCopy": "Click to copy",
-8
View File
@@ -475,14 +475,6 @@ export const isRightAngle = (angle: number) => {
return Math.round((angle / Math.PI) * 10000) % 5000 === 0; return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
}; };
export const radianToDegree = (r: number) => {
return (r * 180) / Math.PI;
};
export const degreeToRadian = (d: number) => {
return (d / 180) * Math.PI;
};
// Given two ranges, return if the two ranges overlap with each other // Given two ranges, return if the two ranges overlap with each other
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5] // e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
export const rangesOverlap = ( export const rangesOverlap = (
+3 -1
View File
@@ -58,7 +58,8 @@
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.0.0", "@excalidraw/mermaid-to-excalidraw": "0.3.0",
"@excalidraw/utils": "*",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2", "@radix-ui/react-tabs": "1.0.2",
@@ -131,6 +132,7 @@
"pack": "yarn build:umd && yarn pack", "pack": "yarn build:umd && yarn pack",
"start": "node ../../scripts/buildExample.mjs && vite", "start": "node ../../scripts/buildExample.mjs && vite",
"build:example": "node ../../scripts/buildExample.mjs", "build:example": "node ../../scripts/buildExample.mjs",
"clear": "rm -rf dist",
"size": "yarn build:umd && size-limit" "size": "yarn build:umd && size-limit"
} }
} }
+22 -6
View File
@@ -1,5 +1,7 @@
import { isTextElement } from "../element"; import { isTextElement, refreshTextDimensions } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { getContainerElement } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -10,9 +12,17 @@ import { ShapeCache } from "./ShapeCache";
export class Fonts { export class Fonts {
private scene: Scene; private scene: Scene;
private onSceneUpdated: () => void;
constructor({ scene }: { scene: Scene }) { constructor({
scene,
onSceneUpdated,
}: {
scene: Scene;
onSceneUpdated: () => void;
}) {
this.scene = scene; this.scene = scene;
this.onSceneUpdated = onSceneUpdated;
} }
// it's ok to track fonts across multiple instances only once, so let's use // it's ok to track fonts across multiple instances only once, so let's use
@@ -47,16 +57,22 @@ export class Fonts {
let didUpdate = false; let didUpdate = false;
this.scene.mapElements((element) => { this.scene.mapElements((element) => {
if (isTextElement(element)) { if (isTextElement(element) && !isBoundToContainer(element)) {
didUpdate = true;
ShapeCache.delete(element); ShapeCache.delete(element);
return newElementWith(element, {}, true); didUpdate = true;
return newElementWith(element, {
...refreshTextDimensions(
element,
getContainerElement(element, this.scene.getNonDeletedElementsMap()),
this.scene.getNonDeletedElementsMap(),
),
});
} }
return element; return element;
}); });
if (didUpdate) { if (didUpdate) {
this.scene.triggerUpdate(); this.onSceneUpdated();
} }
}; };
+4 -3
View File
@@ -107,8 +107,9 @@ export class Renderer {
width, width,
editingElement, editingElement,
pendingImageElementId, pendingImageElementId,
// cache-invalidation nonce // unused but serves we cache on it to invalidate elements if they
sceneNonce: _sceneNonce, // get mutated
versionNonce: _versionNonce,
}: { }: {
zoom: AppState["zoom"]; zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"]; offsetLeft: AppState["offsetLeft"];
@@ -119,7 +120,7 @@ export class Renderer {
width: AppState["width"]; width: AppState["width"];
editingElement: AppState["editingElement"]; editingElement: AppState["editingElement"];
pendingImageElementId: AppState["pendingImageElementId"]; pendingImageElementId: AppState["pendingImageElementId"];
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>; versionNonce: ReturnType<InstanceType<typeof Scene>["getVersionNonce"]>;
}) => { }) => {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
+9 -15
View File
@@ -138,17 +138,7 @@ class Scene {
elements: null, elements: null,
cache: new Map(), 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;
getSceneNonce() {
return this.sceneNonce;
}
getNonDeletedElementsMap() { getNonDeletedElementsMap() {
return this.nonDeletedElementsMap; return this.nonDeletedElementsMap;
@@ -224,6 +214,10 @@ class Scene {
return (this.elementsMap.get(id) as T | undefined) || null; return (this.elementsMap.get(id) as T | undefined) || null;
} }
getVersionNonce() {
return this.versionNonce;
}
getNonDeletedElement( getNonDeletedElement(
id: ExcalidrawElement["id"], id: ExcalidrawElement["id"],
): NonDeleted<ExcalidrawElement> | null { ): NonDeleted<ExcalidrawElement> | null {
@@ -292,18 +286,18 @@ class Scene {
this.frames = nextFrameLikes; this.frames = nextFrameLikes;
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements; this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
this.triggerUpdate(); this.informMutation();
} }
triggerUpdate() { informMutation() {
this.sceneNonce = randomInteger(); this.versionNonce = randomInteger();
for (const callback of Array.from(this.callbacks)) { for (const callback of Array.from(this.callbacks)) {
callback(); callback();
} }
} }
onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover { addCallback(cb: SceneStateCallback): SceneStateCallbackRemover {
if (this.callbacks.has(cb)) { if (this.callbacks.has(cb)) {
throw new Error(); throw new Error();
} }
+1 -1
View File
@@ -339,7 +339,7 @@ export const exportToSvg = async (
assetPath = assetPath =
window.EXCALIDRAW_ASSET_PATH || window.EXCALIDRAW_ASSET_PATH ||
`https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${ `https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${
import.meta.env.VITE_PKG_VERSION import.meta.env.PKG_VERSION
}`; }`;
if (assetPath?.startsWith("/")) { if (assetPath?.startsWith("/")) {
+5 -31
View File
@@ -6,7 +6,6 @@ import { deepCopyElement } from "./element/newElement";
import type { OrderedExcalidrawElement } from "./element/types"; import type { OrderedExcalidrawElement } from "./element/types";
import { Emitter } from "./emitter"; import { Emitter } from "./emitter";
import type { AppState, ObservedAppState } from "./types"; import type { AppState, ObservedAppState } from "./types";
import type { ValueOf } from "./utility-types";
import { isShallowEqual } from "./utils"; import { isShallowEqual } from "./utils";
// hidden non-enumerable property for runtime checks // hidden non-enumerable property for runtime checks
@@ -36,41 +35,16 @@ const isObservedAppState = (
): appState is ObservedAppState => ): appState is ObservedAppState =>
!!Reflect.get(appState, hiddenObservedAppStateProp); !!Reflect.get(appState, hiddenObservedAppStateProp);
export const StoreAction = { export type StoreActionType = "capture" | "update" | "none";
/**
* Immediately undoable. export const StoreAction: {
* [K in Uppercase<StoreActionType>]: StoreActionType;
* 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.
*/
CAPTURE: "capture", CAPTURE: "capture",
/**
* Never undoable.
*
* Use 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.
*/
UPDATE: "update", UPDATE: "update",
/**
* Eventually undoable.
*
* 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
* `StoreAction.CAPTURE` - triggered either by the next `updateScene`
* or internally by the editor.
*
* These updates will _eventually_ make it to the local undo / redo stacks.
*/
NONE: "none", NONE: "none",
} as const; } as const;
export type StoreActionType = ValueOf<typeof StoreAction>;
/** /**
* Represent an increment to the Store. * Represent an increment to the Store.
*/ */
@@ -12,7 +12,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@@ -27,7 +26,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -130,7 +129,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -182,7 +181,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -248,7 +247,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -288,7 +287,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -327,16 +326,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@@ -398,15 +387,11 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -453,7 +438,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -497,15 +482,11 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -554,7 +535,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -603,7 +584,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -643,7 +624,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4433,7 +4414,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@@ -4448,7 +4428,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4551,7 +4531,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4603,7 +4583,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4669,7 +4649,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4709,7 +4689,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4748,16 +4728,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@@ -4819,15 +4789,11 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4874,7 +4840,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4918,15 +4884,11 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -4975,7 +4937,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5024,7 +4986,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5064,7 +5026,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5552,7 +5514,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@@ -5567,7 +5528,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5670,7 +5631,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5722,7 +5683,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5788,7 +5749,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5828,7 +5789,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5867,16 +5828,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@@ -5938,15 +5889,11 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -5993,7 +5940,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -6037,15 +5984,11 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -6094,7 +6037,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -6143,7 +6086,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -6183,7 +6126,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -6741,7 +6684,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -6793,7 +6736,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7005,7 +6948,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7061,7 +7004,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7105,7 +7048,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7158,7 +7101,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
<g <g
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7200,7 +7143,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7378,7 +7321,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@@ -7393,7 +7335,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7496,7 +7438,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7548,7 +7490,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7614,7 +7556,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7654,7 +7596,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7693,16 +7635,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@@ -7764,15 +7696,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7819,7 +7747,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7863,15 +7791,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7920,7 +7844,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -7969,7 +7893,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8009,7 +7933,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8264,7 +8188,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@@ -8279,7 +8202,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8382,7 +8305,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8434,7 +8357,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8500,7 +8423,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8540,7 +8463,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8579,16 +8502,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@@ -8650,15 +8563,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8705,7 +8614,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8749,15 +8658,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
style={ transform="rotate(180)"
{
"transform": "rotate(180deg)",
}
}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8806,7 +8711,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.50000"} strokeWidth={1.5}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8855,7 +8760,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
@@ -8895,7 +8800,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g <g
strokeWidth={"1.25000"} strokeWidth={1.25}
> >
<path <path
d="M0 0h24v24H0z" d="M0 0h24v24H0z"
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" 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: 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: Virgil, Segoe UI Emoji;" 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: 10px; height: 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: Virgil, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />
@@ -189,13 +189,13 @@ exports[`move element > rectangles with binding arrow 7`] = `
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id1", "elementId": "id1",
"focus": "-0.46667", "focus": -0.46666666666666673,
"gap": 10, "gap": 10,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "81.48231", "height": 81.48231043525051,
"id": "id2", "id": "id2",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -210,7 +210,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
], ],
[ [
81, 81,
"81.48231", 81.48231043525051,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -221,7 +221,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id0", "elementId": "id0",
"focus": "-0.60000", "focus": -0.6000000000000001,
"gap": 10, "gap": 10,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@@ -233,6 +233,6 @@ exports[`move element > rectangles with binding arrow 7`] = `
"versionNonce": 2066753033, "versionNonce": 2066753033,
"width": 81, "width": 81,
"x": 110, "x": 110,
"y": "49.98179", "y": 49.981789081137734,
} }
`; `;
@@ -336,7 +336,7 @@ History {
"groupIds": [ "groupIds": [
"id5", "id5",
], ],
"index": "a2", "index": "a1V",
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
@@ -348,11 +348,9 @@ History {
"groupIds": [ "groupIds": [
"id5", "id5",
], ],
"index": "a3",
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"index": "a2",
}, },
}, },
}, },
@@ -721,7 +719,7 @@ History {
"groupIds": [ "groupIds": [
"id4", "id4",
], ],
"index": "a2", "index": "a1V",
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
@@ -733,11 +731,9 @@ History {
"groupIds": [ "groupIds": [
"id4", "id4",
], ],
"index": "a3",
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"index": "a2",
}, },
}, },
}, },
@@ -1860,7 +1856,7 @@ History {
"groupIds": [ "groupIds": [
"id5", "id5",
], ],
"index": "a2", "index": "a1V",
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
@@ -1872,11 +1868,9 @@ History {
"groupIds": [ "groupIds": [
"id5", "id5",
], ],
"index": "a3",
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"index": "a2",
}, },
}, },
}, },
@@ -10758,7 +10752,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollX": "-6.25000", "scrollX": -2.916666666666668,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"selectedElementIds": {}, "selectedElementIds": {},
@@ -12816,7 +12810,7 @@ History {
"id5", "id5",
"id3", "id3",
], ],
"index": "a2", "index": "a1V",
}, },
"inserted": { "inserted": {
"groupIds": [ "groupIds": [
@@ -12831,13 +12825,11 @@ History {
"id5", "id5",
"id3", "id3",
], ],
"index": "a3",
}, },
"inserted": { "inserted": {
"groupIds": [ "groupIds": [
"id3", "id3",
], ],
"index": "a2",
}, },
}, },
}, },
@@ -13696,8 +13688,8 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollX": 20, "scrollX": 10,
"scrollY": "-18.53553", "scrollY": -10,
"scrolledOutside": false, "scrolledOutside": false,
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
@@ -302,7 +302,6 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = ` exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [], "boundElements": [],
"containerId": null, "containerId": null,
@@ -316,7 +315,7 @@ exports[`restoreElements > should restore text element correctly passing value f
"id": "id-text01", "id": "id-text01",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lineHeight": "1.25000", "lineHeight": 1.25,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -338,14 +337,13 @@ exports[`restoreElements > should restore text element correctly passing value f
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 100, "width": 100,
"x": -20, "x": -20,
"y": "-8.75000", "y": -8.75,
} }
`; `;
exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = ` exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [], "boundElements": [],
"containerId": null, "containerId": null,
@@ -359,7 +357,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"id": "id-text01", "id": "id-text01",
"index": "a0", "index": "a0",
"isDeleted": true, "isDeleted": true,
"lineHeight": "1.25000", "lineHeight": 1.25,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
+91 -116
View File
@@ -77,6 +77,97 @@ describe("sync invalid indices with array order", () => {
}); });
}); });
describe("should NOT sync index when it is already valid", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a4" },
],
movedElements: ["A"],
expect: {
validInput: true,
unchangedElements: ["A", "B"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a4" },
],
movedElements: ["B"],
expect: {
validInput: true,
unchangedElements: ["A", "B"],
},
});
});
describe("should NOT sync indices when they are already valid", () => {
{
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
movedElements: ["B", "C"],
expect: {
// this should not sync 'C'
unchangedElements: ["A", "C"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
movedElements: ["A", "B"],
expect: {
// but this should sync 'A' as it's invalid!
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: {
// should not sync 'E'
unchangedElements: ["A", "C", "E"],
},
});
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: {
// should not sync 'D' and 'F'
unchangedElements: ["C", "D", "F"],
},
});
});
describe("should sync when fractional index is not defined", () => { describe("should sync when fractional index is not defined", () => {
testMovedIndicesSync({ testMovedIndicesSync({
elements: [{ id: "A" }], elements: [{ id: "A" }],
@@ -293,122 +384,6 @@ describe("sync invalid indices with array order", () => {
}); });
}); });
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 fractions for explicitly moved elements", () => {
describe("should generate a fraction between 'A' and 'C'", () => { describe("should generate a fraction between 'A' and 'C'", () => {
testMovedIndicesSync({ testMovedIndicesSync({
+2 -125
View File
@@ -10,9 +10,8 @@ import { Excalidraw } from "../index";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react"; import { fireEvent, waitFor } from "@testing-library/react";
import { createUndoAction, createRedoAction } from "../actions/actionHistory"; import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import type { AppState, ExcalidrawImperativeAPI } from "../types"; import type { AppState, ExcalidrawImperativeAPI } from "../types";
import { arrayToMap, resolvablePromise } from "../utils"; import { arrayToMap, resolvablePromise } from "../utils";
@@ -50,6 +49,7 @@ const checkpoint = (name: string) => {
expect(renderStaticScene.mock.calls.length).toMatchSnapshot( expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`, `[${name}] number of renders`,
); );
// `scrolledOutside` does not appear to be stable between test runs // `scrolledOutside` does not appear to be stable between test runs
// `selectedLinearElemnt` includes `startBindingElement` containing seed and versionNonce // `selectedLinearElemnt` includes `startBindingElement` containing seed and versionNonce
const { const {
@@ -1688,129 +1688,6 @@ describe("history", () => {
]); ]);
}); });
}); });
it("should disable undo/redo buttons when stacks empty", async () => {
const { container } = await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
}}
/>,
);
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
});
const undoButton = queryByTestId(container, "button-undo");
const redoButton = queryByTestId(container, "button-redo");
expect(undoButton).toBeDisabled();
expect(redoButton).toBeDisabled();
const rectangle = UI.createElement("rectangle");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: rectangle.id }),
]);
expect(h.history.isUndoStackEmpty).toBeFalsy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(undoButton).not.toBeDisabled();
expect(redoButton).toBeDisabled();
act(() => h.app.actionManager.executeAction(undoAction));
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeFalsy();
expect(undoButton).toBeDisabled();
expect(redoButton).not.toBeDisabled();
act(() => h.app.actionManager.executeAction(redoAction));
expect(h.history.isUndoStackEmpty).toBeFalsy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(undoButton).not.toBeDisabled();
expect(redoButton).toBeDisabled();
});
it("remounting undo/redo buttons should initialize undo/redo state correctly", async () => {
const { container } = await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
}}
/>,
);
const undoAction = createUndoAction(h.history, h.store);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
});
expect(queryByTestId(container, "button-undo")).toBeDisabled();
expect(queryByTestId(container, "button-redo")).toBeDisabled();
// testing undo button
// -----------------------------------------------------------------------
const rectangle = UI.createElement("rectangle");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: rectangle.id }),
]);
expect(h.history.isUndoStackEmpty).toBeFalsy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(queryByTestId(container, "button-undo")).not.toBeDisabled();
expect(queryByTestId(container, "button-redo")).toBeDisabled();
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
expect(h.state.viewModeEnabled).toBe(true);
expect(queryByTestId(container, "button-undo")).toBeNull();
expect(queryByTestId(container, "button-redo")).toBeNull();
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
expect(h.state.viewModeEnabled).toBe(false);
await waitFor(() => {
expect(queryByTestId(container, "button-undo")).not.toBeDisabled();
expect(queryByTestId(container, "button-redo")).toBeDisabled();
});
// testing redo button
// -----------------------------------------------------------------------
act(() => h.app.actionManager.executeAction(undoAction));
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeFalsy();
expect(queryByTestId(container, "button-undo")).toBeDisabled();
expect(queryByTestId(container, "button-redo")).not.toBeDisabled();
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
expect(h.state.viewModeEnabled).toBe(true);
expect(queryByTestId(container, "button-undo")).toBeNull();
expect(queryByTestId(container, "button-redo")).toBeNull();
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
expect(h.state.viewModeEnabled).toBe(false);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeFalsy();
expect(queryByTestId(container, "button-undo")).toBeDisabled();
expect(queryByTestId(container, "button-redo")).not.toBeDisabled();
});
}); });
describe("multiplayer undo/redo", () => { describe("multiplayer undo/redo", () => {
@@ -319,12 +319,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[ [
[ [
"55.96978", 55.9697848965255,
"47.44233", 47.442326230998205,
], ],
[ [
"76.08587", 76.08587175006699,
"43.29417", 43.294165939653226,
], ],
] ]
`); `);
@@ -381,12 +381,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"105.96978", 105.96978489652551,
"67.44233", 67.4423262309982,
], ],
[ [
"126.08587", 126.08587175006699,
"63.29417", 63.294165939653226,
], ],
] ]
`); `);
@@ -627,16 +627,16 @@ describe("Test Linear Elements", () => {
0, 0,
], ],
[ [
"85.96978", 85.96978489652551,
"77.44233", 77.4423262309982,
], ],
[ [
70, 70,
50, 50,
], ],
[ [
"106.08587", 106.08587175006699,
"73.29417", 73.29416593965323,
], ],
[ [
40, 40,
@@ -683,12 +683,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"31.88408", 31.884084517616053,
"23.13276", 23.13275505472383,
], ],
[ [
"77.74793", 77.74792546875662,
"44.57841", 44.57840982272327,
], ],
] ]
`); `);
@@ -769,12 +769,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"55.96978", 55.9697848965255,
"47.44233", 47.442326230998205,
], ],
[ [
"76.08587", 76.08587175006699,
"43.29417", 43.294165939653226,
], ],
] ]
`); `);
@@ -928,8 +928,8 @@ describe("Test Linear Elements", () => {
); );
expect(position).toMatchInlineSnapshot(` expect(position).toMatchInlineSnapshot(`
{ {
"x": "85.82202", "x": 85.82201843191861,
"y": "75.63461", "y": 75.63461309860818,
} }
`); `);
}); });
@@ -972,10 +972,10 @@ describe("Test Linear Elements", () => {
]); ]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"Online whiteboard "Online whiteboard
collaboration made collaboration made
easy" easy"
`); `);
}); });
it("should bind text to arrow when clicked on arrow and enter pressed", async () => { it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
@@ -1006,10 +1006,10 @@ describe("Test Linear Elements", () => {
]); ]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"Online whiteboard "Online whiteboard
collaboration made collaboration made
easy" easy"
`); `);
}); });
it("should not bind text to line when double clicked", async () => { it("should not bind text to line when double clicked", async () => {
@@ -1068,15 +1068,15 @@ describe("Test Linear Elements", () => {
true, true,
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
[ [
20, 20,
20, 20,
105, 105,
80, 80,
"55.45894", 55.45893770831013,
45, 45,
] ]
`); `);
UI.resize(container, "ne", [300, 200]); UI.resize(container, "ne", [300, 200]);
@@ -1084,7 +1084,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"height": 130, "height": 130,
"width": "366.11716", "width": 366.11716195150507,
} }
`); `);
@@ -1095,11 +1095,11 @@ describe("Test Linear Elements", () => {
arrayToMap(h.elements), arrayToMap(h.elements),
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
"x": "271.11716", "x": 271.11716195150507,
"y": 45, "y": 45,
} }
`); `);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"Online whiteboard "Online whiteboard
@@ -1112,15 +1112,15 @@ describe("Test Linear Elements", () => {
true, true,
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
[ [
20, 20,
35, 35,
"501.11716", 501.11716195150507,
95, 95,
"205.45894", 205.4589377083102,
"52.50000", 52.5,
] ]
`); `);
}); });
it("should resize and position the bound text correctly when 2 pointer linear element resized", () => { it("should resize and position the bound text correctly when 2 pointer linear element resized", () => {
-106
View File
@@ -426,112 +426,6 @@ describe("text element", () => {
expect(text.fontSize).toBe(fontSize); 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", () => { describe("image element", () => {
@@ -6,7 +6,7 @@ import {
rectangleWithLinkFixture, rectangleWithLinkFixture,
} from "../fixtures/elementFixture"; } from "../fixtures/elementFixture";
import { API } from "../helpers/api"; import { API } from "../helpers/api";
import { exportToCanvas, exportToSvg } from "../../../utils"; import { exportToCanvas, exportToSvg } from "../../../utils/src/export";
import { FRAME_STYLE } from "../../constants"; import { FRAME_STYLE } from "../../constants";
import { prepareElementsForExport } from "../../data"; import { prepareElementsForExport } from "../../data";
@@ -250,7 +250,6 @@ describe("exporting frames", () => {
exportPadding: 0, exportPadding: 0,
exportingFrame: frame, exportingFrame: frame,
}); });
// frame itself isn't exported // frame itself isn't exported
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull(); expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
// frame child is exported // frame child is exported
-17
View File
@@ -242,20 +242,3 @@ expect.extend({
}; };
}, },
}); });
/**
* Serializer for IEE754 float pointing numbers to avoid random failures due to tiny precision differences
*/
expect.addSnapshotSerializer({
serialize(val, config, indentation, depth, refs, printer) {
return printer(val.toFixed(5), config, indentation, depth, refs);
},
test(val) {
return (
typeof val === "number" &&
Number.isFinite(val) &&
!Number.isNaN(val) &&
!Number.isInteger(val)
);
},
});
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"], "extends": "../../tsconfig",
"exclude": ["**/*.test.*", "tests", "types", "examples", "packages/**/dist/**", "dist"],
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"strict": true, "strict": true,
-1
View File
@@ -592,7 +592,6 @@ export type AppClassProperties = {
files: BinaryFiles; files: BinaryFiles;
device: App["device"]; device: App["device"];
scene: App["scene"]; scene: App["scene"];
store: App["store"];
pasteFromClipboard: App["pasteFromClipboard"]; pasteFromClipboard: App["pasteFromClipboard"];
id: App["id"]; id: App["id"];
onInsertElements: App["onInsertElements"]; onInsertElements: App["onInsertElements"];
+6 -3
View File
@@ -1,3 +1,6 @@
export * from "./export"; export * from "./src/export";
export * from "./withinBounds"; export * from "./src/withinBounds";
export * from "./bbox"; export * from "./src/bbox";
export * from "./src/collision";
export * from "./src/geometry/shape";
export * from "./src/geometry/geometry";
+3
View File
@@ -64,6 +64,7 @@
"@babel/preset-typescript": "7.18.6", "@babel/preset-typescript": "7.18.6",
"babel-loader": "8.2.5", "babel-loader": "8.2.5",
"babel-plugin-transform-class-properties": "6.24.1", "babel-plugin-transform-class-properties": "6.24.1",
"chokidar": "3.6.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"css-loader": "6.7.1", "css-loader": "6.7.1",
"file-loader": "6.2.0", "file-loader": "6.2.0",
@@ -79,8 +80,10 @@
"scripts": { "scripts": {
"gen:types": "rm -rf types && tsc", "gen:types": "rm -rf types && tsc",
"build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js", "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
"build:src": "rm -rf dist && node ../../scripts/buildUtils.js",
"build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types", "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types",
"build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
"clear": "rm -rf dist",
"pack": "yarn build:umd && yarn pack" "pack": "yarn build:umd && yarn pack"
} }
} }
@@ -1,5 +1,5 @@
import type { Bounds } from "../excalidraw/element/bounds"; import type { Bounds } from "../../excalidraw/element/bounds";
import type { Point } from "../excalidraw/types"; import type { Point } from "../../excalidraw/types";
export type LineSegment = [Point, Point]; export type LineSegment = [Point, Point];
@@ -1,23 +1,23 @@
import { import {
exportToCanvas as _exportToCanvas, exportToCanvas as _exportToCanvas,
exportToSvg as _exportToSvg, exportToSvg as _exportToSvg,
} from "../excalidraw/scene/export"; } from "../../excalidraw/scene/export";
import { getDefaultAppState } from "../excalidraw/appState"; import { getDefaultAppState } from "../../excalidraw/appState";
import type { AppState, BinaryFiles } from "../excalidraw/types"; import type { AppState, BinaryFiles } from "../../excalidraw/types";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeleted, NonDeleted,
} from "../excalidraw/element/types"; } from "../../excalidraw/element/types";
import { restore } from "../excalidraw/data/restore"; import { restore } from "../../excalidraw/data/restore";
import { MIME_TYPES } from "../excalidraw/constants"; import { MIME_TYPES } from "../../excalidraw/constants";
import { encodePngMetadata } from "../excalidraw/data/image"; import { encodePngMetadata } from "../../excalidraw/data/image";
import { serializeAsJSON } from "../excalidraw/data/json"; import { serializeAsJSON } from "../../excalidraw/data/json";
import { import {
copyBlobToClipboardAsPng, copyBlobToClipboardAsPng,
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
} from "../excalidraw/clipboard"; } from "../../excalidraw/clipboard";
export { MIME_TYPES }; export { MIME_TYPES };
@@ -170,6 +170,8 @@ export const exportToSvg = async ({
exportPadding?: number; exportPadding?: number;
renderEmbeddables?: boolean; renderEmbeddables?: boolean;
}): Promise<SVGSVGElement> => { }): Promise<SVGSVGElement> => {
console.info("Watching exportToSVG :)");
const { elements: restoredElements, appState: restoredAppState } = restore( const { elements: restoredElements, appState: restoredAppState } = restore(
{ elements, appState }, { elements, appState },
null, null,
@@ -1,4 +1,4 @@
import { distance2d } from "../../excalidraw/math"; import { distance2d } from "../../../excalidraw/math";
import type { import type {
Point, Point,
Line, Line,
@@ -12,7 +12,7 @@
* to pure shapes * to pure shapes
*/ */
import { getElementAbsoluteCoords } from "../../excalidraw/element"; import { getElementAbsoluteCoords } from "../../../excalidraw/element";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
@@ -27,7 +27,7 @@ import type {
ExcalidrawRectangleElement, ExcalidrawRectangleElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../../excalidraw/element/types"; } from "../../../excalidraw/element/types";
import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry"; import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry";
import { pointsOnBezierCurves } from "points-on-curve"; import { pointsOnBezierCurves } from "points-on-curve";
import type { Drawable, Op } from "roughjs/bin/core"; import type { Drawable, Op } from "roughjs/bin/core";
@@ -3,19 +3,19 @@ import type {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../excalidraw/element/types"; } from "../../excalidraw/element/types";
import { import {
isArrowElement, isArrowElement,
isExcalidrawElement, isExcalidrawElement,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../excalidraw/element/typeChecks"; } from "../../excalidraw/element/typeChecks";
import { isValueInRange, rotatePoint } from "../excalidraw/math"; import { isValueInRange, rotatePoint } from "../../excalidraw/math";
import type { Point } from "../excalidraw/types"; import type { Point } from "../../excalidraw/types";
import type { Bounds } from "../excalidraw/element/bounds"; import type { Bounds } from "../../excalidraw/element/bounds";
import { getElementBounds } from "../excalidraw/element/bounds"; import { getElementBounds } from "../../excalidraw/element/bounds";
import { arrayToMap } from "../excalidraw/utils"; import { arrayToMap } from "../../excalidraw/utils";
type Element = NonDeletedExcalidrawElement; type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[]; type Elements = readonly NonDeletedExcalidrawElement[];
@@ -0,0 +1,102 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`exportToSvg > with default arguments 1`] = `
{
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"lastActiveTool": null,
"locked": false,
"type": "selection",
},
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 1,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "round",
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
"exportEmbedScene": false,
"exportPadding": undefined,
"exportScale": 1,
"exportWithDarkMode": false,
"fileHandle": null,
"followedBy": Set {},
"frameRendering": {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"isBindingEnabled": true,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "name",
"objectsSnapModeEnabled": false,
"openDialog": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"pendingImageElementId": null,
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
"selectedLinearElement": null,
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
"toast": null,
"userToFollow": null,
"viewBackgroundColor": "#ffffff",
"viewModeEnabled": false,
"zenModeEnabled": false,
"zoom": {
"value": 1,
},
}
`;
@@ -1,9 +1,9 @@
import * as utils from "."; import * as utils from "../src/export";
import { diagramFactory } from "../excalidraw/tests/fixtures/diagramFixture"; import { diagramFactory } from "../../excalidraw/tests/fixtures/diagramFixture";
import { vi } from "vitest"; import { vi } from "vitest";
import * as mockedSceneExportUtils from "../excalidraw/scene/export"; import * as mockedSceneExportUtils from "../../excalidraw/scene/export";
import { MIME_TYPES } from "../excalidraw/constants"; import { MIME_TYPES } from "../../excalidraw/constants";
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg"); const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
@@ -11,8 +11,15 @@ import {
pointOnPolyline, pointOnPolyline,
pointRightofLine, pointRightofLine,
pointRotate, pointRotate,
} from "./geometry"; } from "../src/geometry/geometry";
import type { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape"; import type {
Curve,
Ellipse,
Line,
Point,
Polygon,
Polyline,
} from "../src/geometry/shape";
describe("point and line", () => { describe("point and line", () => {
const line: Line = [ const line: Line = [
@@ -1,7 +1,10 @@
import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image"; import {
import type { ImportedDataState } from "../excalidraw/data/types"; decodePngMetadata,
import * as utils from "../utils"; decodeSvgMetadata,
import { API } from "../excalidraw/tests/helpers/api"; } from "../../excalidraw/data/image";
import type { ImportedDataState } from "../../excalidraw/data/types";
import * as utils from "../index";
import { API } from "../../excalidraw/tests/helpers/api";
// NOTE this test file is using the actual API, unmocked. Hence splitting it // NOTE this test file is using the actual API, unmocked. Hence splitting it
// from the other test file, because I couldn't figure out how to test // from the other test file, because I couldn't figure out how to test
@@ -1,10 +1,10 @@
import type { Bounds } from "../excalidraw/element/bounds"; import type { Bounds } from "../../excalidraw/element/bounds";
import { API } from "../excalidraw/tests/helpers/api"; import { API } from "../../excalidraw/tests/helpers/api";
import { import {
elementPartiallyOverlapsWithOrContainsBBox, elementPartiallyOverlapsWithOrContainsBBox,
elementsOverlappingBBox, elementsOverlappingBBox,
isElementInsideBBox, isElementInsideBBox,
} from "./withinBounds"; } from "../src/withinBounds";
const makeElement = (x: number, y: number, width: number, height: number) => const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({ API.createElement({
+3 -3
View File
@@ -1,16 +1,16 @@
{ {
"extends": "../../tsconfig",
"exclude": ["**/*.test.*", "**/tests/*", "types", "examples", "packages/**/dist/**", "dist"],
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"strict": true, "strict": true,
"outDir": "dist", "outDir": "dist",
"skipLibCheck": true, "skipLibCheck": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
"resolveJsonModule": true, "resolveJsonModule": true,
"jsx": "react-jsx" "jsx": "react-jsx"
}, }
"exclude": ["**/*.test.*", "**/tests/*", "types", "dist"]
} }

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+2 -1
View File
@@ -4,7 +4,7 @@ import { execSync } from "child_process";
const createDevBuild = async () => { const createDevBuild = async () => {
return await esbuild.build({ return await esbuild.build({
entryPoints: ["../../examples/excalidraw/with-script-in-browser/index.tsx"], entryPoints: ["../../examples/excalidraw/with-script-in-browser/index.tsx"],
outfile: outfile:
"../../examples/excalidraw/with-script-in-browser/public/bundle.js", "../../examples/excalidraw/with-script-in-browser/public/bundle.js",
define: { define: {
@@ -13,6 +13,7 @@ const createDevBuild = async () => {
bundle: true, bundle: true,
format: "esm", format: "esm",
plugins: [sassPlugin()], plugins: [sassPlugin()],
external: ["@excalidraw/utils"],
loader: { loader: {
".woff2": "dataurl", ".woff2": "dataurl",
".html": "copy", ".html": "copy",
+2
View File
@@ -53,6 +53,7 @@ const browserConfig = {
".woff2": "copy", ".woff2": "copy",
".ttf": "copy", ".ttf": "copy",
}, },
external: ["@excalidraw/utils"],
}; };
const createESMBrowserBuild = async () => { const createESMBrowserBuild = async () => {
// Development unminified build with source maps // Development unminified build with source maps
@@ -107,6 +108,7 @@ const rawConfig = {
".json": "copy", ".json": "copy",
}, },
packages: "external", packages: "external",
external: ["@excalidraw/utils"],
}; };
const createESMRawBuild = async () => { const createESMRawBuild = async () => {
+4
View File
@@ -8,6 +8,7 @@ const browserConfig = {
bundle: true, bundle: true,
format: "esm", format: "esm",
plugins: [sassPlugin()], plugins: [sassPlugin()],
external: ["@excalidraw/utils"],
}; };
// Will be used later for treeshaking // Will be used later for treeshaking
@@ -80,6 +81,7 @@ const rawConfig = {
format: "esm", format: "esm",
packages: "external", packages: "external",
plugins: [sassPlugin()], plugins: [sassPlugin()],
external: ["@excalidraw/utils"],
}; };
// const BASE_PATH = `${path.resolve(`${__dirname}/..`)}`; // const BASE_PATH = `${path.resolve(`${__dirname}/..`)}`;
@@ -119,5 +121,7 @@ const createESMRawBuild = async () => {
fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile)); fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile));
}; };
console.info("BUILDING UTILS STARTED");
createESMRawBuild(); createESMRawBuild();
createESMBrowserBuild(); createESMBrowserBuild();
console.info("BUILDING UTILS COMPLETE");
+16
View File
@@ -0,0 +1,16 @@
const chokidar = require("chokidar");
const path = require("path");
const { execSync } = require("child_process");
const BASE_PATH = `${path.resolve(`${__dirname}/..`)}`;
const utilsDir = `${BASE_PATH}/packages/utils/src`;
// One-liner for current directory
chokidar.watch(utilsDir).on("change", (event) => {
console.info("Watching", event);
try {
execSync(`yarn workspace @excalidraw/utils run build:src`);
} catch (err) {
console.error("Error when building workspace", err);
}
console.info("BUILD DONE");
});

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