Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a22927d4d1 | |||
| ca9b7a505e | |||
| 36b387f973 | |||
| 2ac55067cd | |||
| 78ab12c7e6 | |||
| f2f8219917 | |||
| 12c39d1034 | |||
| d33e42e3a1 | |||
| 3b9ffd9586 | |||
| b63689c230 | |||
| c84babf574 | |||
| 36274f1f3e | |||
| 798c795405 | |||
| 107eae3916 | |||
| 56fca30bd0 | |||
| 1e3399eac8 | |||
| 873698a1a2 | |||
| 606ac6c743 | |||
| d99e4a23ca | |||
| 551bae07a7 | |||
| 2af3221974 |
@@ -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,3 +235,5 @@ 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,6 +161,7 @@ export const actionDeleteSelected = register({
|
|||||||
element,
|
element,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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,
|
||||||
@@ -27,6 +26,7 @@ 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,19 +132,14 @@ const flipElements = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { minX, minY, maxX, maxY, midX, midY } =
|
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
||||||
getCommonBoundingBox(selectedElements);
|
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
|
||||||
elementsMap,
|
flipByX: flipDirection === "horizontal",
|
||||||
selectedElements,
|
flipByY: flipDirection === "vertical",
|
||||||
elementsMap,
|
shouldResizeFromCenter: true,
|
||||||
"nw",
|
shouldMaintainAspectRatio: true,
|
||||||
true,
|
});
|
||||||
true,
|
|
||||||
flipDirection === "horizontal" ? maxX : minX,
|
|
||||||
flipDirection === "horizontal" ? minY : maxY,
|
|
||||||
);
|
|
||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
selectedElements.filter(isLinearElement),
|
selectedElements.filter(isLinearElement),
|
||||||
@@ -153,6 +148,7 @@ const flipElements = (
|
|||||||
app.scene,
|
app.scene,
|
||||||
isBindingEnabled(appState),
|
isBindingEnabled(appState),
|
||||||
[],
|
[],
|
||||||
|
appState.zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
sharpArrowIcon,
|
sharpArrowIcon,
|
||||||
roundArrowIcon,
|
roundArrowIcon,
|
||||||
elbowArrowIcon,
|
elbowArrowIcon,
|
||||||
|
ArrowheadCrowfootIcon,
|
||||||
|
ArrowheadCrowfootOneIcon,
|
||||||
|
ArrowheadCrowfootOneOrManyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
ARROW_TYPE,
|
ARROW_TYPE,
|
||||||
@@ -1405,59 +1408,65 @@ 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: "t",
|
keyBinding: "e",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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: null,
|
keyBinding: "r",
|
||||||
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: null,
|
keyBinding: "d",
|
||||||
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: null,
|
keyBinding: "f",
|
||||||
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;
|
||||||
};
|
};
|
||||||
@@ -1521,6 +1530,7 @@ 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"
|
||||||
@@ -1537,6 +1547,7 @@ 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>
|
||||||
@@ -1591,6 +1602,7 @@ export const actionChangeArrowType = register({
|
|||||||
tupleToCoors(startGlobalPoint),
|
tupleToCoors(startGlobalPoint),
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const endHoveredElement =
|
const endHoveredElement =
|
||||||
@@ -1599,6 +1611,7 @@ export const actionChangeArrowType = register({
|
|||||||
tupleToCoors(endGlobalPoint),
|
tupleToCoors(endGlobalPoint),
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const startElement = startHoveredElement
|
const startElement = startHoveredElement
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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;
|
||||||
@@ -39,7 +41,7 @@ export interface ClipboardData {
|
|||||||
|
|
||||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||||
|
|
||||||
type ParsedClipboardEvent =
|
type ParsedClipboardEventTextData =
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
| { type: "mixedContent"; value: PastedMixedContent };
|
| { type: "mixedContent"; value: PastedMixedContent };
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ export const createPasteEvent = ({
|
|||||||
types,
|
types,
|
||||||
files,
|
files,
|
||||||
}: {
|
}: {
|
||||||
types?: { [key in AllowedPasteMimeTypes]?: string };
|
types?: { [key in AllowedPasteMimeTypes]?: string | File };
|
||||||
files?: File[];
|
files?: File[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!types && !files) {
|
if (!types && !files) {
|
||||||
@@ -88,6 +90,11 @@ 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) {
|
||||||
@@ -217,14 +224,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("text/html");
|
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
|
|
||||||
const content = parseHTMLTree(doc.body);
|
const content = parseHTMLTree(doc.body);
|
||||||
|
|
||||||
@@ -238,34 +245,44 @@ 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 } = {};
|
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
|
||||||
|
|
||||||
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) {
|
||||||
if (error.name === "DataError") {
|
try {
|
||||||
console.warn(
|
if (navigator.clipboard?.readText) {
|
||||||
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
console.warn(
|
||||||
);
|
`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;
|
||||||
}
|
}
|
||||||
@@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
types[type] = await (await item.getType(type)).text();
|
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
|
||||||
|
types[type] = await (await item.getType(type)).text();
|
||||||
|
} else if (isSupportedImageFileType(type)) {
|
||||||
|
const imageBlob = await item.getType(type);
|
||||||
|
const file = createFile(imageBlob, type, undefined);
|
||||||
|
types[type] = file;
|
||||||
|
} else {
|
||||||
|
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
error instanceof ExcalidrawError
|
||||||
|
? error.message
|
||||||
|
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
|
|||||||
/**
|
/**
|
||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEvent = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEvent> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||||
|
|
||||||
@@ -308,7 +335,7 @@ const parseClipboardEvent = async (
|
|||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData("text/plain") ||
|
event.clipboardData?.getData(MIME_TYPES.text) ||
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -319,7 +346,7 @@ const parseClipboardEvent = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData("text/plain");
|
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
||||||
|
|
||||||
return { type: "text", value: (text || "").trim() };
|
return { type: "text", value: (text || "").trim() };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -328,13 +355,16 @@ const parseClipboardEvent = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard. Prefers system clipboard.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
|
event,
|
||||||
|
isPlainPaste,
|
||||||
|
);
|
||||||
|
|
||||||
if (parsedEventData.type === "mixedContent") {
|
if (parsedEventData.type === "mixedContent") {
|
||||||
return {
|
return {
|
||||||
@@ -423,8 +453,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("text/plain", text || "");
|
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
|
||||||
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
|
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
|
||||||
throw new Error("Failed to setData on clipboardEvent");
|
throw new Error("Failed to setData on clipboardEvent");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ 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";
|
||||||
@@ -1341,8 +1342,18 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,6 +1375,9 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1375,11 +1389,6 @@ 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);
|
||||||
@@ -1397,13 +1406,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => e.target.select()}
|
||||||
onBlur={() => reset()}
|
onBlur={() => this.resetEditingFrame(f)}
|
||||||
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) {
|
||||||
reset();
|
this.resetEditingFrame(f);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@@ -3207,6 +3216,10 @@ 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,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4364,6 +4377,7 @@ 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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4373,6 +4387,7 @@ 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,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4588,6 +4603,7 @@ 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: [] });
|
||||||
}
|
}
|
||||||
@@ -5085,7 +5101,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
isImageElement(element) ? 0 : this.getElementHitThreshold(),
|
isImageElement(element) ? 0 : this.getElementHitThreshold(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return isPointInShape(pointFrom(x, y), selectionShape);
|
// if hitting the bounding box, return early
|
||||||
|
// but if not, we should check for other cases as well (e.g. frame name)
|
||||||
|
if (isPointInShape(pointFrom(x, y), selectionShape)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// take bound text element into consideration for hit collision as well
|
// take bound text element into consideration for hit collision as well
|
||||||
@@ -5330,6 +5350,14 @@ 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) {
|
||||||
@@ -5842,6 +5870,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
{
|
{
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
informMutation: false,
|
informMutation: false,
|
||||||
|
zoom: this.state.zoom,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -6259,6 +6288,7 @@ 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) {
|
||||||
@@ -7389,6 +7419,7 @@ 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({
|
||||||
@@ -7686,6 +7717,7 @@ 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),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -8264,6 +8296,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
suggestedBindings: getSuggestedBindingsForArrows(
|
suggestedBindings: getSuggestedBindingsForArrows(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
this.state.zoom,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -8432,6 +8465,7 @@ 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) {
|
||||||
@@ -9396,6 +9430,7 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9888,6 +9923,7 @@ 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:
|
||||||
@@ -9916,6 +9952,7 @@ 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 (
|
||||||
@@ -10356,7 +10393,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,
|
||||||
this.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const croppingElement = this.scene
|
const croppingElement = this.scene
|
||||||
@@ -10382,6 +10419,28 @@ 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(
|
||||||
@@ -10389,8 +10448,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
transformHandleType,
|
transformHandleType,
|
||||||
image.naturalWidth,
|
image.naturalWidth,
|
||||||
image.naturalHeight,
|
image.naturalHeight,
|
||||||
x,
|
x + snapOffset.x,
|
||||||
y,
|
y + snapOffset.y,
|
||||||
event.shiftKey
|
event.shiftKey
|
||||||
? croppingAtStateStart.width / croppingAtStateStart.height
|
? croppingAtStateStart.width / croppingAtStateStart.height
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -10410,6 +10469,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
isCropping: transformHandleType && transformHandleType !== "rotation",
|
isCropping: transformHandleType && transformHandleType !== "rotation",
|
||||||
|
snapLines,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10520,6 +10580,7 @@ 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))
|
||||||
@@ -10534,6 +10595,7 @@ 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,7 +15,6 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
@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);
|
||||||
// ˇˇ yeah, i dunno, open to suggestions here :D
|
box-shadow: var(--shadow-island);
|
||||||
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,
|
||||||
@@ -55,47 +52,16 @@
|
|||||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-triangle {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: relative;
|
|
||||||
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-content {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, auto);
|
grid-template-columns: repeat(4, auto);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
}
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
.picker-collapsible {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-keybinding {
|
.picker-keybinding {
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Popover } from "./Popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
import "./IconPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
import { getLanguage } from "../i18n";
|
import { getLanguage, t } 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,
|
||||||
@@ -12,30 +25,16 @@ function Picker<T>({
|
|||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
|
numberOfOptionsToAlwaysShow = options.length,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
options: {
|
options: readonly Option<T>[];
|
||||||
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 rFirstItem = React.useRef<HTMLButtonElement>();
|
const device = useDevice();
|
||||||
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(
|
||||||
@@ -44,28 +43,19 @@ function Picker<T>({
|
|||||||
|
|
||||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||||
// Keybinding navigation
|
// Keybinding navigation
|
||||||
const index = options.indexOf(pressedOption);
|
onChange(pressedOption.value);
|
||||||
(rGallery!.current!.children![index] as any).focus();
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.TAB) {
|
} else if (event.key === KEYS.TAB) {
|
||||||
// Tab navigation cycle through options. If the user tabs
|
const index = options.findIndex((option) => option.value === value);
|
||||||
// away from the picker, close the picker. We need to use
|
const nextIndex = event.shiftKey
|
||||||
// a timeout here to let the stack clear before checking.
|
? (options.length + index - 1) % options.length
|
||||||
setTimeout(() => {
|
: (index + 1) % options.length;
|
||||||
const active = rActiveItem.current;
|
onChange(options[nextIndex].value);
|
||||||
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 = Array.prototype.indexOf.call(
|
const index = options.findIndex((option) => option.value === value);
|
||||||
rGallery!.current!.children,
|
|
||||||
activeElement,
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const length = options.length;
|
const length = options.length;
|
||||||
let nextIndex = index;
|
let nextIndex = index;
|
||||||
@@ -73,19 +63,26 @@ 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:
|
||||||
case KEYS.ARROW_UP: {
|
|
||||||
nextIndex = (length + index - 1) % length;
|
nextIndex = (length + index - 1) % length;
|
||||||
break;
|
break;
|
||||||
|
// Go the next row
|
||||||
|
case KEYS.ARROW_DOWN: {
|
||||||
|
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Go the previous row
|
||||||
|
case KEYS.ARROW_UP: {
|
||||||
|
nextIndex =
|
||||||
|
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(rGallery.current!.children![nextIndex] as any).focus();
|
onChange(options[nextIndex].value);
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
@@ -97,15 +94,29 @@ function Picker<T>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const [showMoreOptions, setShowMoreOptions] = useAtom(
|
||||||
<div
|
moreOptionsAtom,
|
||||||
className={`picker`}
|
jotaiScope,
|
||||||
role="dialog"
|
);
|
||||||
aria-modal="true"
|
|
||||||
aria-label={label}
|
const alwaysVisibleOptions = React.useMemo(
|
||||||
onKeyDown={handleKeyDown}
|
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||||
>
|
[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"
|
||||||
@@ -113,7 +124,6 @@ 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} ${
|
||||||
@@ -122,16 +132,13 @@ 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={(el) => {
|
ref={(ref) => {
|
||||||
if (el && i === 0) {
|
if (value === option.value) {
|
||||||
rFirstItem.current = el;
|
// Use a timeout here to render focus properly
|
||||||
|
setTimeout(() => {
|
||||||
|
ref?.focus();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
if (el && option.value === value) {
|
|
||||||
rActiveItem.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
onChange(option.value);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
@@ -141,7 +148,43 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +194,7 @@ export function IconPicker<T>({
|
|||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
group = "",
|
group = "",
|
||||||
|
numberOfOptionsToAlwaysShow,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
@@ -159,51 +203,40 @@ 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>
|
||||||
<button
|
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||||
name={group}
|
<Popover.Trigger
|
||||||
type="button"
|
name={group}
|
||||||
className={isActive ? "active" : ""}
|
type="button"
|
||||||
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}
|
>
|
||||||
</button>
|
{options.find((option) => option.value === value)?.icon}
|
||||||
<React.Suspense fallback="">
|
</Popover.Trigger>
|
||||||
{isActive ? (
|
{isActive && (
|
||||||
<>
|
<Picker
|
||||||
<Popover
|
options={options}
|
||||||
onCloseRequest={(event) =>
|
value={value}
|
||||||
event.target !== rPickerButton.current && setActive(false)
|
label={label}
|
||||||
}
|
onChange={onChange}
|
||||||
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
onClose={() => {
|
||||||
>
|
setActive(false);
|
||||||
<Picker
|
}}
|
||||||
options={options.filter((opt) => opt.showInPicker !== false)}
|
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||||
value={value}
|
/>
|
||||||
label={label}
|
)}
|
||||||
onChange={onChange}
|
</Popover.Root>
|
||||||
onClose={() => {
|
|
||||||
setActive(false);
|
|
||||||
rPickerButton.current?.focus();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<div className="picker-triangle" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface CollapsibleProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
openTrigger: () => void;
|
openTrigger: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Collapsible = ({
|
const Collapsible = ({
|
||||||
@@ -16,6 +17,7 @@ const Collapsible = ({
|
|||||||
open,
|
open,
|
||||||
openTrigger,
|
openTrigger,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: CollapsibleProps) => {
|
}: CollapsibleProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -26,6 +28,7 @@ const Collapsible = ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
|
className={className}
|
||||||
onClick={openTrigger}
|
onClick={openTrigger}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
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, resizeElement } from "./utils";
|
import { getStepSizedValue, isPropertyEditable } 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";
|
||||||
@@ -23,20 +31,124 @@ 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];
|
||||||
if (origElement) {
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
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"
|
||||||
@@ -55,14 +167,17 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
MIN_WIDTH_OR_HEIGHT,
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
);
|
);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
keepAspectRatio,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
|
{
|
||||||
|
shouldMaintainAspectRatio: keepAspectRatio,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -99,14 +214,17 @@ 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);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
keepAspectRatio,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
|
{
|
||||||
|
shouldMaintainAspectRatio: keepAspectRatio,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -117,9 +235,25 @@ const DimensionDragInput = ({
|
|||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
}: DimensionDragInputProps) => {
|
}: DimensionDragInputProps) => {
|
||||||
const value =
|
let value = round(property === "width" ? element.width : element.height, 2);
|
||||||
Math.round((property === "width" ? element.width : element.height) * 100) /
|
|
||||||
100;
|
if (
|
||||||
|
appState.croppingElementId &&
|
||||||
|
appState.croppingElementId === element.id &&
|
||||||
|
isImageElement(element) &&
|
||||||
|
element.crop
|
||||||
|
) {
|
||||||
|
const { width: uncroppedWidth, height: uncroppedHeight } =
|
||||||
|
getUncroppedWidthAndHeight(element);
|
||||||
|
if (property === "width") {
|
||||||
|
const ratio = uncroppedWidth / element.crop.naturalWidth;
|
||||||
|
value = round(element.crop.width * ratio, 2);
|
||||||
|
}
|
||||||
|
if (property === "height") {
|
||||||
|
const ratio = uncroppedHeight / element.crop.naturalHeight;
|
||||||
|
value = round(element.crop.height * ratio, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragInput
|
<DragInput
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ 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 { rescalePointsInElement } from "../../element/resizeElements";
|
import {
|
||||||
|
rescalePointsInElement,
|
||||||
|
resizeSingleElement,
|
||||||
|
} from "../../element/resizeElements";
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
@@ -17,7 +20,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, resizeElement } from "./utils";
|
import { getElementsInAtomicUnit } 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";
|
||||||
@@ -150,7 +153,6 @@ 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) {
|
||||||
@@ -223,15 +225,17 @@ 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);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
false,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
false,
|
{
|
||||||
|
shouldInformMutation: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,14 +328,17 @@ 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);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
false,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
|
{
|
||||||
|
shouldInformMutation: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ 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 { pointFrom, pointRotateRads } from "../../../math";
|
import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
|
||||||
|
import { isImageElement } from "../../element/typeChecks";
|
||||||
|
import {
|
||||||
|
getFlipAdjustedCropPosition,
|
||||||
|
getUncroppedWidthAndHeight,
|
||||||
|
} from "../../element/cropElement";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
|
||||||
interface PositionProps {
|
interface PositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -18,12 +24,14 @@ 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();
|
||||||
@@ -38,6 +46,82 @@ 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;
|
||||||
@@ -97,8 +181,22 @@ 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,
|
||||||
);
|
);
|
||||||
const value =
|
let value = round(property === "x" ? topLeftX : topLeftY, 2);
|
||||||
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
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ 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 } from "../../element/typeChecks";
|
import { isElbowArrow, isImageElement } 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;
|
||||||
@@ -128,6 +130,13 @@ 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;
|
||||||
@@ -244,8 +253,34 @@ 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">
|
||||||
{t(`element.${singleElement.type}`)}
|
{appState.croppingElementId
|
||||||
|
? t("labels.imageCropping")
|
||||||
|
: t(`element.${singleElement.type}`)}
|
||||||
</StatsRow>
|
</StatsRow>
|
||||||
|
|
||||||
<StatsRow>
|
<StatsRow>
|
||||||
@@ -387,7 +422,8 @@ 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
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,17 +5,7 @@ import {
|
|||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
} from "../../element/binding";
|
} from "../../element/binding";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import {
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
measureFontSizeFromWidth,
|
|
||||||
rescalePointsInElement,
|
|
||||||
} from "../../element/resizeElements";
|
|
||||||
import {
|
|
||||||
getApproxMinLineHeight,
|
|
||||||
getApproxMinLineWidth,
|
|
||||||
getBoundTextElement,
|
|
||||||
getBoundTextMaxWidth,
|
|
||||||
handleBindTextResize,
|
|
||||||
} from "../../element/textElement";
|
|
||||||
import {
|
import {
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@@ -34,7 +24,6 @@ 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"
|
||||||
@@ -121,95 +110,6 @@ export const newOrigin = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resizeElement = (
|
|
||||||
nextWidth: number,
|
|
||||||
nextHeight: number,
|
|
||||||
keepAspectRatio: boolean,
|
|
||||||
origElement: ExcalidrawElement,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
scene: Scene,
|
|
||||||
shouldInformMutation = true,
|
|
||||||
) => {
|
|
||||||
const latestElement = elementsMap.get(origElement.id);
|
|
||||||
if (!latestElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
const minWidth = getApproxMinLineWidth(
|
|
||||||
getFontString(boundTextElement),
|
|
||||||
boundTextElement.lineHeight,
|
|
||||||
);
|
|
||||||
const minHeight = getApproxMinLineHeight(
|
|
||||||
boundTextElement.fontSize,
|
|
||||||
boundTextElement.lineHeight,
|
|
||||||
);
|
|
||||||
nextWidth = Math.max(nextWidth, minWidth);
|
|
||||||
nextHeight = Math.max(nextHeight, minHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
mutateElement(
|
|
||||||
latestElement,
|
|
||||||
{
|
|
||||||
...newOrigin(
|
|
||||||
latestElement.x,
|
|
||||||
latestElement.y,
|
|
||||||
latestElement.width,
|
|
||||||
latestElement.height,
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
latestElement.angle,
|
|
||||||
),
|
|
||||||
width: nextWidth,
|
|
||||||
height: nextHeight,
|
|
||||||
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
|
|
||||||
},
|
|
||||||
shouldInformMutation,
|
|
||||||
);
|
|
||||||
updateBindings(latestElement, elementsMap, elements, scene, {
|
|
||||||
newSize: {
|
|
||||||
width: nextWidth,
|
|
||||||
height: nextHeight,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
boundTextFont = {
|
|
||||||
fontSize: boundTextElement.fontSize,
|
|
||||||
};
|
|
||||||
if (keepAspectRatio) {
|
|
||||||
const updatedElement = {
|
|
||||||
...latestElement,
|
|
||||||
width: nextWidth,
|
|
||||||
height: nextHeight,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextFont = measureFontSizeFromWidth(
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
getBoundTextMaxWidth(updatedElement, boundTextElement),
|
|
||||||
);
|
|
||||||
boundTextFont = {
|
|
||||||
fontSize: nextFont?.size ?? boundTextElement.fontSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBoundElements(latestElement, elementsMap, {
|
|
||||||
newSize: { width: nextWidth, height: nextHeight },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont) {
|
|
||||||
mutateElement(boundTextElement, {
|
|
||||||
fontSize: boundTextFont.fontSize,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const moveElement = (
|
export const moveElement = (
|
||||||
newTopLeftX: number,
|
newTopLeftX: number,
|
||||||
newTopLeftY: number,
|
newTopLeftY: number,
|
||||||
@@ -300,6 +200,7 @@ 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)) {
|
||||||
@@ -310,6 +211,7 @@ export const updateBindings = (
|
|||||||
scene,
|
scene,
|
||||||
true,
|
true,
|
||||||
[],
|
[],
|
||||||
|
options?.zoom,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updateBoundElements(latestElement, elementsMap, options);
|
updateBoundElements(latestElement, elementsMap, options);
|
||||||
|
|||||||
@@ -1352,6 +1352,54 @@ 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)">
|
||||||
|
|||||||
@@ -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,6 +230,12 @@ 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",
|
||||||
@@ -249,6 +255,14 @@ 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": 35,
|
"height": 33.519031369643244,
|
||||||
"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,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
394.5,
|
382.47606040672997,
|
||||||
34.5,
|
34.019031369643244,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"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": 4,
|
"version": 7,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 395,
|
"width": 381.97606040672997,
|
||||||
"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,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
399.5,
|
389.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": 4,
|
"version": 6,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 400,
|
"width": 390,
|
||||||
"x": 227,
|
"x": 237,
|
||||||
"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": 226.5,
|
"y": 236.95454545454544,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -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": 0,
|
"focus": 1.625925925925924,
|
||||||
"gap": 205,
|
"gap": 14,
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 18.278619528619487,
|
||||||
"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,
|
-0.5,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
99.5,
|
357.2037037037038,
|
||||||
0,
|
-17.778619528619487,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"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": 4,
|
"version": 6,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 357.7037037037038,
|
||||||
"x": 255,
|
"x": 171,
|
||||||
"y": 239,
|
"y": 249.45454545454544,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -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": 4,
|
"version": 6,
|
||||||
"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": 4,
|
"version": 6,
|
||||||
"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,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
272.485,
|
270.98528125,
|
||||||
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": 4,
|
"version": 7,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 272.985,
|
"width": 270.48528125,
|
||||||
"x": 111.262,
|
"x": 112.76171875,
|
||||||
"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": 4,
|
"version": 6,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 0,
|
"width": 0,
|
||||||
"x": 77.017,
|
"x": 83.015625,
|
||||||
"y": 79,
|
"y": 81.5,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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";
|
||||||
@@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
|||||||
}
|
}
|
||||||
if (blob.type === MIME_TYPES.svg) {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return (await import("./image")).decodeSvgMetadata({
|
return decodeSvgBase64Payload({
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -106,11 +107,15 @@ 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 !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
return isSupportedImageFileType(type);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSceneOrLibraryFromBlob = async (
|
export const loadSceneOrLibraryFromBlob = async (
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ 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;
|
||||||
@@ -93,10 +94,11 @@ 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 type { FileSystemHandle };
|
|
||||||
export { nativeFileSystemSupported };
|
export { nativeFileSystemSupported };
|
||||||
|
export type { FileSystemHandle };
|
||||||
|
|||||||
@@ -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 { stringToBase64, encode, decode, base64ToString } from "./encode";
|
import { encode, decode } 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,56 +67,3 @@ 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 +5,7 @@ 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";
|
||||||
@@ -15,8 +16,9 @@ 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 { isSomeElementSelected, getSelectedElements } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } 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";
|
||||||
@@ -25,7 +27,6 @@ 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";
|
||||||
@@ -130,6 +131,7 @@ 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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -168,9 +170,8 @@ export const exportCanvas = async (
|
|||||||
return fileSave(blob, {
|
return fileSave(blob, {
|
||||||
description: "Export to PNG",
|
description: "Export to PNG",
|
||||||
name,
|
name,
|
||||||
// FIXME reintroduce `excalidraw.png` when most people upgrade away
|
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||||
// from 111.0.5563.64 (arm64), see #6349
|
mimeTypes: [IMAGE_MIME_TYPES.png],
|
||||||
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
|
|
||||||
fileHandle,
|
fileHandle,
|
||||||
});
|
});
|
||||||
} else if (type === "clipboard") {
|
} else if (type === "clipboard") {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ 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 */
|
||||||
@@ -467,6 +470,28 @@ 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
|
||||||
@@ -608,6 +633,11 @@ 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 */
|
||||||
@@ -650,7 +680,13 @@ export const useHandleLibrary = (
|
|||||||
}) => {
|
}) => {
|
||||||
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
libraryUrl = 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) {
|
||||||
@@ -678,7 +714,12 @@ export const useHandleLibrary = (
|
|||||||
defaultStatus: "published",
|
defaultStatus: "published",
|
||||||
openLibraryMenu: true,
|
openLibraryMenu: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
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)) {
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ 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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
|
|||||||
elementId: "rect-1",
|
elementId: "rect-1",
|
||||||
fixedPoint: null,
|
fixedPoint: null,
|
||||||
focus: 0,
|
focus: 0,
|
||||||
gap: 205,
|
gap: 14,
|
||||||
});
|
});
|
||||||
expect(rect.boundElements).toStrictEqual([
|
expect(rect.boundElements).toStrictEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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("<test>");
|
expect(normalizeLink("<test>")).toBe("<test>");
|
||||||
|
expect(normalizeLink("test&")).toBe("test&");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
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, """);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeLink = (link: string) => {
|
export const normalizeLink = (link: string) => {
|
||||||
link = link.trim();
|
link = link.trim();
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ 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,
|
||||||
@@ -213,6 +215,7 @@ 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 =
|
||||||
@@ -223,7 +226,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||||||
const element = elementsMap.get(elementId);
|
const element = elementsMap.get(elementId);
|
||||||
if (
|
if (
|
||||||
isBindableElement(element) &&
|
isBindableElement(element) &&
|
||||||
bindingBorderTest(element, coors, elementsMap)
|
bindingBorderTest(element, coors, elementsMap, zoom)
|
||||||
) {
|
) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -235,12 +238,14 @@ 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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -250,6 +255,7 @@ 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;
|
||||||
@@ -262,6 +268,7 @@ 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
|
||||||
@@ -270,6 +277,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
const end = endDragged
|
const end = endDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
@@ -278,6 +286,7 @@ 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
|
||||||
@@ -286,6 +295,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [start, end];
|
return [start, end];
|
||||||
@@ -296,10 +306,12 @@ 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
|
||||||
@@ -308,6 +320,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
@@ -318,6 +331,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
@@ -332,6 +346,7 @@ 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
|
||||||
@@ -342,6 +357,7 @@ 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(
|
||||||
@@ -349,6 +365,7 @@ export const bindOrUnbindLinearElements = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
|
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
|
||||||
@@ -358,6 +375,7 @@ 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) {
|
||||||
@@ -368,7 +386,7 @@ export const getSuggestedBindingsForArrows = (
|
|||||||
selectedElements
|
selectedElements
|
||||||
.filter(isLinearElement)
|
.filter(isLinearElement)
|
||||||
.flatMap((element) =>
|
.flatMap((element) =>
|
||||||
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
|
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||||
@@ -406,6 +424,7 @@ export const maybeBindLinearElement = (
|
|||||||
pointerCoords,
|
pointerCoords,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -422,6 +441,26 @@ export const maybeBindLinearElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePointBinding = (
|
||||||
|
binding: { focus: number; gap: number },
|
||||||
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
|
) => {
|
||||||
|
let gap = binding.gap;
|
||||||
|
const maxGap = maxBindingGap(
|
||||||
|
hoveredElement,
|
||||||
|
hoveredElement.width,
|
||||||
|
hoveredElement.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gap > maxGap) {
|
||||||
|
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...binding,
|
||||||
|
gap,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const bindLinearElement = (
|
export const bindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
@@ -433,11 +472,14 @@ export const bindLinearElement = (
|
|||||||
}
|
}
|
||||||
const binding: PointBinding = {
|
const binding: PointBinding = {
|
||||||
elementId: hoveredElement.id,
|
elementId: hoveredElement.id,
|
||||||
...calculateFocusAndGap(
|
...normalizePointBinding(
|
||||||
linearElement,
|
calculateFocusAndGap(
|
||||||
|
linearElement,
|
||||||
|
hoveredElement,
|
||||||
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
startOrEnd,
|
|
||||||
elementsMap,
|
|
||||||
),
|
),
|
||||||
...(isElbowArrow(linearElement)
|
...(isElbowArrow(linearElement)
|
||||||
? calculateFixedPointForElbowArrowBinding(
|
? calculateFixedPointForElbowArrowBinding(
|
||||||
@@ -462,6 +504,12 @@ export const bindLinearElement = (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update bound elements to make sure the binding tips are in sync with
|
||||||
|
// the normalized gap from above
|
||||||
|
if (!isElbowArrow(linearElement)) {
|
||||||
|
updateBoundElements(hoveredElement, elementsMap);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't bind both ends of a simple segment
|
// Don't bind both ends of a simple segment
|
||||||
@@ -514,6 +562,7 @@ 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(
|
||||||
@@ -524,11 +573,13 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -578,9 +629,11 @@ 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 } = options ?? {};
|
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
|
||||||
|
options ?? {};
|
||||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
simultaneouslyUpdated,
|
simultaneouslyUpdated,
|
||||||
);
|
);
|
||||||
@@ -670,6 +723,7 @@ export const updateBoundElements = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
changedElements,
|
changedElements,
|
||||||
|
zoom,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -703,6 +757,7 @@ 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));
|
||||||
|
|
||||||
@@ -714,6 +769,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
origPoint,
|
origPoint,
|
||||||
bindableElement,
|
bindableElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!distance) {
|
if (!distance) {
|
||||||
@@ -737,6 +793,7 @@ 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,
|
||||||
@@ -747,6 +804,7 @@ const getDistanceForBinding = (
|
|||||||
bindableElement,
|
bindableElement,
|
||||||
bindableElement.width,
|
bindableElement.width,
|
||||||
bindableElement.height,
|
bindableElement.height,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
return distance > bindDistance ? null : distance;
|
return distance > bindDistance ? null : distance;
|
||||||
@@ -1174,11 +1232,13 @@ 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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1341,9 +1401,11 @@ 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);
|
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||||
|
|
||||||
const shape = getElementShape(element, elementsMap);
|
const shape = getElementShape(element, elementsMap);
|
||||||
return (
|
return (
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||||
@@ -1356,12 +1418,21 @@ 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(16, Math.min(0.25 * smallerDimension, 32));
|
return Math.max(
|
||||||
|
16,
|
||||||
|
// bigger bindable boundary for bigger elements
|
||||||
|
Math.min(0.25 * smallerDimension, 32),
|
||||||
|
// keep in sync with the zoomed highlight
|
||||||
|
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distanceToBindableElement = (
|
export const distanceToBindableElement = (
|
||||||
|
|||||||
@@ -556,6 +556,10 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -669,6 +673,21 @@ 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),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
|
|
||||||
const MINIMAL_CROP_SIZE = 10;
|
export const MINIMAL_CROP_SIZE = 10;
|
||||||
|
|
||||||
export const cropElement = (
|
export const cropElement = (
|
||||||
element: ExcalidrawImageElement,
|
element: ExcalidrawImageElement,
|
||||||
@@ -585,3 +585,41 @@ 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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 { getFontString, updateActiveTool } from "../utils";
|
import {
|
||||||
|
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";
|
||||||
@@ -11,7 +15,6 @@ 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";
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ export const normalizeSVG = (SVGString: string) => {
|
|||||||
height = height || "50";
|
height = height || "50";
|
||||||
|
|
||||||
if (viewBox) {
|
if (viewBox) {
|
||||||
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
|
const match = viewBox.match(
|
||||||
|
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
|
||||||
|
);
|
||||||
if (match) {
|
if (match) {
|
||||||
[, width, height] = match;
|
[, width, height] = match;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ export class LinearElementEditor {
|
|||||||
),
|
),
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -787,6 +788,7 @@ export class LinearElementEditor {
|
|||||||
scenePointer,
|
scenePointer,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
app.state.zoom,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -911,6 +913,7 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
[points.length - 1],
|
[points.length - 1],
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
app.state.zoom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -964,6 +967,7 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
[{ point: newPoint }],
|
[{ point: newPoint }],
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
app.state.zoom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1218,6 +1222,7 @@ 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;
|
||||||
@@ -1260,6 +1265,7 @@ 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;
|
||||||
@@ -1285,6 +1291,7 @@ 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;
|
||||||
@@ -1337,6 +1344,7 @@ export class LinearElementEditor {
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
changedElements: options?.changedElements,
|
changedElements: options?.changedElements,
|
||||||
|
zoom: options?.zoom,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1451,6 +1459,7 @@ 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)) {
|
||||||
@@ -1487,6 +1496,7 @@ 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
@@ -14,6 +14,7 @@ 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,
|
||||||
@@ -79,6 +80,7 @@ export const mutateElbowArrow = (
|
|||||||
options?: {
|
options?: {
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
informMutation?: boolean;
|
informMutation?: boolean;
|
||||||
|
zoom?: AppState["zoom"];
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const update = updateElbowArrow(
|
const update = updateElbowArrow(
|
||||||
@@ -112,6 +114,7 @@ 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(
|
||||||
@@ -136,7 +139,12 @@ 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(origStartGlobalPoint, origEndGlobalPoint, elementsMap)
|
? getHoveredElements(
|
||||||
|
origStartGlobalPoint,
|
||||||
|
origEndGlobalPoint,
|
||||||
|
elementsMap,
|
||||||
|
options?.zoom,
|
||||||
|
)
|
||||||
: [startElement, endElement];
|
: [startElement, endElement];
|
||||||
const startGlobalPoint = getGlobalPoint(
|
const startGlobalPoint = getGlobalPoint(
|
||||||
arrow.startBinding?.fixedPoint,
|
arrow.startBinding?.fixedPoint,
|
||||||
@@ -1072,6 +1080,7 @@ 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...
|
||||||
@@ -1084,12 +1093,14 @@ 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,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -303,7 +303,10 @@ 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<{
|
||||||
|
|||||||
@@ -46,6 +46,10 @@
|
|||||||
"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",
|
||||||
@@ -157,6 +161,8 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ import type {
|
|||||||
SuggestedBinding,
|
SuggestedBinding,
|
||||||
SuggestedPointBinding,
|
SuggestedPointBinding,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { maxBindingGap } from "../element/binding";
|
import {
|
||||||
|
BINDING_HIGHLIGHT_OFFSET,
|
||||||
|
BINDING_HIGHLIGHT_THICKNESS,
|
||||||
|
maxBindingGap,
|
||||||
|
} from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
bootstrapCanvas,
|
bootstrapCanvas,
|
||||||
@@ -217,17 +221,18 @@ 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)";
|
||||||
context.lineWidth = thickness - strokeOffset;
|
// When zooming out, make line width greater for visibility
|
||||||
const padding = strokeOffset / 2 + thickness / 2;
|
const zoomValue = zoom.value < 1 ? zoom.value : 1;
|
||||||
|
context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
|
||||||
|
// To ensure the binding highlight doesn't overlap the element itself
|
||||||
|
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
|
||||||
|
|
||||||
const radius = getCornerRadius(
|
const radius = getCornerRadius(
|
||||||
Math.min(element.width, element.height),
|
Math.min(element.width, element.height),
|
||||||
@@ -285,6 +290,7 @@ 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;
|
||||||
|
|
||||||
@@ -292,6 +298,7 @@ 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)";
|
||||||
@@ -390,7 +397,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);
|
renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom);
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ const renderElementToSvg = (
|
|||||||
|
|
||||||
symbol.appendChild(image);
|
symbol.appendChild(image);
|
||||||
|
|
||||||
root.prepend(symbol);
|
(root.querySelector("defs") || root).prepend(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
||||||
|
|||||||
@@ -177,6 +177,19 @@ 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":
|
||||||
@@ -255,8 +268,12 @@ 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;
|
||||||
|
|
||||||
@@ -272,6 +289,12 @@ 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,
|
||||||
|
)
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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";
|
||||||
@@ -39,8 +41,7 @@ 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) {
|
||||||
@@ -254,6 +255,13 @@ 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: {
|
||||||
@@ -302,31 +310,20 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
// initialize SVG root
|
const offsetX = -minX + exportPadding;
|
||||||
|
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}`);
|
||||||
@@ -336,53 +333,105 @@ export const exportToSvg = async (
|
|||||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
svgRoot.setAttribute("filter", THEME_FILTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offsetX = -minX + exportPadding;
|
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
|
||||||
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);
|
||||||
|
|
||||||
let exportingFrameClipPath = "";
|
if (frameElements.length) {
|
||||||
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);
|
|
||||||
|
|
||||||
exportingFrameClipPath += `<clipPath id=${frame.id}>
|
for (const frame of frameElements) {
|
||||||
<rect transform="translate(${frame.x + offsetX} ${
|
const clipPath = svgRoot.ownerDocument.createElementNS(
|
||||||
frame.y + offsetY
|
SVG_NS,
|
||||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
"clipPath",
|
||||||
width="${frame.width}"
|
);
|
||||||
height="${frame.height}"
|
|
||||||
${
|
clipPath.setAttribute("id", frame.id);
|
||||||
exportingFrame
|
|
||||||
? ""
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||||
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
||||||
}
|
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
||||||
>
|
|
||||||
</rect>
|
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
||||||
</clipPath>`;
|
rect.setAttribute(
|
||||||
|
"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
|
||||||
|
|
||||||
svgRoot.innerHTML = `
|
const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
|
||||||
${SVG_EXPORT_TAG}
|
style.classList.add("style-fonts");
|
||||||
${metadata}
|
style.appendChild(
|
||||||
<defs>
|
document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
|
||||||
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
|
);
|
||||||
</style>
|
|
||||||
${exportingFrameClipPath}
|
defsElement.appendChild(style);
|
||||||
</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}`);
|
||||||
@@ -391,6 +440,10 @@ 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;
|
||||||
@@ -420,9 +473,66 @@ 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": 1278240551,
|
"seed": 1,
|
||||||
"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": 453191,
|
"versionNonce": 1278240551,
|
||||||
"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": 449462985,
|
"seed": 1,
|
||||||
"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": 401146281,
|
"versionNonce": 449462985,
|
||||||
"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": 1278240551,
|
"seed": 1,
|
||||||
"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": 453191,
|
"versionNonce": 1278240551,
|
||||||
"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": 449462985,
|
"seed": 1,
|
||||||
"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": 401146281,
|
"versionNonce": 449462985,
|
||||||
"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": 99,
|
"height": 125,
|
||||||
"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,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.20800",
|
125,
|
||||||
99,
|
125,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"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": 40,
|
"version": 47,
|
||||||
"width": "98.20800",
|
"width": 125,
|
||||||
"x": 1,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -298,7 +298,7 @@ History {
|
|||||||
"focus": "0.00990",
|
"focus": "0.00990",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": "0.98017",
|
"height": "0.98000",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@@ -306,7 +306,7 @@ History {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
98,
|
98,
|
||||||
"-0.98017",
|
"-0.98000",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
@@ -320,10 +320,10 @@ History {
|
|||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "id165",
|
"elementId": "id165",
|
||||||
"fixedPoint": null,
|
"fixedPoint": null,
|
||||||
"focus": "-0.02000",
|
"focus": "-0.02040",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": "0.00169",
|
"height": "0.02000",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@@ -331,13 +331,13 @@ History {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
98,
|
98,
|
||||||
"0.00169",
|
"0.02000",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "id164",
|
"elementId": "id164",
|
||||||
"fixedPoint": null,
|
"fixedPoint": null,
|
||||||
"focus": "0.02000",
|
"focus": "0.01959",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -393,18 +393,20 @@ History {
|
|||||||
"focus": 0,
|
"focus": 0,
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": 99,
|
"height": 125,
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.20800",
|
125,
|
||||||
99,
|
125,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
|
"width": 125,
|
||||||
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -414,7 +416,7 @@ History {
|
|||||||
"focus": "0.00990",
|
"focus": "0.00990",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": "0.98161",
|
"height": "0.98000",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@@ -422,7 +424,7 @@ History {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
98,
|
98,
|
||||||
"-0.98161",
|
"-0.98000",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
@@ -431,7 +433,9 @@ History {
|
|||||||
"focus": "0.02970",
|
"focus": "0.02970",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"y": "0.99245",
|
"width": 98,
|
||||||
|
"x": 1,
|
||||||
|
"y": "0.99000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id169" => Delta {
|
"id169" => Delta {
|
||||||
@@ -823,9 +827,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": 30,
|
"version": 37,
|
||||||
"width": 0,
|
"width": 100,
|
||||||
"x": 200,
|
"x": 150,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -862,6 +866,8 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"width": 0,
|
||||||
|
"x": 149,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"points": [
|
"points": [
|
||||||
@@ -870,10 +876,12 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
"width": "98.00000",
|
||||||
|
"x": "1.00000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -930,6 +938,8 @@ History {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
|
"width": 100,
|
||||||
|
"x": 150,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
@@ -954,6 +964,8 @@ History {
|
|||||||
"focus": 0,
|
"focus": 0,
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
|
"width": 0,
|
||||||
|
"x": 149,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2363,9 +2375,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": 10,
|
"version": 12,
|
||||||
"width": 498,
|
"width": 498,
|
||||||
"x": 1,
|
"x": "1.00000",
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -2504,7 +2516,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -2523,8 +2535,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": 100,
|
"width": "98.00000",
|
||||||
"x": 0,
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -15167,9 +15179,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 10,
|
"version": 12,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": 1,
|
"x": "1.00000",
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -15208,7 +15220,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -15221,7 +15233,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -15517,7 +15529,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -15536,8 +15548,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": 100,
|
"width": "98.00000",
|
||||||
"x": 0,
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -15866,9 +15878,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 10,
|
"version": 12,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": 1,
|
"x": "1.00000",
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -16140,7 +16152,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -16159,8 +16171,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": 100,
|
"width": "98.00000",
|
||||||
"x": 0,
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -16489,9 +16501,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 10,
|
"version": 12,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": 1,
|
"x": "1.00000",
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -16763,7 +16775,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -16782,8 +16794,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": 100,
|
"width": "98.00000",
|
||||||
"x": 0,
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -17110,9 +17122,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 10,
|
"version": 12,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": 1,
|
"x": "1.00000",
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -17168,7 +17180,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17186,7 +17198,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17455,7 +17467,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17474,8 +17486,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": 100,
|
"width": "98.00000",
|
||||||
"x": 0,
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -17828,9 +17840,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 11,
|
"version": 13,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": 1,
|
"x": "1.00000",
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -17901,7 +17913,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17920,7 +17932,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -18189,7 +18201,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
100,
|
"98.00000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -18208,8 +18220,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": 100,
|
"width": "98.00000",
|
||||||
"x": 0,
|
"x": 1,
|
||||||
"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": 745419401,
|
"versionNonce": 2066753033,
|
||||||
"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": 11,
|
"version": 15,
|
||||||
"versionNonce": 1996028265,
|
"versionNonce": 271613161,
|
||||||
"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).toEqual(image.height);
|
expect(image.width).toBeCloseTo(image.height);
|
||||||
// image is at the corner, not space to its right to expand, should not be able to resize
|
// 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).toEqual(image.height);
|
expect(image.width).toBeCloseTo(image.height);
|
||||||
// max height should be reached
|
// max height should be reached
|
||||||
expect(image.height).toEqual(initialHeight);
|
expect(image.height).toBeCloseTo(initialHeight);
|
||||||
expect(image.width).toBe(initialHeight);
|
expect(image.width).toBe(initialHeight);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ 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 {
|
import { encodePngMetadata } from "../data/image";
|
||||||
encodePngMetadata,
|
|
||||||
encodeSvgMetadata,
|
|
||||||
decodeSvgMetadata,
|
|
||||||
} from "../data/image";
|
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
import { exportToSvg } from "../scene/export";
|
import {
|
||||||
|
decodeSvgBase64Payload,
|
||||||
|
encodeSvgBase64Payload,
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -62,15 +63,32 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("test encoding/decoding scene for SVG export", async () => {
|
it("test encoding/decoding scene for SVG export", async () => {
|
||||||
const encoded = encodeSvgMetadata({
|
const metadataElement = document.createElementNS(SVG_NS, "metadata");
|
||||||
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(() => {
|
||||||
|
|||||||
@@ -220,7 +220,6 @@ export class API {
|
|||||||
| "width"
|
| "width"
|
||||||
| "height"
|
| "height"
|
||||||
| "type"
|
| "type"
|
||||||
| "seed"
|
|
||||||
| "version"
|
| "version"
|
||||||
| "versionNonce"
|
| "versionNonce"
|
||||||
| "isDeleted"
|
| "isDeleted"
|
||||||
@@ -228,6 +227,7 @@ export class API {
|
|||||||
| "link"
|
| "link"
|
||||||
| "updated"
|
| "updated"
|
||||||
> = {
|
> = {
|
||||||
|
seed: 1,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
frameId: rest.frameId ?? null,
|
frameId: rest.frameId ?? null,
|
||||||
|
|||||||
@@ -4785,21 +4785,17 @@ 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: expect.toBeNonNaNNumber(),
|
focus: 0,
|
||||||
gap: expect.toBeNonNaNNumber(),
|
gap: 1,
|
||||||
}),
|
}),
|
||||||
endBinding: expect.objectContaining({
|
endBinding: expect.objectContaining({
|
||||||
elementId: rect2.id,
|
elementId: rect2.id,
|
||||||
fixedPoint: null,
|
fixedPoint: null,
|
||||||
focus: expect.toBeNonNaNNumber(),
|
focus: 0,
|
||||||
gap: expect.toBeNonNaNNumber(),
|
gap: 1,
|
||||||
}),
|
}),
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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")!);
|
||||||
|
|
||||||
@@ -235,7 +237,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it("resizes", async () => {
|
it("resizes", async () => {
|
||||||
const element = UI.createElement(type, { points: points[type] });
|
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||||
const bounds = getBoundsFromPoints(element);
|
const bounds = getBoundsFromPoints(element);
|
||||||
|
|
||||||
UI.resize(element, "ne", [30, -60]);
|
UI.resize(element, "ne", [30, -60]);
|
||||||
@@ -249,7 +251,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("flips while resizing", async () => {
|
it("flips while resizing", async () => {
|
||||||
const element = UI.createElement(type, { points: points[type] });
|
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||||
const bounds = getBoundsFromPoints(element);
|
const bounds = getBoundsFromPoints(element);
|
||||||
|
|
||||||
UI.resize(element, "sw", [140, -80]);
|
UI.resize(element, "sw", [140, -80]);
|
||||||
@@ -263,7 +265,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resizes with locked aspect ratio", async () => {
|
it("resizes with locked aspect ratio", async () => {
|
||||||
const element = UI.createElement(type, { points: points[type] });
|
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||||
const bounds = getBoundsFromPoints(element);
|
const bounds = getBoundsFromPoints(element);
|
||||||
|
|
||||||
UI.resize(element, "ne", [30, -60], { shift: true });
|
UI.resize(element, "ne", [30, -60], { shift: true });
|
||||||
@@ -280,7 +282,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resizes from center", async () => {
|
it("resizes from center", async () => {
|
||||||
const element = UI.createElement(type, { points: points[type] });
|
const element = UI.createElement("freedraw", { points: points.freedraw });
|
||||||
const bounds = getBoundsFromPoints(element);
|
const bounds = getBoundsFromPoints(element);
|
||||||
|
|
||||||
UI.resize(element, "nw", [-20, -30], { alt: true });
|
UI.resize(element, "nw", [-20, -30], { alt: true });
|
||||||
@@ -294,6 +296,147 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("line element", () => {
|
||||||
|
const points: LocalPoint[] = [
|
||||||
|
pointFrom(0, 0),
|
||||||
|
pointFrom(60, -20),
|
||||||
|
pointFrom(20, 40),
|
||||||
|
pointFrom(-40, 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("resizes", async () => {
|
||||||
|
UI.createElement("line", { points });
|
||||||
|
|
||||||
|
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
const {
|
||||||
|
x: prevX,
|
||||||
|
y: prevY,
|
||||||
|
width: prevWidth,
|
||||||
|
height: prevHeight,
|
||||||
|
} = element;
|
||||||
|
|
||||||
|
const nextWidth = prevWidth + 30;
|
||||||
|
const nextHeight = prevHeight + 30;
|
||||||
|
|
||||||
|
resizeSingleElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
element,
|
||||||
|
element,
|
||||||
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
|
"ne",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(element.x).not.toBe(prevX);
|
||||||
|
expect(element.y).not.toBe(prevY);
|
||||||
|
|
||||||
|
expect(element.width).toBe(nextWidth);
|
||||||
|
expect(element.height).toBe(nextHeight);
|
||||||
|
|
||||||
|
expect(element.points[0]).toEqual([0, 0]);
|
||||||
|
|
||||||
|
const { width, height } = getSizeFromPoints(element.points);
|
||||||
|
expect(width).toBe(element.width);
|
||||||
|
expect(height).toBe(element.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips while resizing", async () => {
|
||||||
|
UI.createElement("line", { points });
|
||||||
|
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
const {
|
||||||
|
width: prevWidth,
|
||||||
|
height: prevHeight,
|
||||||
|
points: prevPoints,
|
||||||
|
} = element;
|
||||||
|
|
||||||
|
const nextWidth = prevWidth * -1;
|
||||||
|
const nextHeight = prevHeight * -1;
|
||||||
|
|
||||||
|
resizeSingleElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
element,
|
||||||
|
element,
|
||||||
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
|
"se",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(element.width).toBe(prevWidth);
|
||||||
|
expect(element.height).toBe(prevHeight);
|
||||||
|
|
||||||
|
element.points.forEach((point, idx) => {
|
||||||
|
expect(point[0]).toBeCloseTo(prevPoints[idx][0] * -1);
|
||||||
|
expect(point[1]).toBeCloseTo(prevPoints[idx][1] * -1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes with locked aspect ratio", async () => {
|
||||||
|
UI.createElement("line", { points });
|
||||||
|
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
const { width: prevWidth, height: prevHeight } = element;
|
||||||
|
|
||||||
|
UI.resize(element, "ne", [30, -60], { shift: true });
|
||||||
|
|
||||||
|
const scaleHeight = element.width / prevWidth;
|
||||||
|
const scaleWidth = element.height / prevHeight;
|
||||||
|
|
||||||
|
expect(scaleHeight).toBeCloseTo(scaleWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes from center", async () => {
|
||||||
|
UI.createElement("line", {
|
||||||
|
points: [
|
||||||
|
pointFrom(0, 0),
|
||||||
|
pointFrom(338.05644048727373, -180.4761618151104),
|
||||||
|
pointFrom(338.05644048727373, 180.4761618151104),
|
||||||
|
pointFrom(-338.05644048727373, 180.4761618151104),
|
||||||
|
pointFrom(-338.05644048727373, -180.4761618151104),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
const {
|
||||||
|
x: prevX,
|
||||||
|
y: prevY,
|
||||||
|
width: prevWidth,
|
||||||
|
height: prevHeight,
|
||||||
|
} = element;
|
||||||
|
|
||||||
|
const prevSmallestX = Math.min(...element.points.map((p) => p[0]));
|
||||||
|
const prevBiggestX = Math.max(...element.points.map((p) => p[0]));
|
||||||
|
|
||||||
|
resizeSingleElement(
|
||||||
|
prevWidth + 20,
|
||||||
|
prevHeight,
|
||||||
|
element,
|
||||||
|
element,
|
||||||
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
|
"e",
|
||||||
|
{
|
||||||
|
shouldResizeFromCenter: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(element.width).toBeCloseTo(prevWidth + 20);
|
||||||
|
expect(element.height).toBeCloseTo(prevHeight);
|
||||||
|
|
||||||
|
expect(element.x).toBeCloseTo(prevX);
|
||||||
|
expect(element.y).toBeCloseTo(prevY);
|
||||||
|
|
||||||
|
const smallestX = Math.min(...element.points.map((p) => p[0]));
|
||||||
|
const biggestX = Math.max(...element.points.map((p) => p[0]));
|
||||||
|
|
||||||
|
expect(prevSmallestX - smallestX).toBeCloseTo(10);
|
||||||
|
expect(biggestX - prevBiggestX).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("arrow element", () => {
|
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
@@ -1,13 +1,19 @@
|
|||||||
import * as utils from "../utils";
|
import { isTransparent, sanitizeHTMLAttribute } 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(utils.isTransparent("#ff00")).toEqual(true);
|
expect(isTransparent("#ff00")).toEqual(true);
|
||||||
expect(utils.isTransparent("#fff00000")).toEqual(true);
|
expect(isTransparent("#fff00000")).toEqual(true);
|
||||||
expect(utils.isTransparent("transparent")).toEqual(true);
|
expect(isTransparent("transparent")).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false when color is not transparent", () => {
|
it("should return false when color is not transparent", () => {
|
||||||
expect(utils.isTransparent("#ced4da")).toEqual(false);
|
expect(isTransparent("#ced4da")).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeHTMLAttribute()", () => {
|
||||||
|
it("should escape HTML attribute special characters & not double escape", () => {
|
||||||
|
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&"'><");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1225,3 +1225,16 @@ 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, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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
|
||||||
@@ -27,7 +28,7 @@ describe("embedding scene data", () => {
|
|||||||
|
|
||||||
const svg = svgNode.outerHTML;
|
const svg = svgNode.outerHTML;
|
||||||
|
|
||||||
const parsedString = decodeSvgMetadata({ svg });
|
const parsedString = decodeSvgBase64Payload({ 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(
|
||||||
|
|||||||
@@ -158,5 +158,8 @@ const createESMRawBuild = async () => {
|
|||||||
await buildProd(rawConfigChunks);
|
await buildProd(rawConfigChunks);
|
||||||
};
|
};
|
||||||
|
|
||||||
createESMRawBuild();
|
// otherwise throws "ERROR: Could not resolve "./subset-worker.chunk"
|
||||||
createESMBrowserBuild();
|
(async () => {
|
||||||
|
await createESMRawBuild();
|
||||||
|
await createESMBrowserBuild();
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user