Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e76cd4230 | ||
|
|
e5a9786af3 | ||
|
|
55be6de5ec | ||
|
|
42faafe138 | ||
|
|
798c795405 | ||
|
|
107eae3916 | ||
|
|
56fca30bd0 | ||
|
|
1e3399eac8 | ||
|
|
873698a1a2 | ||
|
|
606ac6c743 | ||
|
|
d99e4a23ca | ||
|
|
551bae07a7 | ||
|
|
2af3221974 |
+1
-1
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -639,6 +639,7 @@ export const restoreAppState = (
|
||||
gridStep: getNormalizedGridStep(
|
||||
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
|
||||
),
|
||||
editingFrame: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
|
||||
elementId: "rect-1",
|
||||
fixedPoint: null,
|
||||
focus: 0,
|
||||
gap: 205,
|
||||
gap: 14,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user