Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 0d3966bf79 build(deps): bump nanoid from 3.3.3 to 3.3.8
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.3 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.3...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-10 17:51:17 +00:00
57 changed files with 1324 additions and 2453 deletions
@@ -12,7 +12,7 @@ import { Excalidraw } from "@excalidraw/excalidraw";
Throughout the documentation we use live, editable Excalidraw examples like the one shown below. 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. 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, height: 141.9765625,
},])); },]));
return ( return (
<div style={{height:"500px", width:"500px"}}> <div style={{height:"500px", width:"500px"}}>
<Excalidraw /> <Excalidraw />
</div> </div>
); );
}; };
export default ExcalidrawWrapper; export default ExcalidrawWrapper;
@@ -84,8 +84,8 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers ```jsx showLineNumbers
import dynamic from "next/dynamic"; 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 // the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic( const ExcalidrawWrapper = dynamic(
@@ -97,7 +97,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
export default function Page() { export default function Page() {
return ( return (
<ExcalidrawWrapper /> <ExcalidrawWrapper />
); );
} }
``` ```
@@ -108,7 +108,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers ```jsx showLineNumbers
import dynamic from "next/dynamic"; 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 // the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic( 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"), "process.env.IS_PREACT": JSON.stringify("true"),
}, },
``` ```
::: :::
## Browser ## Browser
@@ -235,5 +235,3 @@ root.render(React.createElement(App));
</TabItem> </TabItem>
</Tabs> </Tabs>
You can try it out [here](https://codesandbox.io/p/sandbox/excalidraw-in-browser-tlqom?file=%2Findex.html%3A1%2C10).
@@ -161,7 +161,6 @@ export const actionDeleteSelected = register({
element, element,
selectedPointsIndices, selectedPointsIndices,
elementsMap, elementsMap,
appState.zoom,
); );
return { return {
+13 -9
View File
@@ -12,6 +12,7 @@ import { resizeMultipleElements } from "../element/resizeElements";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
isBindingEnabled, isBindingEnabled,
@@ -26,7 +27,6 @@ import {
} from "../element/typeChecks"; } from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@@ -132,14 +132,19 @@ const flipElements = (
}); });
} }
const { midX, midY } = getCommonBoundingBox(selectedElements); const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, { resizeMultipleElements(
flipByX: flipDirection === "horizontal", elementsMap,
flipByY: flipDirection === "vertical", selectedElements,
shouldResizeFromCenter: true, elementsMap,
shouldMaintainAspectRatio: true, "nw",
}); true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
@@ -148,7 +153,6 @@ const flipElements = (
app.scene, app.scene,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
appState.zoom,
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -53,9 +53,6 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
ARROW_TYPE, ARROW_TYPE,
@@ -1408,65 +1405,59 @@ const getArrowheadOptions = (flip: boolean) => {
keyBinding: "w", keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />, icon: <ArrowheadArrowIcon flip={flip} />,
}, },
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "e",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "dot",
text: t("labels.arrowhead_circle"),
keyBinding: null,
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "r",
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: null,
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
showInPicker: false,
},
{ {
value: "triangle", value: "triangle",
text: t("labels.arrowhead_triangle"), text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />, icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e", keyBinding: "t",
}, },
{ {
value: "triangle_outline", value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"), text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r", keyBinding: null,
}, showInPicker: false,
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
}, },
{ {
value: "diamond", value: "diamond",
text: t("labels.arrowhead_diamond"), text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />, icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d", keyBinding: null,
showInPicker: false,
}, },
{ {
value: "diamond_outline", value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"), text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f", keyBinding: null,
}, showInPicker: false,
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "c",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "x",
},
{
value: "crowfoot_one_or_many",
text: t("labels.arrowhead_crowfoot_one_or_many"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
keyBinding: "v",
}, },
] as const; ] as const;
}; };
@@ -1530,7 +1521,6 @@ export const actionChangeArrowhead = register({
appState.currentItemStartArrowhead, appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
label="arrowhead_end" label="arrowhead_end"
@@ -1547,7 +1537,6 @@ export const actionChangeArrowhead = register({
appState.currentItemEndArrowhead, appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1602,7 +1591,6 @@ export const actionChangeArrowType = register({
tupleToCoors(startGlobalPoint), tupleToCoors(startGlobalPoint),
elements, elements,
elementsMap, elementsMap,
appState.zoom,
true, true,
); );
const endHoveredElement = const endHoveredElement =
@@ -1611,7 +1599,6 @@ export const actionChangeArrowType = register({
tupleToCoors(endGlobalPoint), tupleToCoors(endGlobalPoint),
elements, elements,
elementsMap, elementsMap,
appState.zoom,
true, true,
); );
const startElement = startHoveredElement const startElement = startHoveredElement
+35 -65
View File
@@ -18,8 +18,6 @@ import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { ExcalidrawError } from "./errors";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -41,7 +39,7 @@ export interface ClipboardData {
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number]; type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEventTextData = type ParsedClipboardEvent =
| { type: "text"; value: string } | { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent }; | { type: "mixedContent"; value: PastedMixedContent };
@@ -77,7 +75,7 @@ export const createPasteEvent = ({
types, types,
files, files,
}: { }: {
types?: { [key in AllowedPasteMimeTypes]?: string | File }; types?: { [key in AllowedPasteMimeTypes]?: string };
files?: File[]; files?: File[];
}) => { }) => {
if (!types && !files) { if (!types && !files) {
@@ -90,11 +88,6 @@ export const createPasteEvent = ({
if (types) { if (types) {
for (const [type, value] of Object.entries(types)) { for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try { try {
event.clipboardData?.setData(type, value); event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) { if (event.clipboardData?.getData(type) !== value) {
@@ -224,14 +217,14 @@ function parseHTMLTree(el: ChildNode) {
const maybeParseHTMLPaste = ( const maybeParseHTMLPaste = (
event: ClipboardEvent, event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData(MIME_TYPES.html); const html = event.clipboardData?.getData("text/html");
if (!html) { if (!html) {
return null; return null;
} }
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, "text/html");
const content = parseHTMLTree(doc.body); const content = parseHTMLTree(doc.body);
@@ -245,44 +238,34 @@ const maybeParseHTMLPaste = (
return null; return null;
}; };
/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
export const readSystemClipboard = async () => { export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {}; 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;
}
}
let clipboardItems: ClipboardItems; let clipboardItems: ClipboardItems;
try { try {
clipboardItems = await navigator.clipboard?.read(); clipboardItems = await navigator.clipboard?.read();
} catch (error: any) { } catch (error: any) {
try { if (error.name === "DataError") {
if (navigator.clipboard?.readText) { console.warn(
console.warn( `navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`, );
); return types;
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; throw error;
} }
@@ -293,20 +276,10 @@ export const readSystemClipboard = async () => {
continue; continue;
} }
try { try {
if (type === MIME_TYPES.text || type === MIME_TYPES.html) { types[type] = await (await item.getType(type)).text();
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) { } catch (error: any) {
console.warn( console.warn(
error instanceof ExcalidrawError `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
); );
} }
} }
@@ -323,10 +296,10 @@ export const readSystemClipboard = async () => {
/** /**
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEvent = async (
event: ClipboardEvent, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEvent> => {
try { try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
@@ -335,7 +308,7 @@ const parseClipboardEventTextData = async (
return { return {
type: "text", type: "text",
value: value:
event.clipboardData?.getData(MIME_TYPES.text) || event.clipboardData?.getData("text/plain") ||
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@@ -346,7 +319,7 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
const text = event.clipboardData?.getData(MIME_TYPES.text); const text = event.clipboardData?.getData("text/plain");
return { type: "text", value: (text || "").trim() }; return { type: "text", value: (text || "").trim() };
} catch { } catch {
@@ -355,16 +328,13 @@ const parseClipboardEventTextData = async (
}; };
/** /**
* Attempts to parse clipboard event. * Attempts to parse clipboard. Prefers system clipboard.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
event,
isPlainPaste,
);
if (parsedEventData.type === "mixedContent") { if (parsedEventData.type === "mixedContent") {
return { return {
@@ -453,8 +423,8 @@ export const copyTextToSystemClipboard = async (
// (2) if fails and we have access to ClipboardEvent, use plain old setData() // (2) if fails and we have access to ClipboardEvent, use plain old setData()
try { try {
if (clipboardEvent) { if (clipboardEvent) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || ""); clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) { if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
throw new Error("Failed to setData on clipboardEvent"); throw new Error("Failed to setData on clipboardEvent");
} }
return; return;
+11 -73
View File
@@ -91,7 +91,6 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_REDUCED_GLOBAL_ALPHA,
isSafari, isSafari,
type EXPORT_IMAGE_TYPES, type EXPORT_IMAGE_TYPES,
DOUBLE_CLICK_POINTERUP_TIMEOUT,
} from "../constants"; } from "../constants";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
@@ -1342,18 +1341,8 @@ class App extends React.Component<AppProps, AppState> {
_cache: new Map(), _cache: new Map(),
}; };
private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
if (frame) {
mutateElement(frame, { name: frame.name?.trim() || null });
}
this.setState({ editingFrame: null });
};
private renderFrameNames = () => { private renderFrameNames = () => {
if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) { if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
if (this.state.editingFrame) {
this.resetEditingFrame(null);
}
return null; return null;
} }
@@ -1375,9 +1364,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
) )
) { ) {
if (this.state.editingFrame === f.id) {
this.resetEditingFrame(f);
}
// if frame not visible, don't render its name // if frame not visible, don't render its name
return null; return null;
} }
@@ -1389,6 +1375,11 @@ class App extends React.Component<AppProps, AppState> {
const FRAME_NAME_EDIT_PADDING = 6; const FRAME_NAME_EDIT_PADDING = 6;
const reset = () => {
mutateElement(f, { name: f.name?.trim() || null });
this.setState({ editingFrame: null });
};
let frameNameJSX; let frameNameJSX;
const frameName = getFrameLikeTitle(f); const frameName = getFrameLikeTitle(f);
@@ -1406,13 +1397,13 @@ class App extends React.Component<AppProps, AppState> {
}); });
}} }}
onFocus={(e) => e.target.select()} onFocus={(e) => e.target.select()}
onBlur={() => this.resetEditingFrame(f)} onBlur={() => reset()}
onKeyDown={(event) => { onKeyDown={(event) => {
// for some inexplicable reason, `onBlur` triggered on ESC // for some inexplicable reason, `onBlur` triggered on ESC
// does not reset `state.editingFrame` despite being called, // does not reset `state.editingFrame` despite being called,
// and we need to reset it here as well // and we need to reset it here as well
if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
this.resetEditingFrame(f); reset();
} }
}} }}
style={{ style={{
@@ -3216,10 +3207,6 @@ class App extends React.Component<AppProps, AppState> {
), ),
), ),
[el.points[0], el.points[el.points.length - 1]], [el.points[0], el.points[el.points.length - 1]],
undefined,
{
zoom: this.state.zoom,
},
), ),
}; };
} }
@@ -4377,7 +4364,6 @@ class App extends React.Component<AppProps, AppState> {
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
zoom: this.state.zoom,
}); });
}); });
@@ -4387,7 +4373,6 @@ class App extends React.Component<AppProps, AppState> {
(element) => element.id !== elbowArrow?.id || step !== 0, (element) => element.id !== elbowArrow?.id || step !== 0,
), ),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom,
), ),
}); });
@@ -4603,7 +4588,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene, this.scene,
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
); );
this.setState({ suggestedBindings: [] }); this.setState({ suggestedBindings: [] });
} }
@@ -5101,11 +5085,7 @@ class App extends React.Component<AppProps, AppState> {
isImageElement(element) ? 0 : this.getElementHitThreshold(), isImageElement(element) ? 0 : this.getElementHitThreshold(),
); );
// if hitting the bounding box, return early return isPointInShape(pointFrom(x, y), selectionShape);
// 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 // take bound text element into consideration for hit collision as well
@@ -5350,14 +5330,6 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasDoubleClick = ( private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>, event: React.MouseEvent<HTMLCanvasElement>,
) => { ) => {
if (
this.lastPointerDownEvent &&
event.timeStamp - this.lastPointerDownEvent.timeStamp >
DOUBLE_CLICK_POINTERUP_TIMEOUT
) {
return;
}
// case: double-clicking with arrow/line tool selected would both create // case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode // text and enter multiElement mode
if (this.state.multiElement) { if (this.state.multiElement) {
@@ -5870,7 +5842,6 @@ class App extends React.Component<AppProps, AppState> {
{ {
isDragging: true, isDragging: true,
informMutation: false, informMutation: false,
zoom: this.state.zoom,
}, },
); );
} else { } else {
@@ -6288,7 +6259,6 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) { if (this.state.searchMatches) {
@@ -7419,7 +7389,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin, pointerDownState.origin,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom,
); );
this.setState({ this.setState({
@@ -7717,7 +7686,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin, pointerDownState.origin,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(element), isElbowArrow(element),
); );
@@ -8296,7 +8264,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBindings: getSuggestedBindingsForArrows( suggestedBindings: getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom,
), ),
}); });
} }
@@ -8465,7 +8432,6 @@ class App extends React.Component<AppProps, AppState> {
{ {
isDragging: true, isDragging: true,
informMutation: false, informMutation: false,
zoom: this.state.zoom,
}, },
); );
} else if (points.length === 2) { } else if (points.length === 2) {
@@ -9430,7 +9396,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene, this.scene,
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
); );
} }
@@ -9923,7 +9888,6 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords, pointerCoords,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom,
); );
this.setState({ this.setState({
suggestedBindings: suggestedBindings:
@@ -9952,7 +9916,6 @@ class App extends React.Component<AppProps, AppState> {
coords, coords,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement), isArrowElement(linearElement) && isElbowArrow(linearElement),
); );
if ( if (
@@ -10393,7 +10356,7 @@ class App extends React.Component<AppProps, AppState> {
const [x, y] = getGridPoint( const [x, y] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x, pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y, pointerCoords.y - pointerDownState.resize.offset.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), this.getEffectiveGridSize(),
); );
const croppingElement = this.scene const croppingElement = this.scene
@@ -10419,28 +10382,6 @@ class App extends React.Component<AppProps, AppState> {
image && image &&
!(image instanceof Promise) !(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( mutateElement(
croppingElement, croppingElement,
cropElement( cropElement(
@@ -10448,8 +10389,8 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType, transformHandleType,
image.naturalWidth, image.naturalWidth,
image.naturalHeight, image.naturalHeight,
x + snapOffset.x, x,
y + snapOffset.y, y,
event.shiftKey event.shiftKey
? croppingAtStateStart.width / croppingAtStateStart.height ? croppingAtStateStart.width / croppingAtStateStart.height
: undefined, : undefined,
@@ -10469,7 +10410,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation", isCropping: transformHandleType && transformHandleType !== "rotation",
snapLines,
}); });
} }
@@ -10580,7 +10520,6 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType, transformHandleType,
selectedElements, selectedElements,
this.scene.getElementsMapIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
this.scene,
shouldRotateWithDiscreteAngle(event), shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event), shouldResizeFromCenter(event),
selectedElements.some((element) => isImageElement(element)) selectedElements.some((element) => isImageElement(element))
@@ -10595,7 +10534,6 @@ class App extends React.Component<AppProps, AppState> {
const suggestedBindings = getSuggestedBindingsForArrows( const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom,
); );
const elementsToHighlight = new Set<ExcalidrawElement>(); const elementsToHighlight = new Set<ExcalidrawElement>();
@@ -15,6 +15,7 @@
top: var(--editor-container-padding); top: var(--editor-container-padding);
right: var(--editor-container-padding); right: var(--editor-container-padding);
bottom: var(--editor-container-padding); bottom: var(--editor-container-padding);
z-index: 2;
} }
.FixedSideContainer_side_top.zen-mode { .FixedSideContainer_side_top.zen-mode {
+47 -13
View File
@@ -1,16 +1,19 @@
@import "../css/variables.module.scss"; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.picker-container {
display: inline-block;
box-sizing: border-box;
margin-right: 0.25rem;
}
.picker { .picker {
padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: var(--shadow-island); // ˇˇ yeah, i dunno, open to suggestions here :D
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
} }
.picker-container button, .picker-container button,
@@ -52,16 +55,47 @@
padding: 0.25rem 0.28rem 0.35rem 0.25rem; padding: 0.25rem 0.28rem 0.35rem 0.25rem;
} }
.picker-content { .picker-triangle {
display: grid; width: 0;
grid-template-columns: repeat(4, auto); height: 0;
grid-gap: 0.5rem; position: relative;
border-radius: 4px; top: -10px;
:root[dir="ltr"] & {
left: 12px;
}
:root[dir="rtl"] & {
right: 12px;
}
z-index: 10;
&:before {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px;
}
&:after {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
}
} }
.picker-collapsible { .picker-content {
font-size: 0.75rem; padding: 0.5rem;
padding: 0.5rem 0; display: grid;
grid-template-columns: repeat(3, auto);
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
padding: 0.4rem;
}
} }
.picker-keybinding { .picker-keybinding {
+98 -131
View File
@@ -1,23 +1,10 @@
import React, { useEffect } from "react"; import React from "react";
import * as Popover from "@radix-ui/react-popover"; import { Popover } from "./Popover";
import "./IconPicker.scss"; import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
import { getLanguage, t } from "../i18n"; import { getLanguage } from "../i18n";
import clsx from "clsx"; import clsx from "clsx";
import Collapsible from "./Stats/Collapsible";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { useDevice } from "..";
const moreOptionsAtom = atom(false);
type Option<T> = {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
};
function Picker<T>({ function Picker<T>({
options, options,
@@ -25,16 +12,30 @@ function Picker<T>({
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly Option<T>[]; options: {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) { }) {
const device = useDevice(); const rFirstItem = React.useRef<HTMLButtonElement>();
const rActiveItem = React.useRef<HTMLButtonElement>();
const rGallery = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// After the component is first mounted focus on first input
if (rActiveItem.current) {
rActiveItem.current.focus();
} else if (rGallery.current) {
rGallery.current.focus();
}
}, []);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = options.find(
@@ -43,19 +44,28 @@ function Picker<T>({
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation // Keybinding navigation
onChange(pressedOption.value); const index = options.indexOf(pressedOption);
(rGallery!.current!.children![index] as any).focus();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } else if (event.key === KEYS.TAB) {
const index = options.findIndex((option) => option.value === value); // Tab navigation cycle through options. If the user tabs
const nextIndex = event.shiftKey // away from the picker, close the picker. We need to use
? (options.length + index - 1) % options.length // a timeout here to let the stack clear before checking.
: (index + 1) % options.length; setTimeout(() => {
onChange(options[nextIndex].value); const active = rActiveItem.current;
const docActive = document.activeElement;
if (active !== docActive) {
onClose();
}
}, 0);
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
// Arrow navigation // Arrow navigation
const { activeElement } = document;
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const index = options.findIndex((option) => option.value === value); const index = Array.prototype.indexOf.call(
rGallery!.current!.children,
activeElement,
);
if (index !== -1) { if (index !== -1) {
const length = options.length; const length = options.length;
let nextIndex = index; let nextIndex = index;
@@ -63,26 +73,19 @@ function Picker<T>({
switch (event.key) { switch (event.key) {
// Select the next option // Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT: case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
case KEYS.ARROW_DOWN: {
nextIndex = (index + 1) % length; nextIndex = (index + 1) % length;
break; break;
}
// Select the previous option // Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT: case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
nextIndex = (length + index - 1) % length;
break;
// Go the next row
case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
// Go the previous row
case KEYS.ARROW_UP: { case KEYS.ARROW_UP: {
nextIndex = nextIndex = (length + index - 1) % length;
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
break; break;
} }
} }
onChange(options[nextIndex].value); (rGallery.current!.children![nextIndex] as any).focus();
} }
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -94,29 +97,15 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
const [showMoreOptions, setShowMoreOptions] = useAtom( return (
moreOptionsAtom, <div
jotaiScope, className={`picker`}
); role="dialog"
aria-modal="true"
const alwaysVisibleOptions = React.useMemo( aria-label={label}
() => options.slice(0, numberOfOptionsToAlwaysShow), onKeyDown={handleKeyDown}
[options, numberOfOptionsToAlwaysShow], >
); <div className="picker-content" ref={rGallery}>
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option, i) => ( {options.map((option, i) => (
<button <button
type="button" type="button"
@@ -124,6 +113,7 @@ function Picker<T>({
active: value === option.value, active: value === option.value,
})} })}
onClick={(event) => { onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text} ${ title={`${option.text} ${
@@ -132,13 +122,16 @@ function Picker<T>({
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
ref={(ref) => { ref={(el) => {
if (value === option.value) { if (el && i === 0) {
// Use a timeout here to render focus properly rFirstItem.current = el;
setTimeout(() => {
ref?.focus();
}, 0);
} }
if (el && option.value === value) {
rActiveItem.current = el;
}
}}
onFocus={() => {
onChange(option.value);
}} }}
> >
{option.icon} {option.icon}
@@ -148,43 +141,7 @@ function Picker<T>({
</button> </button>
))} ))}
</div> </div>
); </div>
};
return (
<Popover.Content
side={
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start"
sideOffset={12}
style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown}
>
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{moreOptions.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
openTrigger={() => {
setShowMoreOptions((value) => !value);
}}
className="picker-collapsible"
>
{renderOptions(moreOptions)}
</Collapsible>
)}
</div>
</Popover.Content>
); );
} }
@@ -194,7 +151,6 @@ export function IconPicker<T>({
options, options,
onChange, onChange,
group = "", group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
@@ -203,40 +159,51 @@ export function IconPicker<T>({
text: string; text: string;
icon: JSX.Element; icon: JSX.Element;
keyBinding: string | null; keyBinding: string | null;
showInPicker?: boolean;
}[]; }[];
onChange: (value: T) => void; onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string; group?: string;
}) { }) {
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null); const rPickerButton = React.useRef<any>(null);
const isRTL = getLanguage().rtl;
return ( return (
<div> <div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}> <button
<Popover.Trigger name={group}
name={group} type="button"
type="button" className={isActive ? "active" : ""}
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton} ref={rPickerButton}
className={isActive ? "active" : ""} >
> {options.find((option) => option.value === value)?.icon}
{options.find((option) => option.value === value)?.icon} </button>
</Popover.Trigger> <React.Suspense fallback="">
{isActive && ( {isActive ? (
<Picker <>
options={options} <Popover
value={value} onCloseRequest={(event) =>
label={label} event.target !== rPickerButton.current && setActive(false)
onChange={onChange} }
onClose={() => { {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
setActive(false); >
}} <Picker
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow} options={options.filter((opt) => opt.showInPicker !== false)}
/> value={value}
)} label={label}
</Popover.Root> onChange={onChange}
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
</div> </div>
); );
} }
@@ -9,7 +9,6 @@ interface CollapsibleProps {
open: boolean; open: boolean;
openTrigger: () => void; openTrigger: () => void;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
const Collapsible = ({ const Collapsible = ({
@@ -17,7 +16,6 @@ const Collapsible = ({
open, open,
openTrigger, openTrigger,
children, children,
className,
}: CollapsibleProps) => { }: CollapsibleProps) => {
return ( return (
<> <>
@@ -28,7 +26,6 @@ const Collapsible = ({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}} }}
className={className}
onClick={openTrigger} onClick={openTrigger}
> >
{label} {label}
@@ -1,18 +1,10 @@
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { resizeSingleElement } from "../../element/resizeElements";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; 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 { interface DimensionDragInputProps {
property: "width" | "height"; property: "width" | "height";
@@ -31,124 +23,20 @@ const handleDimensionChange: DragInputCallbackType<
> = ({ > = ({
accumulatedChange, accumulatedChange,
originalElements, originalElements,
originalElementsMap,
shouldKeepAspectRatio, shouldKeepAspectRatio,
shouldChangeByStepSize, shouldChangeByStepSize,
nextValue, nextValue,
property, property,
originalAppState,
instantChange,
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
const latestElement = elementsMap.get(origElement.id); if (origElement) {
if (origElement && latestElement) {
const keepAspectRatio = const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement); shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height; 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) { if (nextValue !== undefined) {
const nextWidth = Math.max( const nextWidth = Math.max(
property === "width" property === "width"
@@ -167,17 +55,14 @@ const handleDimensionChange: DragInputCallbackType<
MIN_WIDTH_OR_HEIGHT, MIN_WIDTH_OR_HEIGHT,
); );
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{
shouldMaintainAspectRatio: keepAspectRatio,
},
); );
return; return;
@@ -214,17 +99,14 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{
shouldMaintainAspectRatio: keepAspectRatio,
},
); );
} }
}; };
@@ -235,25 +117,9 @@ const DimensionDragInput = ({
scene, scene,
appState, appState,
}: DimensionDragInputProps) => { }: DimensionDragInputProps) => {
let value = round(property === "width" ? element.width : element.height, 2); const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
if ( 100;
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 ( return (
<DragInput <DragInput
@@ -2,10 +2,7 @@ import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element"; import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding"; import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { import { rescalePointsInElement } from "../../element/resizeElements";
rescalePointsInElement,
resizeSingleElement,
} from "../../element/resizeElements";
import { import {
getBoundTextElement, getBoundTextElement,
handleBindTextResize, handleBindTextResize,
@@ -20,7 +17,7 @@ import type { AppState } from "../../types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math"; import { pointFrom, type GlobalPoint } from "../../../math";
@@ -153,6 +150,7 @@ const handleDimensionChange: DragInputCallbackType<
property, property,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState); const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) { if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) { for (const atomicUnit of atomicUnits) {
@@ -225,17 +223,15 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, false,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{ false,
shouldInformMutation: false,
},
); );
} }
} }
@@ -328,17 +324,14 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, false,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{
shouldInformMutation: false,
},
); );
} }
} }
@@ -4,13 +4,7 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { clamp, pointFrom, pointRotateRads, round } from "../../../math"; import { pointFrom, pointRotateRads } from "../../../math";
import { isImageElement } from "../../element/typeChecks";
import {
getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
interface PositionProps { interface PositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -24,14 +18,12 @@ const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange, accumulatedChange,
instantChange,
originalElements, originalElements,
originalElementsMap, originalElementsMap,
shouldChangeByStepSize, shouldChangeByStepSize,
nextValue, nextValue,
property, property,
scene, scene,
originalAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
@@ -46,82 +38,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.angle, 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) { if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX; const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY; const newTopLeftY = property === "y" ? nextValue : topLeftY;
@@ -181,22 +97,8 @@ const Position = ({
pointFrom(element.x + element.width / 2, element.y + element.height / 2), pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle, element.angle,
); );
let value = round(property === "x" ? topLeftX : topLeftY, 2); const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
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 ( return (
<StatsDragInput <StatsDragInput
+3 -39
View File
@@ -23,14 +23,12 @@ import Collapsible from "./Collapsible";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils"; import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants"; import { STATS_PANELS } from "../../constants";
import { isElbowArrow, isImageElement } from "../../element/typeChecks"; import { isElbowArrow } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid"; import CanvasGrid from "./CanvasGrid";
import clsx from "clsx"; import clsx from "clsx";
import "./Stats.scss"; import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping"; import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math";
interface StatsProps { interface StatsProps {
app: AppClassProperties; app: AppClassProperties;
@@ -130,13 +128,6 @@ export const StatsInner = memo(
const multipleElements = const multipleElements =
selectedElements.length > 1 ? selectedElements : null; selectedElements.length > 1 ? selectedElements : null;
const cropMode =
appState.croppingElementId && isImageElement(singleElement);
const unCroppedDimension = cropMode
? getUncroppedWidthAndHeight(singleElement)
: null;
const [sceneDimension, setSceneDimension] = useState<{ const [sceneDimension, setSceneDimension] = useState<{
width: number; width: number;
height: number; height: number;
@@ -253,34 +244,8 @@ export const StatsInner = memo(
<StatsRows> <StatsRows>
{singleElement && ( {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"> <StatsRow heading data-testid="stats-element-type">
{appState.croppingElementId {t(`element.${singleElement.type}`)}
? t("labels.imageCropping")
: t(`element.${singleElement.type}`)}
</StatsRow> </StatsRow>
<StatsRow> <StatsRow>
@@ -422,8 +387,7 @@ export const StatsInner = memo(
prev.selectedElements === next.selectedElements && prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels && prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled && prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep && prev.appState.gridStep === next.appState.gridStep
prev.appState.croppingElementId === next.appState.croppingElementId
); );
}, },
); );
+101 -3
View File
@@ -5,7 +5,17 @@ import {
updateBoundElements, updateBoundElements,
} from "../../element/binding"; } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement"; import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { import {
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
@@ -24,6 +34,7 @@ import {
} from "../../groups"; } from "../../groups";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty = export type StatsInputProperty =
| "x" | "x"
@@ -110,6 +121,95 @@ 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 = ( export const moveElement = (
newTopLeftX: number, newTopLeftX: number,
newTopLeftY: number, newTopLeftY: number,
@@ -200,7 +300,6 @@ export const updateBindings = (
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
}, },
) => { ) => {
if (isLinearElement(latestElement)) { if (isLinearElement(latestElement)) {
@@ -211,7 +310,6 @@ export const updateBindings = (
scene, scene,
true, true,
[], [],
options?.zoom,
); );
} else { } else {
updateBoundElements(latestElement, elementsMap, options); updateBoundElements(latestElement, elementsMap, options);
-48
View File
@@ -1352,54 +1352,6 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
), ),
); );
export const ArrowheadCrowfootIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const FontSizeSmallIcon = createIcon( export const FontSizeSmallIcon = createIcon(
<> <>
<g clipPath="url(#a)"> <g clipPath="url(#a)">
+2 -16
View File
@@ -214,9 +214,9 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = { export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
@@ -230,12 +230,6 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES, ...IMAGE_MIME_TYPES,
} as const; } 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 = { export const EXPORT_IMAGE_TYPES = {
png: "png", png: "png",
svg: "svg", svg: "svg",
@@ -255,14 +249,6 @@ export const EXPORT_SOURCE =
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500; export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; export const TAP_TWICE_TIMEOUT = 300;
/**
* The time the user has from 2nd pointerdown to following pointerup
* before it's not considered a double click.
*
* Helps prevent cases where you double-click by mistake but then drag/keep
* the pointer down for to cancel the double click or do another action.
*/
export const DOUBLE_CLICK_POINTERUP_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 33.519031369643244, "height": 35,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0.5, 0.5,
], ],
[ [
382.47606040672997, 394.5,
34.019031369643244, 34.5,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 381.97606040672997, "width": 395,
"x": 247, "x": 247,
"y": 420, "y": 420,
} }
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0, 0,
], ],
[ [
389.5, 399.5,
0, 0,
], ],
], ],
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 390, "width": 400,
"x": 237, "x": 227,
"y": 450, "y": 450,
} }
`; `;
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 560, "x": 560,
"y": 236.95454545454544, "y": 226.5,
} }
`; `;
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": null, "fixedPoint": null,
"focus": 1.625925925925924, "focus": 0,
"gap": 14, "gap": 205,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 18.278619528619487, "height": 0,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"points": [ "points": [
[ [
0.5, 0.5,
-0.5, 0,
], ],
[ [
357.2037037037038, 99.5,
-17.778619528619487, 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 357.7037037037038, "width": 100,
"x": 171, "x": 255,
"y": 249.45454545454544, "y": 239,
} }
`; `;
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
0, 0,
], ],
[ [
270.98528125, 272.485,
0, 0,
], ],
], ],
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 270.48528125, "width": 272.985,
"x": 112.76171875, "x": 111.262,
"y": 57, "y": 57,
} }
`; `;
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 0, "width": 0,
"x": 83.015625, "x": 77.017,
"y": 81.5, "y": 79,
} }
`; `;
+2 -7
View File
@@ -5,7 +5,6 @@ import { clearElementsForExport } from "../element";
import type { ExcalidrawElement, FileId } from "../element/types"; import type { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors"; import { CanvasError, ImageSceneDataError } from "../errors";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types"; import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils"; import { bytesToHexString, isPromiseLike } from "../utils";
@@ -48,7 +47,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
} }
if (blob.type === MIME_TYPES.svg) { if (blob.type === MIME_TYPES.svg) {
try { try {
return decodeSvgBase64Payload({ return (await import("./image")).decodeSvgMetadata({
svg: contents, svg: contents,
}); });
} catch (error: any) { } catch (error: any) {
@@ -107,15 +106,11 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg"; 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 = ( export const isSupportedImageFile = (
blob: Blob | null | undefined, blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => { ): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {}; const { type } = blob || {};
return isSupportedImageFileType(type); return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
}; };
export const loadSceneOrLibraryFromBlob = async ( export const loadSceneOrLibraryFromBlob = async (
+1 -3
View File
@@ -82,7 +82,6 @@ export const fileSave = (
name: string; name: string;
/** file extension */ /** file extension */
extension: FILE_EXTENSION; extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string; description: string;
/** existing FileSystemHandle */ /** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
@@ -94,11 +93,10 @@ export const fileSave = (
fileName: `${opts.name}.${opts.extension}`, fileName: `${opts.name}.${opts.extension}`,
description: opts.description, description: opts.description,
extensions: [`.${opts.extension}`], extensions: [`.${opts.extension}`],
mimeTypes: opts.mimeTypes,
}, },
opts.fileHandle, opts.fileHandle,
); );
}; };
export { nativeFileSystemSupported };
export type { FileSystemHandle }; export type { FileSystemHandle };
export { nativeFileSystemSupported };
+54 -1
View File
@@ -1,7 +1,7 @@
import decodePng from "png-chunks-extract"; import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text"; import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode"; import encodePng from "png-chunks-encode";
import { encode, decode } from "./encode"; import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob"; import { blobToArrayBuffer } from "./blob";
@@ -67,3 +67,56 @@ export const decodePngMetadata = async (blob: Blob) => {
} }
throw new Error("INVALID"); throw new Error("INVALID");
}; };
// -----------------------------------------------------------------------------
// SVG
// -----------------------------------------------------------------------------
export const encodeSvgMetadata = ({ text }: { text: string }) => {
const base64 = stringToBase64(
JSON.stringify(encode({ text })),
true /* is already byte string */,
);
let metadata = "";
metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
metadata += `<!-- payload-version:2 -->`;
metadata += "<!-- payload-start -->";
metadata += base64;
metadata += "<!-- payload-end -->";
return metadata;
};
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
const version = versionMatch?.[1] || "1";
const isByteString = version !== "1";
try {
const json = base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json;
}
throw new Error("FAILED");
}
return decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};
+5 -6
View File
@@ -5,7 +5,6 @@ import {
import { import {
DEFAULT_EXPORT_PADDING, DEFAULT_EXPORT_PADDING,
DEFAULT_FILENAME, DEFAULT_FILENAME,
IMAGE_MIME_TYPES,
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
} from "../constants"; } from "../constants";
@@ -16,9 +15,8 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { getElementsOverlappingFrame } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected, getSelectedElements } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export"; import { exportToCanvas, exportToSvg } from "../scene/export";
import type { ExportType } from "../scene/types"; import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
@@ -27,6 +25,7 @@ import { canvasToBlob } from "./blob";
import type { FileSystemHandle } from "./filesystem"; import type { FileSystemHandle } from "./filesystem";
import { fileSave } from "./filesystem"; import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json"; import { serializeAsJSON } from "./json";
import { getElementsOverlappingFrame } from "../frame";
export { loadFromBlob } from "./blob"; export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json"; export { loadFromJSON, saveAsJSON } from "./json";
@@ -131,7 +130,6 @@ export const exportCanvas = async (
description: "Export to SVG", description: "Export to SVG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg", extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
mimeTypes: [IMAGE_MIME_TYPES.svg],
fileHandle, fileHandle,
}, },
); );
@@ -170,8 +168,9 @@ export const exportCanvas = async (
return fileSave(blob, { return fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png", // FIXME reintroduce `excalidraw.png` when most people upgrade away
mimeTypes: [IMAGE_MIME_TYPES.png], // from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
+2 -43
View File
@@ -35,9 +35,6 @@ import type { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter"; import { Emitter } from "../emitter";
import { Queue } from "../queue"; import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element"; import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
type LibraryUpdate = { type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */ /** deleted library items since last onLibraryChange event */
@@ -470,28 +467,6 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements; return resElements;
}; };
const validateLibraryUrl = (
libraryUrl: string,
/**
* If supplied, takes precedence over the default whitelist.
* Return `true` if the URL is valid.
*/
validator?: (libraryUrl: string) => boolean,
): boolean => {
if (
validator
? validator(libraryUrl)
: ALLOWED_LIBRARY_HOSTNAMES.includes(
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
)
) {
return true;
}
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
throw new Error("Invalid or disallowed library URL");
};
export const parseLibraryTokensFromUrl = () => { export const parseLibraryTokensFromUrl = () => {
const libraryUrl = const libraryUrl =
// current // current
@@ -633,11 +608,6 @@ const persistLibraryUpdate = async (
export const useHandleLibrary = ( export const useHandleLibrary = (
opts: { opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null; excalidrawAPI: ExcalidrawImperativeAPI | null;
/**
* Return `true` if the library install url should be allowed.
* If not supplied, only the excalidraw.com base domain is allowed.
*/
validateLibraryUrl?: (libraryUrl: string) => boolean;
} & ( } & (
| { | {
/** @deprecated we recommend using `opts.adapter` instead */ /** @deprecated we recommend using `opts.adapter` instead */
@@ -680,13 +650,7 @@ export const useHandleLibrary = (
}) => { }) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => { const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try { try {
libraryUrl = decodeURIComponent(libraryUrl); const request = await fetch(decodeURIComponent(libraryUrl));
libraryUrl = toValidURL(libraryUrl);
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
const request = await fetch(libraryUrl);
const blob = await request.blob(); const blob = await request.blob();
resolve(blob); resolve(blob);
} catch (error: any) { } catch (error: any) {
@@ -714,12 +678,7 @@ export const useHandleLibrary = (
defaultStatus: "published", defaultStatus: "published",
openLibraryMenu: true, openLibraryMenu: true,
}); });
} catch (error: any) { } catch (error) {
excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
throw error; throw error;
} finally { } finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
-1
View File
@@ -639,7 +639,6 @@ export const restoreAppState = (
gridStep: getNormalizedGridStep( gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP, isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
), ),
editingFrame: null,
}; };
}; };
+1 -1
View File
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
elementId: "rect-1", elementId: "rect-1",
fixedPoint: null, fixedPoint: null,
focus: 0, focus: 0,
gap: 14, gap: 205,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {
+1 -2
View File
@@ -25,7 +25,6 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://"); expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]"); expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("&lt;test&gt;"); expect(normalizeLink("<test>")).toBe("<test>");
expect(normalizeLink("test&")).toBe("test&amp;");
}); });
}); });
+4 -1
View File
@@ -1,5 +1,8 @@
import { sanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl } from "@braintree/sanitize-url";
import { sanitizeHTMLAttribute } from "../utils";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
export const normalizeLink = (link: string) => { export const normalizeLink = (link: string) => {
link = link.trim(); link = link.trim();
+10 -81
View File
@@ -97,8 +97,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
}; };
export const FIXED_BINDING_DISTANCE = 5; export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
@@ -215,7 +213,6 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end", edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => { ): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId = const elementId =
@@ -226,7 +223,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const element = elementsMap.get(elementId); const element = elementsMap.get(elementId);
if ( if (
isBindableElement(element) && isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom) bindingBorderTest(element, coors, elementsMap)
) { ) {
return element; return element;
} }
@@ -238,14 +235,12 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => ["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge( getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement, linearElement,
edge as "start" | "end", edge as "start" | "end",
elementsMap, elementsMap,
zoom,
), ),
); );
@@ -255,7 +250,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
draggingPoints: readonly number[], draggingPoints: readonly number[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0; const startIdx = 0;
const endIdx = selectedElement.points.length - 1; const endIdx = selectedElement.points.length - 1;
@@ -268,7 +262,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start", "start",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null // If binding is disabled and start is dragged, break all binds : 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 : // We have to update the focus and gap of the binding, so let's rebind
@@ -277,7 +270,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start", "start",
elementsMap, elementsMap,
elements, elements,
zoom,
); );
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
@@ -286,7 +278,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end", "end",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null // If binding is disabled and end is dragged, break all binds : 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 : // We have to update the focus and gap of the binding, so let's rebind
@@ -295,7 +286,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end", "end",
elementsMap, elementsMap,
elements, elements,
zoom,
); );
return [start, end]; return [start, end];
@@ -306,12 +296,10 @@ const getBindingStrategyForDraggingArrowOrJoints = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
isBindingEnabled: boolean, isBindingEnabled: boolean,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement, selectedElement,
elementsMap, elementsMap,
zoom,
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
@@ -320,7 +308,6 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"start", "start",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null : null
: null; : null;
@@ -331,7 +318,6 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"end", "end",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null : null
: null; : null;
@@ -346,7 +332,6 @@ export const bindOrUnbindLinearElements = (
scene: Scene, scene: Scene,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[] | null, draggingPoints: readonly number[] | null,
zoom?: AppState["zoom"],
): void => { ): void => {
selectedElements.forEach((selectedElement) => { selectedElements.forEach((selectedElement) => {
const [start, end] = draggingPoints?.length const [start, end] = draggingPoints?.length
@@ -357,7 +342,6 @@ export const bindOrUnbindLinearElements = (
draggingPoints ?? [], draggingPoints ?? [],
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: // The arrow itself (the shaft) or the inner joins are dragged : // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints( getBindingStrategyForDraggingArrowOrJoints(
@@ -365,7 +349,6 @@ export const bindOrUnbindLinearElements = (
elementsMap, elementsMap,
elements, elements,
isBindingEnabled, isBindingEnabled,
zoom,
); );
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene); bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
@@ -375,7 +358,6 @@ export const bindOrUnbindLinearElements = (
export const getSuggestedBindingsForArrows = ( export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[], selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => { ): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large // HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) { if (selectedElements.length > 50) {
@@ -386,7 +368,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements selectedElements
.filter(isLinearElement) .filter(isLinearElement)
.flatMap((element) => .flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom), getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
) )
.filter( .filter(
(element): element is NonDeleted<ExcalidrawBindableElement> => (element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -424,7 +406,6 @@ export const maybeBindLinearElement = (
pointerCoords, pointerCoords,
elements, elements,
elementsMap, elementsMap,
appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement) && isElbowArrow(linearElement),
); );
@@ -441,26 +422,6 @@ 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 = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
@@ -472,14 +433,11 @@ export const bindLinearElement = (
} }
const binding: PointBinding = { const binding: PointBinding = {
elementId: hoveredElement.id, elementId: hoveredElement.id,
...normalizePointBinding( ...calculateFocusAndGap(
calculateFocusAndGap( linearElement,
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement, hoveredElement,
startOrEnd,
elementsMap,
), ),
...(isElbowArrow(linearElement) ...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding( ? calculateFixedPointForElbowArrowBinding(
@@ -504,12 +462,6 @@ 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 // Don't bind both ends of a simple segment
@@ -562,7 +514,6 @@ export const getHoveredElementForBinding = (
}, },
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
@@ -573,13 +524,11 @@ export const getHoveredElementForBinding = (
element, element,
pointerCoords, pointerCoords,
elementsMap, elementsMap,
zoom,
// disable fullshape snapping for frame elements so we // disable fullshape snapping for frame elements so we
// can bind to frame children // can bind to frame children
fullShape && !isFrameLikeElement(element), fullShape && !isFrameLikeElement(element),
), ),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
@@ -629,11 +578,9 @@ export const updateBoundElements = (
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
}, },
) => { ) => {
const { newSize, simultaneouslyUpdated, changedElements, zoom } = const { newSize, simultaneouslyUpdated, changedElements } = options ?? {};
options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
@@ -723,7 +670,6 @@ export const updateBoundElements = (
}, },
{ {
changedElements, changedElements,
zoom,
}, },
); );
@@ -757,7 +703,6 @@ export const getHeadingForElbowArrowSnap = (
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
elementsMap: ElementsMap, elementsMap: ElementsMap,
origPoint: GlobalPoint, origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -769,7 +714,6 @@ export const getHeadingForElbowArrowSnap = (
origPoint, origPoint,
bindableElement, bindableElement,
elementsMap, elementsMap,
zoom,
); );
if (!distance) { if (!distance) {
@@ -793,7 +737,6 @@ const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement( const distance = distanceToBindableElement(
bindableElement, bindableElement,
@@ -804,7 +747,6 @@ const getDistanceForBinding = (
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
bindableElement.height, bindableElement.height,
zoom,
); );
return distance > bindDistance ? null : distance; return distance > bindDistance ? null : distance;
@@ -1232,13 +1174,11 @@ const getElligibleElementForBindingElement = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elements, elements,
elementsMap, elementsMap,
zoom,
); );
}; };
@@ -1401,11 +1341,9 @@ export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>, element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number }, { x, y }: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const threshold = maxBindingGap(element, element.width, element.height, zoom); const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap); const shape = getElementShape(element, elementsMap);
return ( return (
isPointOnShape(pointFrom(x, y), shape, threshold) || isPointOnShape(pointFrom(x, y), shape, threshold) ||
@@ -1418,21 +1356,12 @@ export const maxBindingGap = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementWidth: number, elementWidth: number,
elementHeight: number, elementHeight: number,
zoom?: AppState["zoom"],
): number => { ): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles // Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
// We make the bindable boundary bigger for bigger elements
return Math.max( return Math.max(16, Math.min(0.25 * smallerDimension, 32));
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 = ( export const distanceToBindableElement = (
-19
View File
@@ -556,10 +556,6 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
default: default:
return 15; return 15;
} }
@@ -673,21 +669,6 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead); const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(angle),
);
return [xs, ys, x3, y3, x4, y4];
}
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
+1 -39
View File
@@ -26,7 +26,7 @@ import {
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
export const MINIMAL_CROP_SIZE = 10; const MINIMAL_CROP_SIZE = 10;
export const cropElement = ( export const cropElement = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
@@ -585,41 +585,3 @@ const adjustCropPosition = (
cropY, 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),
};
};
+2 -5
View File
@@ -1,11 +1,7 @@
import { register } from "../actions/register"; import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types"; import type { ExcalidrawProps } from "../types";
import { import { getFontString, updateActiveTool } from "../utils";
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@@ -15,6 +11,7 @@ import type {
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
IframeData, IframeData,
} from "./types"; } from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
import type { MarkRequired } from "../utility-types"; import type { MarkRequired } from "../utility-types";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
+1 -3
View File
@@ -124,9 +124,7 @@ export const normalizeSVG = (SVGString: string) => {
height = height || "50"; height = height || "50";
if (viewBox) { if (viewBox) {
const match = viewBox.match( const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
);
if (match) { if (match) {
[, width, height] = match; [, width, height] = match;
} }
@@ -448,7 +448,6 @@ export class LinearElementEditor {
), ),
elements, elements,
elementsMap, elementsMap,
appState.zoom,
) )
: null; : null;
@@ -788,7 +787,6 @@ export class LinearElementEditor {
scenePointer, scenePointer,
elements, elements,
elementsMap, elementsMap,
app.state.zoom,
), ),
}; };
@@ -913,7 +911,6 @@ export class LinearElementEditor {
element, element,
[points.length - 1], [points.length - 1],
elementsMap, elementsMap,
app.state.zoom,
); );
} }
return { return {
@@ -967,7 +964,6 @@ export class LinearElementEditor {
element, element,
[{ point: newPoint }], [{ point: newPoint }],
elementsMap, elementsMap,
app.state.zoom,
); );
} }
return { return {
@@ -1222,7 +1218,6 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[], pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
@@ -1265,7 +1260,6 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[], targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
const offsetX = 0; const offsetX = 0;
const offsetY = 0; const offsetY = 0;
@@ -1291,7 +1285,6 @@ export class LinearElementEditor {
options?: { options?: {
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"];
}, },
) { ) {
const { points } = element; const { points } = element;
@@ -1344,7 +1337,6 @@ export class LinearElementEditor {
false, false,
), ),
changedElements: options?.changedElements, changedElements: options?.changedElements,
zoom: options?.zoom,
}, },
); );
} }
@@ -1459,7 +1451,6 @@ export class LinearElementEditor {
options?: { options?: {
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"];
}, },
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
@@ -1496,7 +1487,6 @@ export class LinearElementEditor {
bindings, bindings,
{ {
isDragging: options?.isDragging, isDragging: options?.isDragging,
zoom: options?.zoom,
}, },
); );
} else { } else {
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -14,7 +14,6 @@ import {
import BinaryHeap from "../binaryheap"; import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { aabbForElement, pointInsideBounds } from "../shapes"; import { aabbForElement, pointInsideBounds } from "../shapes";
import type { AppState } from "../types";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import { import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
@@ -80,7 +79,6 @@ export const mutateElbowArrow = (
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
informMutation?: boolean; informMutation?: boolean;
zoom?: AppState["zoom"];
}, },
) => { ) => {
const update = updateElbowArrow( const update = updateElbowArrow(
@@ -114,7 +112,6 @@ export const updateElbowArrow = (
isDragging?: boolean; isDragging?: boolean;
disableBinding?: boolean; disableBinding?: boolean;
informMutation?: boolean; informMutation?: boolean;
zoom?: AppState["zoom"];
}, },
): ElementUpdate<ExcalidrawElbowArrowElement> | null => { ): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
const origStartGlobalPoint: GlobalPoint = pointTranslate( const origStartGlobalPoint: GlobalPoint = pointTranslate(
@@ -139,12 +136,7 @@ export const updateElbowArrow = (
arrow.endBinding && arrow.endBinding &&
getBindableElementForId(arrow.endBinding.elementId, elementsMap); getBindableElementForId(arrow.endBinding.elementId, elementsMap);
const [hoveredStartElement, hoveredEndElement] = options?.isDragging const [hoveredStartElement, hoveredEndElement] = options?.isDragging
? getHoveredElements( ? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap)
origStartGlobalPoint,
origEndGlobalPoint,
elementsMap,
options?.zoom,
)
: [startElement, endElement]; : [startElement, endElement];
const startGlobalPoint = getGlobalPoint( const startGlobalPoint = getGlobalPoint(
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
@@ -1080,7 +1072,6 @@ const getHoveredElements = (
origStartGlobalPoint: GlobalPoint, origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: GlobalPoint, origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom?: AppState["zoom"],
) => { ) => {
// TODO: Might be a performance bottleneck and the Map type // TODO: Might be a performance bottleneck and the Map type
// remembers the insertion order anyway... // remembers the insertion order anyway...
@@ -1093,14 +1084,12 @@ const getHoveredElements = (
tupleToCoors(origStartGlobalPoint), tupleToCoors(origStartGlobalPoint),
elements, elements,
nonDeletedSceneElementsMap, nonDeletedSceneElementsMap,
zoom,
true, true,
), ),
getHoveredElementForBinding( getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint), tupleToCoors(origEndGlobalPoint),
elements, elements,
nonDeletedSceneElementsMap, nonDeletedSceneElementsMap,
zoom,
true, true,
), ),
]; ];
+1 -4
View File
@@ -303,10 +303,7 @@ export type Arrowhead =
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline" | "diamond_outline";
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
-6
View File
@@ -46,10 +46,6 @@
"arrowhead_triangle_outline": "Triangle (outline)", "arrowhead_triangle_outline": "Triangle (outline)",
"arrowhead_diamond": "Diamond", "arrowhead_diamond": "Diamond",
"arrowhead_diamond_outline": "Diamond (outline)", "arrowhead_diamond_outline": "Diamond (outline)",
"arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)",
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
"more_options": "More options",
"arrowtypes": "Arrow type", "arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow", "arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow", "arrowtype_round": "Curved arrow",
@@ -161,8 +157,6 @@
"zoomToFit": "Zoom to fit all elements", "zoomToFit": "Zoom to fit all elements",
"installPWA": "Install Excalidraw locally (PWA)", "installPWA": "Install Excalidraw locally (PWA)",
"autoResize": "Enable text auto-resizing", "autoResize": "Enable text auto-resizing",
"imageCropping": "Image cropping",
"unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object", "copyElementLink": "Copy link to object",
"linkToElement": "Link to object" "linkToElement": "Link to object"
}, },
+1 -1
View File
@@ -72,7 +72,7 @@
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "1.13.1", "jotai": "1.13.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.3.3", "nanoid": "3.3.8",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.2.0", "perfect-freehand": "1.2.0",
@@ -43,11 +43,7 @@ import type {
SuggestedBinding, SuggestedBinding,
SuggestedPointBinding, SuggestedPointBinding,
} from "../element/binding"; } from "../element/binding";
import { import { maxBindingGap } from "../element/binding";
BINDING_HIGHLIGHT_OFFSET,
BINDING_HIGHLIGHT_THICKNESS,
maxBindingGap,
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
bootstrapCanvas, bootstrapCanvas,
@@ -221,18 +217,17 @@ const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1; const width = x2 - x1;
const height = y2 - y1; 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.strokeStyle = "rgba(0,0,0,.05)";
// When zooming out, make line width greater for visibility context.lineWidth = thickness - strokeOffset;
const zoomValue = zoom.value < 1 ? zoom.value : 1; const padding = strokeOffset / 2 + thickness / 2;
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( const radius = getCornerRadius(
Math.min(element.width, element.height), Math.min(element.width, element.height),
@@ -290,7 +285,6 @@ const renderBindingHighlightForSuggestedPointBinding = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
suggestedBinding: SuggestedPointBinding, suggestedBinding: SuggestedPointBinding,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => { ) => {
const [element, startOrEnd, bindableElement] = suggestedBinding; const [element, startOrEnd, bindableElement] = suggestedBinding;
@@ -298,7 +292,6 @@ const renderBindingHighlightForSuggestedPointBinding = (
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
bindableElement.height, bindableElement.height,
zoom,
); );
context.strokeStyle = "rgba(0,0,0,0)"; context.strokeStyle = "rgba(0,0,0,0)";
@@ -397,7 +390,7 @@ const renderBindingHighlight = (
context.save(); context.save();
context.translate(appState.scrollX, appState.scrollY); context.translate(appState.scrollX, appState.scrollY);
renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom); renderHighlight(context, suggestedBinding as any, elementsMap);
context.restore(); context.restore();
}; };
@@ -449,7 +449,7 @@ const renderElementToSvg = (
symbol.appendChild(image); symbol.appendChild(image);
(root.querySelector("defs") || root).prepend(symbol); root.prepend(symbol);
} }
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
-23
View File
@@ -177,19 +177,6 @@ const getArrowheadShapes = (
return []; return [];
} }
const generateCrowfootOne = (
arrowheadPoints: number[] | null,
options: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
switch (arrowhead) { switch (arrowhead) {
case "dot": case "dot":
case "circle": case "circle":
@@ -268,12 +255,8 @@ const getArrowheadShapes = (
), ),
]; ];
} }
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar": case "bar":
case "arrow": case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: { default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
@@ -289,12 +272,6 @@ const getArrowheadShapes = (
return [ return [
generator.line(x3, y3, x2, y2, options), generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options), generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
]; ];
} }
} }
+53 -163
View File
@@ -18,8 +18,6 @@ import {
SVG_NS, SVG_NS,
THEME, THEME,
THEME_FILTER, THEME_FILTER,
MIME_TYPES,
EXPORT_DATA_TYPES,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
@@ -41,7 +39,8 @@ import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) { if (element.width <= maxWidth) {
@@ -255,13 +254,6 @@ export const exportToCanvas = async (
return canvas; return canvas;
}; };
const createHTMLComment = (text: string) => {
// surrounding with spaces to maintain prettified consistency with previous
// iterations
// <!-- comment -->
return document.createComment(` ${text} `);
};
export const exportToSvg = async ( export const exportToSvg = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: { appState: {
@@ -310,20 +302,31 @@ export const exportToSvg = async (
exportPadding = 0; exportPadding = 0;
} }
let metadata = "";
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) {
try {
metadata = (await import("../data/image")).encodeSvgMetadata({
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
text: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
const [minX, minY, width, height] = getCanvasSize( const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding, exportPadding,
); );
const offsetX = -minX + exportPadding; // initialize SVG root
const offsetY = -minY + exportPadding;
// ---------------------------------------------------------------------------
// initialize SVG root element
// ---------------------------------------------------------------------------
const svgRoot = document.createElementNS(SVG_NS, "svg"); const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1"); svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS); svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`); svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
@@ -333,105 +336,53 @@ export const exportToSvg = async (
svgRoot.setAttribute("filter", THEME_FILTER); svgRoot.setAttribute("filter", THEME_FILTER);
} }
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs"); const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
const metadataElement = svgRoot.ownerDocument.createElementNS(
SVG_NS,
"metadata",
);
svgRoot.appendChild(createHTMLComment("svg-source:excalidraw"));
svgRoot.appendChild(metadataElement);
svgRoot.appendChild(defsElement);
// ---------------------------------------------------------------------------
// scene embed
// ---------------------------------------------------------------------------
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) {
try {
encodeSvgBase64Payload({
metadataElement,
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
payload: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
// ---------------------------------------------------------------------------
// frame clip paths
// ---------------------------------------------------------------------------
const frameElements = getFrameLikeElements(elements); const frameElements = getFrameLikeElements(elements);
if (frameElements.length) { let exportingFrameClipPath = "";
const elementsMap = arrayToMap(elements); const elementsMap = arrayToMap(elements);
for (const frame of frameElements) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1);
const cy = (y2 - y1) / 2 - (frame.y - y1);
for (const frame of frameElements) { exportingFrameClipPath += `<clipPath id=${frame.id}>
const clipPath = svgRoot.ownerDocument.createElementNS( <rect transform="translate(${frame.x + offsetX} ${
SVG_NS, frame.y + offsetY
"clipPath", }) rotate(${frame.angle} ${cx} ${cy})"
); width="${frame.width}"
height="${frame.height}"
clipPath.setAttribute("id", frame.id); ${
exportingFrame
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); ? ""
const cx = (x2 - x1) / 2 - (frame.x - x1); : `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
const cy = (y2 - y1) / 2 - (frame.y - y1); }
>
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); </rect>
rect.setAttribute( </clipPath>`;
"transform",
`translate(${frame.x + offsetX} ${frame.y + offsetY}) rotate(${
frame.angle
} ${cx} ${cy})`,
);
rect.setAttribute("width", `${frame.width}`);
rect.setAttribute("height", `${frame.height}`);
if (!exportingFrame) {
rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
}
clipPath.appendChild(rect);
defsElement.appendChild(clipPath);
}
} }
// ---------------------------------------------------------------------------
// inline font faces
// ---------------------------------------------------------------------------
const fontFaces = !opts?.skipInliningFonts const fontFaces = !opts?.skipInliningFonts
? await Fonts.generateFontFaceDeclarations(elements) ? await Fonts.generateFontFaceDeclarations(elements)
: []; : [];
const delimiter = "\n "; // 6 spaces const delimiter = "\n "; // 6 spaces
const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style"); svgRoot.innerHTML = `
style.classList.add("style-fonts"); ${SVG_EXPORT_TAG}
style.appendChild( ${metadata}
document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`), <defs>
); <style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
</style>
defsElement.appendChild(style); ${exportingFrameClipPath}
</defs>
// --------------------------------------------------------------------------- `;
// background
// ---------------------------------------------------------------------------
// render background rect // render background rect
if (appState.exportBackground && viewBackgroundColor) { if (appState.exportBackground && viewBackgroundColor) {
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0"); rect.setAttribute("x", "0");
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`); rect.setAttribute("width", `${width}`);
@@ -440,10 +391,6 @@ export const exportToSvg = async (
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
// ---------------------------------------------------------------------------
// render elements
// ---------------------------------------------------------------------------
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
const renderEmbeddables = opts?.renderEmbeddables ?? false; const renderEmbeddables = opts?.renderEmbeddables ?? false;
@@ -473,66 +420,9 @@ export const exportToSvg = async (
}, },
); );
// ---------------------------------------------------------------------------
return svgRoot; return svgRoot;
}; };
export const encodeSvgBase64Payload = ({
payload,
metadataElement,
}: {
payload: string;
metadataElement: SVGMetadataElement;
}) => {
const base64 = stringToBase64(
JSON.stringify(encode({ text: payload })),
true /* is already byte string */,
);
metadataElement.appendChild(
createHTMLComment(`payload-type:${MIME_TYPES.excalidraw}`),
);
metadataElement.appendChild(createHTMLComment("payload-version:2"));
metadataElement.appendChild(createHTMLComment("payload-start"));
metadataElement.appendChild(document.createTextNode(base64));
metadataElement.appendChild(createHTMLComment("payload-end"));
};
export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
const version = versionMatch?.[1] || "1";
const isByteString = version !== "1";
try {
const json = base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json;
}
throw new Error("FAILED");
}
return decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};
// calculate smallest area to fit the contents in // calculate smallest area to fit the contents in
const getCanvasSize = ( const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -1006,14 +1006,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 1, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -1042,14 +1042,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 1, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 449462985, "versionNonce": 401146281,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -9792,14 +9792,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 1, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 200, "width": 200,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -9826,14 +9826,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 1, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 449462985, "versionNonce": 401146281,
"width": 200, "width": 200,
"x": 0, "x": 0,
"y": 0, "y": 0,
File diff suppressed because one or more lines are too long
@@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 125, "height": 99,
"id": "id166", "id": "id166",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
125, "98.20800",
125, 99,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -226,9 +226,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 47, "version": 40,
"width": 125, "width": "98.20800",
"x": 0, "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -298,7 +298,7 @@ History {
"focus": "0.00990", "focus": "0.00990",
"gap": 1, "gap": 1,
}, },
"height": "0.98000", "height": "0.98017",
"points": [ "points": [
[ [
0, 0,
@@ -306,7 +306,7 @@ History {
], ],
[ [
98, 98,
"-0.98000", "-0.98017",
], ],
], ],
"startBinding": { "startBinding": {
@@ -320,10 +320,10 @@ History {
"endBinding": { "endBinding": {
"elementId": "id165", "elementId": "id165",
"fixedPoint": null, "fixedPoint": null,
"focus": "-0.02040", "focus": "-0.02000",
"gap": 1, "gap": 1,
}, },
"height": "0.02000", "height": "0.00169",
"points": [ "points": [
[ [
0, 0,
@@ -331,13 +331,13 @@ History {
], ],
[ [
98, 98,
"0.02000", "0.00169",
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id164", "elementId": "id164",
"fixedPoint": null, "fixedPoint": null,
"focus": "0.01959", "focus": "0.02000",
"gap": 1, "gap": 1,
}, },
}, },
@@ -393,20 +393,18 @@ History {
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
"height": 125, "height": 99,
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
125, "98.20800",
125, 99,
], ],
], ],
"startBinding": null, "startBinding": null,
"width": 125,
"x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -416,7 +414,7 @@ History {
"focus": "0.00990", "focus": "0.00990",
"gap": 1, "gap": 1,
}, },
"height": "0.98000", "height": "0.98161",
"points": [ "points": [
[ [
0, 0,
@@ -424,7 +422,7 @@ History {
], ],
[ [
98, 98,
"-0.98000", "-0.98161",
], ],
], ],
"startBinding": { "startBinding": {
@@ -433,9 +431,7 @@ History {
"focus": "0.02970", "focus": "0.02970",
"gap": 1, "gap": 1,
}, },
"width": 98, "y": "0.99245",
"x": 1,
"y": "0.99000",
}, },
}, },
"id169" => Delta { "id169" => Delta {
@@ -827,9 +823,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 37, "version": 30,
"width": 100, "width": 0,
"x": 150, "x": 200,
"y": 0, "y": 0,
} }
`; `;
@@ -866,8 +862,6 @@ History {
0, 0,
], ],
], ],
"width": 0,
"x": 149,
}, },
"inserted": { "inserted": {
"points": [ "points": [
@@ -876,12 +870,10 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
"width": "98.00000",
"x": "1.00000",
}, },
}, },
}, },
@@ -938,8 +930,6 @@ History {
], ],
], ],
"startBinding": null, "startBinding": null,
"width": 100,
"x": 150,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -964,8 +954,6 @@ History {
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
"width": 0,
"x": 149,
}, },
}, },
}, },
@@ -2375,9 +2363,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": 498, "width": 498,
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -2516,7 +2504,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -2535,8 +2523,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -15179,9 +15167,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -15220,7 +15208,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -15233,7 +15221,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -15529,7 +15517,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -15548,8 +15536,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -15878,9 +15866,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -16152,7 +16140,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -16171,8 +16159,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -16501,9 +16489,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -16775,7 +16763,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -16794,8 +16782,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -17122,9 +17110,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -17180,7 +17168,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17198,7 +17186,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17467,7 +17455,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17486,8 +17474,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -17840,9 +17828,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 13, "version": 11,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -17913,7 +17901,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17932,7 +17920,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -18201,7 +18189,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -18220,8 +18208,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 2066753033, "versionNonce": 745419401,
"width": 300, "width": 300,
"x": 201, "x": 201,
"y": 2, "y": 2,
@@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 15, "version": 11,
"versionNonce": 271613161, "versionNonce": 1996028265,
"width": 81, "width": 81,
"x": 110, "x": 110,
"y": 50, "y": 50,
@@ -186,14 +186,14 @@ describe("Crop an image", () => {
// 50 x 50 square // 50 x 50 square
UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]); UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true); UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
expect(image.width).toBeCloseTo(image.height); expect(image.width).toEqual(image.height);
// image is at the corner, not space to its right to expand, should not be able to resize // image is at the corner, not space to its right to expand, should not be able to resize
expect(image.height).toBeCloseTo(50); expect(image.height).toBeCloseTo(50);
UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true); UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
expect(image.width).toBeCloseTo(image.height); expect(image.width).toEqual(image.height);
// max height should be reached // max height should be reached
expect(image.height).toBeCloseTo(initialHeight); expect(image.height).toEqual(initialHeight);
expect(image.width).toBe(initialHeight); expect(image.width).toBe(initialHeight);
}); });
}); });
+9 -27
View File
@@ -2,17 +2,16 @@ import React from "react";
import { render, waitFor } from "./test-utils"; import { render, waitFor } from "./test-utils";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { encodePngMetadata } from "../data/image";
import { serializeAsJSON } from "../data/json";
import { import {
decodeSvgBase64Payload, encodePngMetadata,
encodeSvgBase64Payload, encodeSvgMetadata,
exportToSvg, decodeSvgMetadata,
} from "../scene/export"; } from "../data/image";
import { serializeAsJSON } from "../data/json";
import { exportToSvg } from "../scene/export";
import type { FileId } from "../element/types"; import type { FileId } from "../element/types";
import { getDataURL } from "../data/blob"; import { getDataURL } from "../data/blob";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { SVG_NS } from "../constants";
const { h } = window; const { h } = window;
@@ -63,32 +62,15 @@ describe("export", () => {
}); });
it("test encoding/decoding scene for SVG export", async () => { it("test encoding/decoding scene for SVG export", async () => {
const metadataElement = document.createElementNS(SVG_NS, "metadata"); const encoded = encodeSvgMetadata({
text: serializeAsJSON(testElements, h.state, {}, "local"),
encodeSvgBase64Payload({
metadataElement,
payload: serializeAsJSON(testElements, h.state, {}, "local"),
}); });
const decoded = JSON.parse(decodeSvgMetadata({ svg: encoded }));
const decoded = JSON.parse(
decodeSvgBase64Payload({ svg: metadataElement.innerHTML }),
);
expect(decoded.elements).toEqual([ expect(decoded.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }), expect.objectContaining({ type: "text", text: "😀" }),
]); ]);
}); });
it("export svg-embedded scene", async () => {
const svg = await exportToSvg(
testElements,
{ ...getDefaultAppState(), exportEmbedScene: true },
{},
);
const svgText = svg.outerHTML;
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
});
it("import embedded png (legacy v1)", async () => { it("import embedded png (legacy v1)", async () => {
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png")); await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
await waitFor(() => { await waitFor(() => {
+1 -1
View File
@@ -220,6 +220,7 @@ export class API {
| "width" | "width"
| "height" | "height"
| "type" | "type"
| "seed"
| "version" | "version"
| "versionNonce" | "versionNonce"
| "isDeleted" | "isDeleted"
@@ -227,7 +228,6 @@ export class API {
| "link" | "link"
| "updated" | "updated"
> = { > = {
seed: 1,
x, x,
y, y,
frameId: rest.frameId ?? null, frameId: rest.frameId ?? null,
+8 -4
View File
@@ -4785,17 +4785,21 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, boundElements: [] }), expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
points: [
[0, 0],
[100, 0],
],
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null, fixedPoint: null,
focus: 0, focus: expect.toBeNonNaNNumber(),
gap: 1, gap: expect.toBeNonNaNNumber(),
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null, fixedPoint: null,
focus: 0, focus: expect.toBeNonNaNNumber(),
gap: 1, gap: expect.toBeNonNaNNumber(),
}), }),
isDeleted: true, isDeleted: true,
}), }),
+4 -147
View File
@@ -18,8 +18,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math"; import { pointFrom } from "../../math";
import { resizeSingleElement } from "../element/resizeElements";
import { getSizeFromPoints } from "../points";
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -237,7 +235,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
}; };
it("resizes", async () => { it("resizes", async () => {
const element = UI.createElement("freedraw", { points: points.freedraw }); const element = UI.createElement(type, { points: points[type] });
const bounds = getBoundsFromPoints(element); const bounds = getBoundsFromPoints(element);
UI.resize(element, "ne", [30, -60]); UI.resize(element, "ne", [30, -60]);
@@ -251,7 +249,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
}); });
it("flips while resizing", async () => { it("flips while resizing", async () => {
const element = UI.createElement("freedraw", { points: points.freedraw }); const element = UI.createElement(type, { points: points[type] });
const bounds = getBoundsFromPoints(element); const bounds = getBoundsFromPoints(element);
UI.resize(element, "sw", [140, -80]); UI.resize(element, "sw", [140, -80]);
@@ -265,7 +263,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
}); });
it("resizes with locked aspect ratio", async () => { it("resizes with locked aspect ratio", async () => {
const element = UI.createElement("freedraw", { points: points.freedraw }); const element = UI.createElement(type, { points: points[type] });
const bounds = getBoundsFromPoints(element); const bounds = getBoundsFromPoints(element);
UI.resize(element, "ne", [30, -60], { shift: true }); UI.resize(element, "ne", [30, -60], { shift: true });
@@ -282,7 +280,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
}); });
it("resizes from center", async () => { it("resizes from center", async () => {
const element = UI.createElement("freedraw", { points: points.freedraw }); const element = UI.createElement(type, { points: points[type] });
const bounds = getBoundsFromPoints(element); const bounds = getBoundsFromPoints(element);
UI.resize(element, "nw", [-20, -30], { alt: true }); UI.resize(element, "nw", [-20, -30], { alt: true });
@@ -296,147 +294,6 @@ 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", () => { describe("arrow element", () => {
it("resizes with a label", async () => { it("resizes with a label", async () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
File diff suppressed because one or more lines are too long
+5 -11
View File
@@ -1,19 +1,13 @@
import { isTransparent, sanitizeHTMLAttribute } from "../utils"; import * as utils from "../utils";
describe("Test isTransparent", () => { describe("Test isTransparent", () => {
it("should return true when color is rgb transparent", () => { it("should return true when color is rgb transparent", () => {
expect(isTransparent("#ff00")).toEqual(true); expect(utils.isTransparent("#ff00")).toEqual(true);
expect(isTransparent("#fff00000")).toEqual(true); expect(utils.isTransparent("#fff00000")).toEqual(true);
expect(isTransparent("transparent")).toEqual(true); expect(utils.isTransparent("transparent")).toEqual(true);
}); });
it("should return false when color is not transparent", () => { it("should return false when color is not transparent", () => {
expect(isTransparent("#ced4da")).toEqual(false); expect(utils.isTransparent("#ced4da")).toEqual(false);
});
});
describe("sanitizeHTMLAttribute()", () => {
it("should escape HTML attribute special characters & not double escape", () => {
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&amp;&quot;&#39;&gt;&lt;");
}); });
}); });
-13
View File
@@ -1225,16 +1225,3 @@ export class PromisePool<T> {
}); });
} }
} }
export const sanitizeHTMLAttribute = (html: string) => {
return (
html
// note, if we're not doing stupid things, escaping " is enough,
// but we might end up doing stupid things
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;")
);
};
+2 -3
View File
@@ -1,8 +1,7 @@
import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image";
import type { ImportedDataState } from "../excalidraw/data/types"; import type { ImportedDataState } from "../excalidraw/data/types";
import * as utils from "../utils"; import * as utils from "../utils";
import { API } from "../excalidraw/tests/helpers/api"; import { API } from "../excalidraw/tests/helpers/api";
import { decodeSvgBase64Payload } from "../excalidraw/scene/export";
import { decodePngMetadata } from "../excalidraw/data/image";
// NOTE this test file is using the actual API, unmocked. Hence splitting it // NOTE this test file is using the actual API, unmocked. Hence splitting it
// from the other test file, because I couldn't figure out how to test // from the other test file, because I couldn't figure out how to test
@@ -28,7 +27,7 @@ describe("embedding scene data", () => {
const svg = svgNode.outerHTML; const svg = svgNode.outerHTML;
const parsedString = decodeSvgBase64Payload({ svg }); const parsedString = decodeSvgMetadata({ svg });
const importedData: ImportedDataState = JSON.parse(parsedString); const importedData: ImportedDataState = JSON.parse(parsedString);
expect(sourceElements.map((x) => x.id)).toEqual( expect(sourceElements.map((x) => x.id)).toEqual(
+2 -5
View File
@@ -158,8 +158,5 @@ const createESMRawBuild = async () => {
await buildProd(rawConfigChunks); await buildProd(rawConfigChunks);
}; };
// otherwise throws "ERROR: Could not resolve "./subset-worker.chunk" createESMRawBuild();
(async () => { createESMBrowserBuild();
await createESMRawBuild();
await createESMBrowserBuild();
})();
+4 -9
View File
@@ -8168,21 +8168,16 @@ multimath@^2.0.0:
glur "^1.1.2" glur "^1.1.2"
object-assign "^4.1.1" object-assign "^4.1.1"
nanoid@3.3.3: nanoid@3.3.8, nanoid@^3.3.2, nanoid@^3.3.6, nanoid@^3.3.7:
version "3.3.3" version "3.3.8"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
nanoid@4.0.2: nanoid@4.0.2:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw== integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
nanoid@^3.3.2, nanoid@^3.3.6, nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
nanospinner@^1.1.0: nanospinner@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.1.0.tgz#d17ff621cb1784b0a206b400da88a0ef6db39b97" resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.1.0.tgz#d17ff621cb1784b0a206b400da88a0ef6db39b97"