Compare commits

...
Author SHA1 Message Date
dwelle 0e76cd4230 debug 2025-01-02 17:15:21 +01:00
dwelle e5a9786af3 wip 2025-01-02 16:59:14 +01:00
dwelle 55be6de5ec fix: caret not showing in Safari for empty wysiwyg 2024-12-29 21:19:31 +01:00
dwelle 42faafe138 fix: UI offset when typing into out-of-viewport wysiwyg 2024-12-29 21:19:31 +01:00
Aakansha DoshiandGitHub 798c795405 docs: add demo link for browser integration (#8956) 2024-12-27 14:39:08 +09:00
107eae3916 refactor: separate resizing logic from pointer (#8155)
* separate resizing logic for a single element

* replace resize logic in stats

* do not recompute width and height from points when they're already given

* correctly update linear elements' position when resized

* update snapshots

* lint

* simplify linear resizing logic

* fix initial scale for aspect ratio

* update tests for linear elements

* test typo

* separate pointer from resizing for multiple elements

* lint and simplify

* fix tests

* lint

* provide scene in param instead

* type

* refactor code

* fix floating in tests

* remove restrictions/checks on width & height

* update pointer to dimension to prevent regression

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-23 11:10:35 +01:00
zsviczianandGitHub 56fca30bd0 fix: normalizeSVG width and height from viewbox when size includes decimal points (#8939)
Update image.ts
2024-12-22 23:10:11 +01:00
Ryan DiandGitHub 1e3399eac8 fix: make arrow binding area adapt to zoom levels (#8927)
* make binding area adapt to zoom

* revert stroke color

* normalize binding gap

* reduce normalized gap
2024-12-22 22:55:50 +01:00
David LuzarandGitHub 873698a1a2 fix: robust state.editingFrame teardown (#8941) 2024-12-22 22:47:39 +01:00
Ryan DiandGitHub 606ac6c743 fix: regression on dragging a selected frame by its name (#8924)
fix hit element check for a selected frame's name
2024-12-22 22:47:21 +01:00
d99e4a23ca feat: use stats panel to crop (#8848)
* feat: use stats panel to crop

* fix: test flake

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-17 13:15:30 +01:00
Ryan DiandGitHub 551bae07a7 feat: snap when cropping as well (#8831)
* crop with snap

* make crop snap work with cmd as well

* turn off grid with cmd as well in crop
2024-12-16 18:31:33 +08:00
2af3221974 fix: right-click paste for images in clipboard (Issue #8826) (#8845)
* Fix right-click paste command for images (Issue #8826)

* Fix clipboard logic for multiple paste types

* fix: remove unused code

* refactor & robustness

* fix: creating paste event with image files

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-10 20:10:34 +00:00
45 changed files with 2017 additions and 1001 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
VITE_APP_PORT=3000
VITE_APP_PORT=3001
#Debug flags
@@ -12,7 +12,7 @@ import { Excalidraw } from "@excalidraw/excalidraw";
Throughout the documentation we use live, editable Excalidraw examples like the one shown below.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
For example, we're passing a `theme` prop to it based on the current color theme of the docs you're just reading.
:::
@@ -70,9 +70,9 @@ If you are using `pages router` then importing the wrapper dynamically would wor
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
</div>
);
};
export default ExcalidrawWrapper;
@@ -84,8 +84,8 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -97,7 +97,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
export default function Page() {
return (
<ExcalidrawWrapper />
<ExcalidrawWrapper />
);
}
```
@@ -108,7 +108,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -153,7 +153,7 @@ Since Vite removes env variables by default, you can update the vite config to e
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
:::
:::
## Browser
@@ -235,3 +235,5 @@ root.render(React.createElement(App));
</TabItem>
</Tabs>
You can try it out [here](https://codesandbox.io/p/sandbox/excalidraw-in-browser-tlqom?file=%2Findex.html%3A1%2C10).
+1 -1
View File
@@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id:"excalidraw",
id: "excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
@@ -161,6 +161,7 @@ export const actionDeleteSelected = register({
element,
selectedPointsIndices,
elementsMap,
appState.zoom,
);
return {
+9 -13
View File
@@ -12,7 +12,6 @@ import { resizeMultipleElements } from "../element/resizeElements";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
bindOrUnbindLinearElements,
isBindingEnabled,
@@ -27,6 +26,7 @@ import {
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -132,19 +132,14 @@ const flipElements = (
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(
elementsMap,
selectedElements,
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
});
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
@@ -153,6 +148,7 @@ const flipElements = (
app.scene,
isBindingEnabled(appState),
[],
appState.zoom,
);
// ---------------------------------------------------------------------------
@@ -1591,6 +1591,7 @@ export const actionChangeArrowType = register({
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const endHoveredElement =
@@ -1599,6 +1600,7 @@ export const actionChangeArrowType = register({
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const startElement = startHoveredElement
+65 -35
View File
@@ -18,6 +18,8 @@ import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { ExcalidrawError } from "./errors";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -39,7 +41,7 @@ export interface ClipboardData {
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEvent =
type ParsedClipboardEventTextData =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
@@ -75,7 +77,7 @@ export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string };
types?: { [key in AllowedPasteMimeTypes]?: string | File };
files?: File[];
}) => {
if (!types && !files) {
@@ -88,6 +90,11 @@ export const createPasteEvent = ({
if (types) {
for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
@@ -217,14 +224,14 @@ function parseHTMLTree(el: ChildNode) {
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html");
const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try {
const doc = new DOMParser().parseFromString(html, "text/html");
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
const content = parseHTMLTree(doc.body);
@@ -238,34 +245,44 @@ const maybeParseHTMLPaste = (
return null;
};
/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.readText() };
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
throw error;
}
}
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
try {
if (navigator.clipboard?.readText) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
const readText = await navigator.clipboard?.readText();
if (readText) {
return { [MIME_TYPES.text]: readText };
}
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
}
throw error;
}
@@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
types[type] = await (await item.getType(type)).text();
} else if (isSupportedImageFileType(type)) {
const imageBlob = await item.getType(type);
const file = createFile(imageBlob, type, undefined);
types[type] = file;
} else {
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
}
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
error instanceof ExcalidrawError
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
@@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
/**
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEvent = async (
const parseClipboardEventTextData = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
): Promise<ParsedClipboardEventTextData> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
@@ -308,7 +335,7 @@ const parseClipboardEvent = async (
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value
.map((item) => item.value)
.join("\n")
@@ -319,7 +346,7 @@ const parseClipboardEvent = async (
return mixedContent;
}
const text = event.clipboardData?.getData("text/plain");
const text = event.clipboardData?.getData(MIME_TYPES.text);
return { type: "text", value: (text || "").trim() };
} catch {
@@ -328,13 +355,16 @@ const parseClipboardEvent = async (
};
/**
* Attempts to parse clipboard. Prefers system clipboard.
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
const parsedEventData = await parseClipboardEventTextData(
event,
isPlainPaste,
);
if (parsedEventData.type === "mixedContent") {
return {
@@ -423,8 +453,8 @@ export const copyTextToSystemClipboard = async (
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
try {
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
+93 -16
View File
@@ -1341,8 +1341,18 @@ class App extends React.Component<AppProps, AppState> {
_cache: new Map(),
};
private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
if (frame) {
mutateElement(frame, { name: frame.name?.trim() || null });
}
this.setState({ editingFrame: null });
};
private renderFrameNames = () => {
if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
if (this.state.editingFrame) {
this.resetEditingFrame(null);
}
return null;
}
@@ -1364,6 +1374,9 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
)
) {
if (this.state.editingFrame === f.id) {
this.resetEditingFrame(f);
}
// if frame not visible, don't render its name
return null;
}
@@ -1375,11 +1388,6 @@ class App extends React.Component<AppProps, AppState> {
const FRAME_NAME_EDIT_PADDING = 6;
const reset = () => {
mutateElement(f, { name: f.name?.trim() || null });
this.setState({ editingFrame: null });
};
let frameNameJSX;
const frameName = getFrameLikeTitle(f);
@@ -1397,13 +1405,13 @@ class App extends React.Component<AppProps, AppState> {
});
}}
onFocus={(e) => e.target.select()}
onBlur={() => reset()}
onBlur={() => this.resetEditingFrame(f)}
onKeyDown={(event) => {
// for some inexplicable reason, `onBlur` triggered on ESC
// does not reset `state.editingFrame` despite being called,
// and we need to reset it here as well
if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
reset();
this.resetEditingFrame(f);
}
}}
style={{
@@ -2575,6 +2583,21 @@ class App extends React.Component<AppProps, AppState> {
this.handleWheel,
{ passive: false },
),
addEventListener(window, "focusin", (event) => {
console.log("%c@@@@@@ focusin:", "color:lime", event.target);
const target = event.target;
if (
event.target instanceof HTMLElement &&
this.state.editingTextElement
) {
if (event.target.tagName !== "TEXTAREA") {
this.focusContainer();
}
}
}),
addEventListener(window, "focusout", (event) => {
console.log("%c@@@@@@ focusout:", "color:red", event.target);
}),
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
addEventListener(document, EVENT.POINTER_UP, this.removePointer, {
passive: false,
@@ -3207,6 +3230,10 @@ class App extends React.Component<AppProps, AppState> {
),
),
[el.points[0], el.points[el.points.length - 1]],
undefined,
{
zoom: this.state.zoom,
},
),
};
}
@@ -4364,6 +4391,7 @@ class App extends React.Component<AppProps, AppState> {
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements,
zoom: this.state.zoom,
});
});
@@ -4373,6 +4401,7 @@ class App extends React.Component<AppProps, AppState> {
(element) => element.id !== elbowArrow?.id || step !== 0,
),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
@@ -4588,6 +4617,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
);
this.setState({ suggestedBindings: [] });
}
@@ -4830,6 +4860,12 @@ class App extends React.Component<AppProps, AppState> {
) {
const elementsMap = this.scene.getElementsMapIncludingDeleted();
// flushSync(() => {
// this.setState({
// editingTextElement: element,
// });
// });
const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
this.scene.replaceAllElements([
// Not sure why we include deleted elements as well hence using deleted elements map
@@ -5085,7 +5121,11 @@ class App extends React.Component<AppProps, AppState> {
isImageElement(element) ? 0 : this.getElementHitThreshold(),
);
return isPointInShape(pointFrom(x, y), selectionShape);
// if hitting the bounding box, return early
// but if not, we should check for other cases as well (e.g. frame name)
if (isPointInShape(pointFrom(x, y), selectionShape)) {
return true;
}
}
// take bound text element into consideration for hit collision as well
@@ -5842,6 +5882,7 @@ class App extends React.Component<AppProps, AppState> {
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else {
@@ -6258,6 +6299,11 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
console.log("(1)", document.activeElement);
console.time();
this.focusContainer();
console.timeEnd();
console.log("(2)", document.activeElement);
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
@@ -6702,17 +6748,16 @@ class App extends React.Component<AppProps, AppState> {
}
isPanning = true;
// due to event.preventDefault below, container wouldn't get focus
// automatically
this.focusContainer();
// preventing defualt while text editing messes with cursor/focus
if (!this.state.editingTextElement) {
// necessary to prevent browser from scrolling the page if excalidraw
// not full-page #4489
//
// as such, the above is broken when panning canvas while in wysiwyg
// note, this fix won't work when panning canvas while in wysiwyg since
// we don't execute it while in wysiwyg
event.preventDefault();
// focus explicitly due to the event.preventDefault above
this.focusContainer();
}
let nextPastePrevented = false;
@@ -7389,6 +7434,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
@@ -7686,6 +7732,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(element),
);
@@ -8264,6 +8311,7 @@ class App extends React.Component<AppProps, AppState> {
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
}
@@ -8432,6 +8480,7 @@ class App extends React.Component<AppProps, AppState> {
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else if (points.length === 2) {
@@ -9396,6 +9445,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
);
}
@@ -9888,6 +9938,7 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
suggestedBindings:
@@ -9916,6 +9967,7 @@ class App extends React.Component<AppProps, AppState> {
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement),
);
if (
@@ -10356,7 +10408,7 @@ class App extends React.Component<AppProps, AppState> {
const [x, y] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.getEffectiveGridSize(),
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const croppingElement = this.scene
@@ -10382,6 +10434,28 @@ class App extends React.Component<AppProps, AppState> {
image &&
!(image instanceof Promise)
) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const dragOffset = {
x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y,
};
this.maybeCacheReferenceSnapPoints(event, [croppingElement]);
const { snapOffset, snapLines } = snapResizingElements(
[croppingElement],
[croppingAtStateStart],
this,
event,
dragOffset,
transformHandleType,
);
mutateElement(
croppingElement,
cropElement(
@@ -10389,8 +10463,8 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType,
image.naturalWidth,
image.naturalHeight,
x,
y,
x + snapOffset.x,
y + snapOffset.y,
event.shiftKey
? croppingAtStateStart.width / croppingAtStateStart.height
: undefined,
@@ -10410,6 +10484,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation",
snapLines,
});
}
@@ -10520,6 +10595,7 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType,
selectedElements,
this.scene.getElementsMapIncludingDeleted(),
this.scene,
shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event),
selectedElements.some((element) => isImageElement(element))
@@ -10534,6 +10610,7 @@ class App extends React.Component<AppProps, AppState> {
const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
const elementsToHighlight = new Set<ExcalidrawElement>();
@@ -19,6 +19,7 @@ import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { CLASSES } from "../../constants";
import "./ColorPicker.scss";
@@ -186,9 +187,13 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
})}
className={clsx(
"color-picker__button active-color",
CLASSES.PROPERTIES_POPOVER_TRIGGER,
{
"is-transparent": color === "transparent" || !color,
},
)}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
title={
@@ -1,6 +1,7 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
@@ -17,6 +18,7 @@ export const CustomColorList = ({
onChange,
label,
}: CustomColorListProps) => {
const appState = useUIAppState();
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
@@ -54,7 +56,9 @@ export const CustomColorList = ({
key={i}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
{!appState.editingTextElement && (
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
)}
</button>
);
})}
@@ -10,6 +10,7 @@ import HotkeyLabel from "./HotkeyLabel";
import type { ColorPaletteCustom } from "../../colors";
import type { TranslationKeys } from "../../i18n";
import { t } from "../../i18n";
import { useUIAppState } from "../../context/ui-appState";
interface PickerColorListProps {
palette: ColorPaletteCustom;
@@ -26,6 +27,8 @@ const PickerColorList = ({
label,
activeShade,
}: PickerColorListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
@@ -80,7 +83,9 @@ const PickerColorList = ({
key={key}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={keybinding} />
)}
</button>
);
})}
@@ -8,6 +8,7 @@ import {
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import type { ColorPaletteCustom } from "../../colors";
import { useUIAppState } from "../../context/ui-appState";
interface ShadeListProps {
hex: string;
@@ -16,6 +17,8 @@ interface ShadeListProps {
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
palette,
@@ -31,7 +34,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
if (btnRef.current && activeColorPickerSection === "shades") {
btnRef.current.focus();
}
}, [colorObj, activeColorPickerSection]);
}, [colorObj?.colorName, colorObj?.shade, activeColorPickerSection]);
if (colorObj) {
const { colorName, shade } = colorObj;
@@ -64,7 +67,9 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
}}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button>
))}
</div>
@@ -56,6 +56,7 @@ export const TopPicks = ({
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
tabIndex={-1}
>
<div className="color-picker__button-outline" />
</button>
@@ -250,6 +250,10 @@ export const FontPickerList = React.memo(
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
onFocusOutside={(event) => {
// so we don't close when refocusing wysiwyg while editing
event.preventDefault();
}}
>
<QuickSearch
ref={inputRef}
@@ -5,6 +5,7 @@ import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
import { CLASSES } from "../../constants";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
@@ -26,7 +27,7 @@ export const FontPickerTrigger = ({
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
className={CLASSES.PROPERTIES_POPOVER_TRIGGER}
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
@@ -5,6 +5,7 @@ import * as Popover from "@radix-ui/react-popover";
import { useDevice } from "./App";
import { Island } from "./Island";
import { isInteractive } from "../utils";
import { CLASSES } from "../constants";
interface PropertiesPopoverProps {
className?: string;
@@ -42,7 +43,11 @@ export const PropertiesPopover = React.forwardRef<
<Popover.Portal container={container}>
<Popover.Content
ref={ref}
className={clsx("focus-visible-none", className)}
className={clsx(
"focus-visible-none",
CLASSES.PROPERTIES_POPOVER,
className,
)}
data-prevent-outside-click
side={
device.editor.isMobile && !device.viewport.isLandscape
@@ -1,10 +1,18 @@
import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { resizeSingleElement } from "../../element/resizeElements";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { isImageElement } from "../../element/typeChecks";
import {
MINIMAL_CROP_SIZE,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
import { clamp, round } from "../../../math";
interface DimensionDragInputProps {
property: "width" | "height";
@@ -23,20 +31,124 @@ const handleDimensionChange: DragInputCallbackType<
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
property,
originalAppState,
instantChange,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (origElement && latestElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height;
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = { ...crop };
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth;
const naturalToUncroppedHeightRatio =
crop.naturalHeight / uncroppedHeight;
const MAX_POSSIBLE_WIDTH = isFlippedByX
? crop.width + crop.x
: crop.naturalWidth - crop.x;
const MAX_POSSIBLE_HEIGHT = isFlippedByY
? crop.height + crop.y
: crop.naturalHeight - crop.y;
const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio;
const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio;
if (nextValue !== undefined) {
if (property === "width") {
const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio;
const nextCropWidth = clamp(
nextValueInNatural,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
nextCrop = {
...nextCrop,
width: nextCropWidth,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
};
} else if (property === "height") {
const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio;
const nextCropHeight = clamp(
nextValueInNatural,
MIN_HEIGHT,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...nextCrop,
height: nextCropHeight,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
};
}
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
const changeInWidth = property === "width" ? instantChange : 0;
const changeInHeight = property === "height" ? instantChange : 0;
const nextCropWidth = clamp(
crop.width + changeInWidth,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
const nextCropHeight = clamp(
crop.height + changeInHeight,
MIN_WIDTH,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...crop,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
width: nextCropWidth,
height: nextCropHeight,
};
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
@@ -55,14 +167,17 @@ const handleDimensionChange: DragInputCallbackType<
MIN_WIDTH_OR_HEIGHT,
);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
return;
@@ -99,14 +214,17 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
}
};
@@ -117,9 +235,25 @@ const DimensionDragInput = ({
scene,
appState,
}: DimensionDragInputProps) => {
const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
100;
let value = round(property === "width" ? element.width : element.height, 2);
if (
appState.croppingElementId &&
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (property === "width") {
const ratio = uncroppedWidth / element.crop.naturalWidth;
value = round(element.crop.width * ratio, 2);
}
if (property === "height") {
const ratio = uncroppedHeight / element.crop.naturalHeight;
value = round(element.crop.height * ratio, 2);
}
}
return (
<DragInput
@@ -2,7 +2,10 @@ import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import { rescalePointsInElement } from "../../element/resizeElements";
import {
rescalePointsInElement,
resizeSingleElement,
} from "../../element/resizeElements";
import {
getBoundTextElement,
handleBindTextResize,
@@ -17,7 +20,7 @@ import type { AppState } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import { getElementsInAtomicUnit } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math";
@@ -150,7 +153,6 @@ const handleDimensionChange: DragInputCallbackType<
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
@@ -223,15 +225,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
false,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -324,14 +328,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -4,7 +4,13 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math";
import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
import { isImageElement } from "../../element/typeChecks";
import {
getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
interface PositionProps {
property: "x" | "y";
@@ -18,12 +24,14 @@ const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
@@ -38,6 +46,82 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.angle,
);
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = crop;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (nextValue !== undefined) {
if (property === "x") {
const nextValueInNatural =
nextValue * (crop.naturalWidth / uncroppedWidth);
if (isFlippedByX) {
nextCrop = {
...crop,
x: clamp(
crop.naturalWidth - nextValueInNatural - crop.width,
0,
crop.naturalWidth - crop.width,
),
};
} else {
nextCrop = {
...crop,
x: clamp(
nextValue * (crop.naturalWidth / uncroppedWidth),
0,
crop.naturalWidth - crop.width,
),
};
}
}
if (property === "y") {
nextCrop = {
...crop,
y: clamp(
nextValue * (crop.naturalHeight / uncroppedHeight),
0,
crop.naturalHeight - crop.height,
),
};
}
mutateElement(element, {
crop: nextCrop,
});
return;
}
const changeInX =
(property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1);
const changeInY =
(property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1);
nextCrop = {
...crop,
x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width),
y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
};
mutateElement(element, {
crop: nextCrop,
});
return;
}
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
@@ -97,8 +181,22 @@ const Position = ({
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
);
const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
let value = round(property === "x" ? topLeftX : topLeftY, 2);
if (
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const flipAdjustedPosition = getFlipAdjustedCropPosition(element);
if (flipAdjustedPosition) {
value = round(
property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y,
2,
);
}
}
return (
<StatsDragInput
+39 -3
View File
@@ -23,12 +23,14 @@ import Collapsible from "./Collapsible";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
import { isElbowArrow, isImageElement } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math";
interface StatsProps {
app: AppClassProperties;
@@ -128,6 +130,13 @@ export const StatsInner = memo(
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const cropMode =
appState.croppingElementId && isImageElement(singleElement);
const unCroppedDimension = cropMode
? getUncroppedWidthAndHeight(singleElement)
: null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
@@ -244,8 +253,34 @@ export const StatsInner = memo(
<StatsRows>
{singleElement && (
<>
{cropMode && (
<StatsRow heading>
{t("labels.unCroppedDimension")}
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.width")}</div>
<div>{round(unCroppedDimension.width, 2)}</div>
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.height")}</div>
<div>{round(unCroppedDimension.height, 2)}</div>
</StatsRow>
)}
<StatsRow heading data-testid="stats-element-type">
{t(`element.${singleElement.type}`)}
{appState.croppingElementId
? t("labels.imageCropping")
: t(`element.${singleElement.type}`)}
</StatsRow>
<StatsRow>
@@ -387,7 +422,8 @@ export const StatsInner = memo(
prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep
prev.appState.gridStep === next.appState.gridStep &&
prev.appState.croppingElementId === next.appState.croppingElementId
);
},
);
+3 -101
View File
@@ -5,17 +5,7 @@ import {
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { getBoundTextElement } from "../../element/textElement";
import {
isFrameLikeElement,
isLinearElement,
@@ -34,7 +24,6 @@ import {
} from "../../groups";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
@@ -121,95 +110,6 @@ export const newOrigin = (
};
};
export const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
mutateElement(
latestElement,
{
...newOrigin(
latestElement.x,
latestElement.y,
latestElement.width,
latestElement.height,
nextWidth,
nextHeight,
latestElement.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestElement,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
}
}
updateBoundElements(latestElement, elementsMap, {
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
};
export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
@@ -300,6 +200,7 @@ export const updateBindings = (
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
@@ -310,6 +211,7 @@ export const updateBindings = (
scene,
true,
[],
options?.zoom,
);
} else {
updateBoundElements(latestElement, elementsMap, options);
+10 -2
View File
@@ -115,6 +115,8 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
PROPERTIES_POPOVER: "properties-popover",
PROPERTIES_POPOVER_TRIGGER: "properties-popover-trigger",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@@ -214,9 +216,9 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
@@ -230,6 +232,12 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES,
} as const;
export const ALLOWED_PASTE_MIME_TYPES = [
MIME_TYPES.text,
MIME_TYPES.html,
...Object.values(IMAGE_MIME_TYPES),
] as const;
export const EXPORT_IMAGE_TYPES = {
png: "png",
svg: "svg",
+5
View File
@@ -759,3 +759,8 @@ body.excalidraw-cursor-resize * {
font-family: "Assistant";
}
}
.excalidraw-textEditorContainer {
position: fixed;
z-index: var(--zIndex-wysiwyg);
}
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 35,
"height": 33.519031369643244,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0.5,
],
[
394.5,
34.5,
382.47606040672997,
34.019031369643244,
],
],
"roughness": 1,
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 7,
"versionNonce": Any<Number>,
"width": 395,
"width": 381.97606040672997,
"x": 247,
"y": 420,
}
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0,
],
[
399.5,
389.5,
0,
],
],
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 400,
"x": 227,
"width": 390,
"x": 237,
"y": 450,
}
`;
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"verticalAlign": "top",
"width": 100,
"x": 560,
"y": 226.5,
"y": 236.95454545454544,
}
`;
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 205,
"focus": 1.625925925925924,
"gap": 14,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"height": 18.278619528619487,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"points": [
[
0.5,
0,
-0.5,
],
[
99.5,
0,
357.2037037037038,
-17.778619528619487,
],
],
"roughness": 1,
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
"y": 239,
"width": 357.7037037037038,
"x": 171,
"y": 249.45454545454544,
}
`;
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
0,
],
[
272.485,
270.98528125,
0,
],
],
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 7,
"versionNonce": Any<Number>,
"width": 272.985,
"x": 111.262,
"width": 270.48528125,
"x": 112.76171875,
"y": 57,
}
`;
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 0,
"x": 77.017,
"y": 79,
"x": 83.015625,
"y": 81.5,
}
`;
+5 -1
View File
@@ -106,11 +106,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg";
};
export const isSupportedImageFileType = (type: string | null | undefined) => {
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {};
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
return isSupportedImageFileType(type);
};
export const loadSceneOrLibraryFromBlob = async (
+1
View File
@@ -639,6 +639,7 @@ export const restoreAppState = (
gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
),
editingFrame: null,
};
};
+1 -1
View File
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
elementId: "rect-1",
fixedPoint: null,
focus: 0,
gap: 205,
gap: 14,
});
expect(rect.boundElements).toStrictEqual([
{
+82 -12
View File
@@ -40,7 +40,6 @@ import {
isBoundToContainer,
isElbowArrow,
isFixedPointBinding,
isFrameLikeElement,
isLinearElement,
isRectangularElement,
isTextElement,
@@ -97,6 +96,8 @@ export const isBindingEnabled = (appState: AppState): boolean => {
};
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = (
scene: Scene,
@@ -213,6 +214,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
@@ -223,7 +225,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap)
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
@@ -235,12 +237,14 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement,
edge as "start" | "end",
elementsMap,
zoom,
),
);
@@ -250,6 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
draggingPoints: readonly number[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0;
const endIdx = selectedElement.points.length - 1;
@@ -262,6 +267,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -270,6 +276,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
);
const end = endDragged
? isBindingEnabled
@@ -278,6 +285,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -286,6 +294,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
);
return [start, end];
@@ -296,10 +305,12 @@ const getBindingStrategyForDraggingArrowOrJoints = (
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
isBindingEnabled: boolean,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement,
elementsMap,
zoom,
);
const start = startIsClose
? isBindingEnabled
@@ -308,6 +319,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"start",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -318,6 +330,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"end",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -332,6 +345,7 @@ export const bindOrUnbindLinearElements = (
scene: Scene,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
zoom?: AppState["zoom"],
): void => {
selectedElements.forEach((selectedElement) => {
const [start, end] = draggingPoints?.length
@@ -342,6 +356,7 @@ export const bindOrUnbindLinearElements = (
draggingPoints ?? [],
elementsMap,
elements,
zoom,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
@@ -349,6 +364,7 @@ export const bindOrUnbindLinearElements = (
elementsMap,
elements,
isBindingEnabled,
zoom,
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
@@ -358,6 +374,7 @@ export const bindOrUnbindLinearElements = (
export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) {
@@ -368,7 +385,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements
.filter(isLinearElement)
.flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -406,6 +423,7 @@ export const maybeBindLinearElement = (
pointerCoords,
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement),
);
@@ -422,6 +440,26 @@ export const maybeBindLinearElement = (
}
};
const normalizePointBinding = (
binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement,
) => {
let gap = binding.gap;
const maxGap = maxBindingGap(
hoveredElement,
hoveredElement.width,
hoveredElement.height,
);
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
@@ -433,11 +471,14 @@ export const bindLinearElement = (
}
const binding: PointBinding = {
elementId: hoveredElement.id,
...calculateFocusAndGap(
linearElement,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
startOrEnd,
elementsMap,
),
...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding(
@@ -462,6 +503,12 @@ export const bindLinearElement = (
}),
});
}
// update bound elements to make sure the binding tips are in sync with
// the normalized gap from above
if (!isElbowArrow(linearElement)) {
updateBoundElements(hoveredElement, elementsMap);
}
};
// Don't bind both ends of a simple segment
@@ -514,6 +561,7 @@ export const getHoveredElementForBinding = (
},
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
@@ -524,11 +572,13 @@ export const getHoveredElementForBinding = (
element,
pointerCoords,
elementsMap,
zoom,
// disable fullshape snapping for frame elements so we
// can bind to frame children
fullShape && !isFrameLikeElement(element),
fullShape,
),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -578,9 +628,11 @@ export const updateBoundElements = (
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
},
) => {
const { newSize, simultaneouslyUpdated, changedElements } = options ?? {};
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
@@ -670,6 +722,7 @@ export const updateBoundElements = (
},
{
changedElements,
zoom,
},
);
@@ -703,6 +756,7 @@ export const getHeadingForElbowArrowSnap = (
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -714,6 +768,7 @@ export const getHeadingForElbowArrowSnap = (
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) {
@@ -737,6 +792,7 @@ const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(
bindableElement,
@@ -747,6 +803,7 @@ const getDistanceForBinding = (
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
return distance > bindDistance ? null : distance;
@@ -1174,11 +1231,13 @@ const getElligibleElementForBindingElement = (
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding(
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elements,
elementsMap,
zoom,
);
};
@@ -1341,9 +1400,11 @@ export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): boolean => {
const threshold = maxBindingGap(element, element.width, element.height);
const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape(pointFrom(x, y), shape, threshold) ||
@@ -1356,12 +1417,21 @@ export const maxBindingGap = (
element: ExcalidrawElement,
elementWidth: number,
elementHeight: number,
zoom?: AppState["zoom"],
): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
// We make the bindable boundary bigger for bigger elements
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
return Math.max(
16,
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
);
};
export const distanceToBindableElement = (
+39 -1
View File
@@ -26,7 +26,7 @@ import {
getResizedElementAbsoluteCoords,
} from "./bounds";
const MINIMAL_CROP_SIZE = 10;
export const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
@@ -585,3 +585,41 @@ const adjustCropPosition = (
cropY,
};
};
export const getFlipAdjustedCropPosition = (
element: ExcalidrawImageElement,
natural = false,
) => {
const crop = element.crop;
if (!crop) {
return null;
}
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let cropX = crop.x;
let cropY = crop.y;
if (isFlippedByX) {
cropX = crop.naturalWidth - crop.width - crop.x;
}
if (isFlippedByY) {
cropY = crop.naturalHeight - crop.height - crop.y;
}
if (natural) {
return {
x: cropX,
y: cropY,
};
}
const { width, height } = getUncroppedWidthAndHeight(element);
return {
x: cropX / (crop.naturalWidth / width),
y: cropY / (crop.naturalHeight / height),
};
};
+3 -1
View File
@@ -124,7 +124,9 @@ export const normalizeSVG = (SVGString: string) => {
height = height || "50";
if (viewBox) {
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
const match = viewBox.match(
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
);
if (match) {
[, width, height] = match;
}
@@ -448,6 +448,7 @@ export class LinearElementEditor {
),
elements,
elementsMap,
appState.zoom,
)
: null;
@@ -787,6 +788,7 @@ export class LinearElementEditor {
scenePointer,
elements,
elementsMap,
app.state.zoom,
),
};
@@ -911,6 +913,7 @@ export class LinearElementEditor {
element,
[points.length - 1],
elementsMap,
app.state.zoom,
);
}
return {
@@ -964,6 +967,7 @@ export class LinearElementEditor {
element,
[{ point: newPoint }],
elementsMap,
app.state.zoom,
);
}
return {
@@ -1218,6 +1222,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
let offsetX = 0;
let offsetY = 0;
@@ -1260,6 +1265,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
const offsetX = 0;
const offsetY = 0;
@@ -1285,6 +1291,7 @@ export class LinearElementEditor {
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
const { points } = element;
@@ -1337,6 +1344,7 @@ export class LinearElementEditor {
false,
),
changedElements: options?.changedElements,
zoom: options?.zoom,
},
);
}
@@ -1451,6 +1459,7 @@ export class LinearElementEditor {
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
if (isElbowArrow(element)) {
@@ -1487,6 +1496,7 @@ export class LinearElementEditor {
bindings,
{
isDragging: options?.isDragging,
zoom: options?.zoom,
},
);
} else {
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -14,6 +14,7 @@ import {
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points";
import { aabbForElement, pointInsideBounds } from "../shapes";
import type { AppState } from "../types";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import {
bindPointToSnapToElementOutline,
@@ -79,6 +80,7 @@ export const mutateElbowArrow = (
options?: {
isDragging?: boolean;
informMutation?: boolean;
zoom?: AppState["zoom"];
},
) => {
const update = updateElbowArrow(
@@ -112,6 +114,7 @@ export const updateElbowArrow = (
isDragging?: boolean;
disableBinding?: boolean;
informMutation?: boolean;
zoom?: AppState["zoom"];
},
): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
const origStartGlobalPoint: GlobalPoint = pointTranslate(
@@ -136,7 +139,12 @@ export const updateElbowArrow = (
arrow.endBinding &&
getBindableElementForId(arrow.endBinding.elementId, elementsMap);
const [hoveredStartElement, hoveredEndElement] = options?.isDragging
? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap)
? getHoveredElements(
origStartGlobalPoint,
origEndGlobalPoint,
elementsMap,
options?.zoom,
)
: [startElement, endElement];
const startGlobalPoint = getGlobalPoint(
arrow.startBinding?.fixedPoint,
@@ -1072,6 +1080,7 @@ const getHoveredElements = (
origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom?: AppState["zoom"],
) => {
// TODO: Might be a performance bottleneck and the Map type
// remembers the insertion order anyway...
@@ -1084,12 +1093,14 @@ const getHoveredElements = (
tupleToCoors(origStartGlobalPoint),
elements,
nonDeletedSceneElementsMap,
zoom,
true,
),
getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint),
elements,
nonDeletedSceneElementsMap,
zoom,
true,
),
];
+85 -45
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, POINTER_BUTTON } from "../constants";
import { CLASSES, EVENT, isSafari, POINTER_BUTTON } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -50,6 +50,8 @@ import {
originalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { activeEyeDropperAtom } from "../components/EyeDropper";
import { jotaiStore } from "../jotai";
const getTransform = (
width: number,
@@ -289,6 +291,8 @@ export const textWysiwyg = ({
editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
editable.wrap = "off";
// set &nbsp; placeholder fix for Safari not showing caret for empty textarea
editable.placeholder = "\u00A0";
editable.classList.add("excalidraw-wysiwyg");
let whiteSpace = "pre";
@@ -522,6 +526,7 @@ export const textWysiwyg = ({
// so that we don't need to create separate a callback for event handlers
let submittedViaKeyboard = false;
const handleSubmit = () => {
console.warn("handleSubmit");
// prevent double submit
if (isDestroyed) {
return;
@@ -579,62 +584,96 @@ export const textWysiwyg = ({
});
};
const onBlur = () => {
console.warn("onBlur", document.activeElement);
const isColorPicking = jotaiStore.get(activeEyeDropperAtom);
if (isColorPicking) {
focusEditable(null);
} else if (document.activeElement !== editable) {
handleSubmit();
}
};
const cleanup = () => {
// remove events to ensure they don't late-fire
editable.onblur = null;
editable.oninput = null;
editable.onkeydown = null;
editable.onpointerdown = null;
if (observer) {
observer.disconnect();
}
window.removeEventListener("resize", updateWysiwygStyle);
window.removeEventListener("wheel", stopEvent, true);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
window.removeEventListener(EVENT.RESIZE, updateWysiwygStyle);
window.removeEventListener(EVENT.WHEEL, stopEvent, true);
window.removeEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.BLUR, onBlur);
window.removeEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
unbindUpdate();
unbindOnScroll();
editable.remove();
};
const bindBlurEvent = (event?: MouseEvent) => {
window.removeEventListener("pointerup", bindBlurEvent);
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
const focusEditable = (event: MouseEvent | FocusEvent | null) => {
const target = event?.target;
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
const shouldSkipRefocus =
target &&
// don't steal focus if user is focusing an input such as HEX input
((isWritableElement(target) && document.activeElement !== editable) ||
// refocusing while clicking on popver breaks safari
(isSafari &&
target instanceof HTMLElement &&
target.classList.contains(CLASSES.PROPERTIES_POPOVER_TRIGGER)));
setTimeout(() => {
editable.onblur = handleSubmit;
// case: clicking on the same property → no change → no update → no focus
if (!isPropertiesTrigger) {
editable.focus();
}
});
if (!shouldSkipRefocus) {
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
setTimeout(() => {
// double deferred because on onUpdate/color picker shennanings
setTimeout(() => {
editable.focus();
});
});
}
};
const temporarilyDisableSubmit = () => {
const onPointerUp = (event: PointerEvent | FocusEvent) => {
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.FOCUS, onPointerUp);
// needs to be deferred due to Safari
setTimeout(() => {
editable.onblur = onBlur;
});
focusEditable(event);
};
const disableBlurUntilNextPointerUp = () => {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
window.addEventListener(EVENT.FOCUS, onPointerUp);
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const target = event?.target;
// ugly hack to close popups such as color picker when clicking back
// into the wysiwyg editor (it won't autoclose as blur won't trigger
// since we perpetually keep focus inside the wysiwyg)
if (target === editable && app.state.openPopup) {
app.setState({ openPopup: null });
}
// panning canvas
if (event.button === POINTER_BUTTON.WHEEL) {
// trying to pan by clicking inside text area itself -> handle here
@@ -642,24 +681,18 @@ export const textWysiwyg = ({
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
temporarilyDisableSubmit();
disableBlurUntilNextPointerUp();
return;
}
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
if (
((event.target instanceof HTMLElement ||
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) ||
isPropertiesTrigger
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}, .${CLASSES.PROPERTIES_POPOVER}`,
)
) {
temporarilyDisableSubmit();
disableBlurUntilNextPointerUp();
} else if (
event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
@@ -682,9 +715,11 @@ export const textWysiwyg = ({
const unbindUpdate = app.scene.onUpdate(() => {
updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest(
".properties-content",
CLASSES.PROPERTIES_POPOVER,
);
if (!isPopupOpened) {
// we need to keep this code path for safari (iPadOS) bs reasons
// (also Vitest)
editable.focus();
}
});
@@ -702,8 +737,11 @@ export const textWysiwyg = ({
// because we need it to happen *after* the blur event from `pointerdown`)
editable.select();
}
bindBlurEvent();
focusEditable(null);
setTimeout(() => {
editable.onblur = onBlur;
});
console.log(">>>>>>>>", app.state.editingTextElement);
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
// is preferred so we catch changes from host, where window may not resize.
let observer: ResizeObserver | null = null;
@@ -713,7 +751,7 @@ export const textWysiwyg = ({
});
observer.observe(canvas);
} else {
window.addEventListener("resize", updateWysiwygStyle);
window.addEventListener(EVENT.RESIZE, updateWysiwygStyle);
}
editable.onpointerdown = (event) => event.stopPropagation();
@@ -721,9 +759,11 @@ export const textWysiwyg = ({
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
// triggered the wysiwyg
requestAnimationFrame(() => {
window.addEventListener("pointerdown", onPointerDown, { capture: true });
window.addEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
});
window.addEventListener("beforeunload", handleSubmit);
window.addEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
+2
View File
@@ -157,6 +157,8 @@
"zoomToFit": "Zoom to fit all elements",
"installPWA": "Install Excalidraw locally (PWA)",
"autoResize": "Enable text auto-resizing",
"imageCropping": "Image cropping",
"unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object",
"linkToElement": "Link to object"
},
@@ -43,7 +43,11 @@ import type {
SuggestedBinding,
SuggestedPointBinding,
} from "../element/binding";
import { maxBindingGap } from "../element/binding";
import {
BINDING_HIGHLIGHT_OFFSET,
BINDING_HIGHLIGHT_THICKNESS,
maxBindingGap,
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
bootstrapCanvas,
@@ -217,17 +221,18 @@ const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
const thickness = 10;
// So that we don't overlap the element itself
const strokeOffset = 4;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = thickness - strokeOffset;
const padding = strokeOffset / 2 + thickness / 2;
// When zooming out, make line width greater for visibility
const zoomValue = zoom.value < 1 ? zoom.value : 1;
context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
// To ensure the binding highlight doesn't overlap the element itself
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
const radius = getCornerRadius(
Math.min(element.width, element.height),
@@ -285,6 +290,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
context: CanvasRenderingContext2D,
suggestedBinding: SuggestedPointBinding,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [element, startOrEnd, bindableElement] = suggestedBinding;
@@ -292,6 +298,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
context.strokeStyle = "rgba(0,0,0,0)";
@@ -390,7 +397,7 @@ const renderBindingHighlight = (
context.save();
context.translate(appState.scrollX, appState.scrollY);
renderHighlight(context, suggestedBinding as any, elementsMap);
renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom);
context.restore();
};
@@ -635,7 +635,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
class="color-picker__button active-color properties-trigger"
class="color-picker__button active-color properties-popover-trigger"
data-state="closed"
style="--swatch-color: #ffffff;"
title="Show background color picker"
@@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 99,
"height": 125,
"id": "id166",
"index": "a2",
"isDeleted": false,
@@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.20800",
99,
125,
125,
],
],
"roughness": 1,
@@ -226,9 +226,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 40,
"width": "98.20800",
"x": 1,
"version": 47,
"width": 125,
"x": 0,
"y": 0,
}
`;
@@ -298,7 +298,7 @@ History {
"focus": "0.00990",
"gap": 1,
},
"height": "0.98017",
"height": "0.98000",
"points": [
[
0,
@@ -306,7 +306,7 @@ History {
],
[
98,
"-0.98017",
"-0.98000",
],
],
"startBinding": {
@@ -320,10 +320,10 @@ History {
"endBinding": {
"elementId": "id165",
"fixedPoint": null,
"focus": "-0.02000",
"focus": "-0.02040",
"gap": 1,
},
"height": "0.00169",
"height": "0.02000",
"points": [
[
0,
@@ -331,13 +331,13 @@ History {
],
[
98,
"0.00169",
"0.02000",
],
],
"startBinding": {
"elementId": "id164",
"fixedPoint": null,
"focus": "0.02000",
"focus": "0.01959",
"gap": 1,
},
},
@@ -393,18 +393,20 @@ History {
"focus": 0,
"gap": 1,
},
"height": 99,
"height": 125,
"points": [
[
0,
0,
],
[
"98.20800",
99,
125,
125,
],
],
"startBinding": null,
"width": 125,
"x": 0,
"y": 0,
},
"inserted": {
@@ -414,7 +416,7 @@ History {
"focus": "0.00990",
"gap": 1,
},
"height": "0.98161",
"height": "0.98000",
"points": [
[
0,
@@ -422,7 +424,7 @@ History {
],
[
98,
"-0.98161",
"-0.98000",
],
],
"startBinding": {
@@ -431,7 +433,9 @@ History {
"focus": "0.02970",
"gap": 1,
},
"y": "0.99245",
"width": 98,
"x": 1,
"y": "0.99000",
},
},
"id169" => Delta {
@@ -823,9 +827,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 30,
"width": 0,
"x": 200,
"version": 37,
"width": 100,
"x": 150,
"y": 0,
}
`;
@@ -862,6 +866,8 @@ History {
0,
],
],
"width": 0,
"x": 149,
},
"inserted": {
"points": [
@@ -870,10 +876,12 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
"width": "98.00000",
"x": "1.00000",
},
},
},
@@ -930,6 +938,8 @@ History {
],
],
"startBinding": null,
"width": 100,
"x": 150,
},
"inserted": {
"endBinding": {
@@ -954,6 +964,8 @@ History {
"focus": 0,
"gap": 1,
},
"width": 0,
"x": 149,
},
},
},
@@ -2363,9 +2375,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": 498,
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -2504,7 +2516,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -2523,8 +2535,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -15167,9 +15179,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -15208,7 +15220,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -15221,7 +15233,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -15517,7 +15529,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -15536,8 +15548,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -15866,9 +15878,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -16140,7 +16152,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -16159,8 +16171,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -16489,9 +16501,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -16763,7 +16775,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -16782,8 +16794,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -17110,9 +17122,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -17168,7 +17180,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17186,7 +17198,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17455,7 +17467,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17474,8 +17486,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -17828,9 +17840,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"version": 13,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -17901,7 +17913,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17920,7 +17932,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -18189,7 +18201,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -18208,8 +18220,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -17,6 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
placeholder=" "
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
tabindex="0"
wrap="off"
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"type": "rectangle",
"updated": 1,
"version": 7,
"versionNonce": 745419401,
"versionNonce": 2066753033,
"width": 300,
"x": 201,
"y": 2,
@@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"versionNonce": 1996028265,
"version": 15,
"versionNonce": 271613161,
"width": 81,
"x": 110,
"y": 50,
@@ -186,14 +186,14 @@ describe("Crop an image", () => {
// 50 x 50 square
UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
expect(image.width).toEqual(image.height);
expect(image.width).toBeCloseTo(image.height);
// image is at the corner, not space to its right to expand, should not be able to resize
expect(image.height).toBeCloseTo(50);
UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
expect(image.width).toEqual(image.height);
expect(image.width).toBeCloseTo(image.height);
// max height should be reached
expect(image.height).toEqual(initialHeight);
expect(image.height).toBeCloseTo(initialHeight);
expect(image.width).toBe(initialHeight);
});
});
+19 -4
View File
@@ -38,6 +38,8 @@ import { pointFrom, pointRotateRads } from "../../../math";
import { cropElement } from "../../element/cropElement";
import type { ToolType } from "../../types";
const TEXT_EDITOR_SELECTOR = ".excalidraw-textEditorContainer > textarea";
// so that window.h is available when App.tsx is not imported as well.
createTestHook();
@@ -477,12 +479,20 @@ export class UI {
pointFrom(0, 0),
pointFrom(width, height),
];
UI.clickTool(type);
if (type === "text") {
mouse.reset();
mouse.click(x, y);
const openedEditor =
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
openedEditor?.focus();
} else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => {
mouse.reset();
@@ -518,20 +528,25 @@ export class UI {
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor = await getTextEditor(textEditorSelector);
const editor = await getTextEditor();
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
editor.focus();
fireEvent.input(editor, { target: { value: text } });
act(() => {
editor.blur();
+4 -8
View File
@@ -4785,21 +4785,17 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({
id: arrowId,
points: [
[0, 0],
[100, 0],
],
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
focus: 0,
gap: 1,
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
focus: 0,
gap: 1,
}),
isDeleted: true,
}),
+4 -1
View File
@@ -1,7 +1,10 @@
import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (selector: string, waitForEditor = true) => {
export const getTextEditor = async (
selector = ".excalidraw-textEditorContainer > textarea",
waitForEditor = true,
) => {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));
+148 -5
View File
@@ -18,6 +18,8 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
import { resizeSingleElement } from "../element/resizeElements";
import { getSizeFromPoints } from "../points";
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -235,7 +237,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
};
it("resizes", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "ne", [30, -60]);
@@ -249,7 +251,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
it("flips while resizing", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "sw", [140, -80]);
@@ -263,7 +265,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
it("resizes with locked aspect ratio", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "ne", [30, -60], { shift: true });
@@ -280,7 +282,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
it("resizes from center", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "nw", [-20, -30], { alt: true });
@@ -294,6 +296,147 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
});
describe("line element", () => {
const points: LocalPoint[] = [
pointFrom(0, 0),
pointFrom(60, -20),
pointFrom(20, 40),
pointFrom(-40, 0),
];
it("resizes", async () => {
UI.createElement("line", { points });
const element = h.elements[0] as ExcalidrawLinearElement;
const {
x: prevX,
y: prevY,
width: prevWidth,
height: prevHeight,
} = element;
const nextWidth = prevWidth + 30;
const nextHeight = prevHeight + 30;
resizeSingleElement(
nextWidth,
nextHeight,
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
"ne",
);
expect(element.x).not.toBe(prevX);
expect(element.y).not.toBe(prevY);
expect(element.width).toBe(nextWidth);
expect(element.height).toBe(nextHeight);
expect(element.points[0]).toEqual([0, 0]);
const { width, height } = getSizeFromPoints(element.points);
expect(width).toBe(element.width);
expect(height).toBe(element.height);
});
it("flips while resizing", async () => {
UI.createElement("line", { points });
const element = h.elements[0] as ExcalidrawLinearElement;
const {
width: prevWidth,
height: prevHeight,
points: prevPoints,
} = element;
const nextWidth = prevWidth * -1;
const nextHeight = prevHeight * -1;
resizeSingleElement(
nextWidth,
nextHeight,
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
"se",
);
expect(element.width).toBe(prevWidth);
expect(element.height).toBe(prevHeight);
element.points.forEach((point, idx) => {
expect(point[0]).toBeCloseTo(prevPoints[idx][0] * -1);
expect(point[1]).toBeCloseTo(prevPoints[idx][1] * -1);
});
});
it("resizes with locked aspect ratio", async () => {
UI.createElement("line", { points });
const element = h.elements[0] as ExcalidrawLinearElement;
const { width: prevWidth, height: prevHeight } = element;
UI.resize(element, "ne", [30, -60], { shift: true });
const scaleHeight = element.width / prevWidth;
const scaleWidth = element.height / prevHeight;
expect(scaleHeight).toBeCloseTo(scaleWidth);
});
it("resizes from center", async () => {
UI.createElement("line", {
points: [
pointFrom(0, 0),
pointFrom(338.05644048727373, -180.4761618151104),
pointFrom(338.05644048727373, 180.4761618151104),
pointFrom(-338.05644048727373, 180.4761618151104),
pointFrom(-338.05644048727373, -180.4761618151104),
],
});
const element = h.elements[0] as ExcalidrawLinearElement;
const {
x: prevX,
y: prevY,
width: prevWidth,
height: prevHeight,
} = element;
const prevSmallestX = Math.min(...element.points.map((p) => p[0]));
const prevBiggestX = Math.max(...element.points.map((p) => p[0]));
resizeSingleElement(
prevWidth + 20,
prevHeight,
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
"e",
{
shouldResizeFromCenter: true,
},
);
expect(element.width).toBeCloseTo(prevWidth + 20);
expect(element.height).toBeCloseTo(prevHeight);
expect(element.x).toBeCloseTo(prevX);
expect(element.y).toBeCloseTo(prevY);
const smallestX = Math.min(...element.points.map((p) => p[0]));
const biggestX = Math.max(...element.points.map((p) => p[0]));
expect(prevSmallestX - smallestX).toBeCloseTo(10);
expect(biggestX - prevBiggestX).toBeCloseTo(10);
});
});
describe("arrow element", () => {
it("resizes with a label", async () => {
const arrow = UI.createElement("arrow", {
@@ -983,7 +1126,7 @@ describe("multiple selection", () => {
expect(bottomArrowLabel.fontSize).toBeCloseTo(28 * scale);
});
it("resizes with text elements", async () => {
it.only("resizes with text elements", async () => {
const topText = UI.createElement("text", { position: 0 });
await UI.editText(topText, "lorem ipsum");