Compare commits
84 Commits
master
...
arnost/scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
110afc3c85 | ||
|
|
23a6b6d3df | ||
|
|
f6ced89c3c | ||
|
|
6eb0596638 | ||
|
|
0607003903 | ||
|
|
4e2026e47d | ||
|
|
67260915cb | ||
|
|
c84fad4436 | ||
|
|
2e9c8851b3 | ||
|
|
19608b712f | ||
|
|
3a566a292c | ||
|
|
62c800c21a | ||
|
|
f9723e2d19 | ||
|
|
ffbd4a5dc8 | ||
|
|
5dded6112c | ||
|
|
84c396aec2 | ||
|
|
bc6cc83b1e | ||
|
|
baa7b3293a | ||
|
|
4208c97b62 | ||
|
|
2d0c0afa34 | ||
|
|
df26487936 | ||
|
|
782772cec5 | ||
|
|
39f79927ae | ||
|
|
1316d884fe | ||
|
|
d6710ded04 | ||
|
|
78d2a6ecc0 | ||
|
|
213134bbca | ||
|
|
b5bf346229 | ||
|
|
4c62eef7da | ||
|
|
82aa1cf19d | ||
|
|
9bc874a61e | ||
|
|
d5f55aba44 | ||
|
|
72de65e482 | ||
|
|
0f99e823f4 | ||
|
|
3ec09988fa | ||
|
|
b40fd65404 | ||
|
|
266069ae05 | ||
|
|
c33fb846ab | ||
|
|
94e9b20951 | ||
|
|
186ed43671 | ||
|
|
d1e3ea431b | ||
|
|
ddb08ce732 | ||
|
|
edf54d1543 | ||
|
|
b4e80b602d | ||
|
|
dd9bde5ee7 | ||
|
|
b99bf74c3d | ||
|
|
84b19a77d7 | ||
|
|
53a88d4c7a | ||
|
|
10900f39ee | ||
|
|
ebbd72e792 | ||
|
|
f8ba862774 | ||
|
|
806b1e9705 | ||
|
|
b0cdd00c2a | ||
|
|
6711735b27 | ||
|
|
803e14ada1 | ||
|
|
4469c02191 | ||
|
|
04e23e1d29 | ||
|
|
d24a032dbb | ||
|
|
76d3930983 | ||
|
|
af6e64ffc2 | ||
|
|
4e9039e850 | ||
|
|
132750f753 | ||
|
|
71eb3023b2 | ||
|
|
6d165971fc | ||
|
|
9562e4309f | ||
|
|
e8e391e465 | ||
|
|
92be92071a | ||
|
|
71918e57a8 | ||
|
|
c0bd9027cb | ||
|
|
7336b1c276 | ||
|
|
7fb6c23715 | ||
|
|
82014fe670 | ||
|
|
bc44c3f947 | ||
|
|
19ba107041 | ||
|
|
381ef93956 | ||
|
|
f82363aae9 | ||
|
|
485c57fd59 | ||
|
|
35b43c14d8 | ||
|
|
f7e8056abe | ||
|
|
71f7960606 | ||
|
|
2998573e79 | ||
|
|
209934c90a | ||
|
|
a8158691b7 | ||
|
|
75f8e904cc |
@ -4,6 +4,7 @@ import {
|
||||
TTDDialogTrigger,
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
getCommonBounds,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@ -20,6 +21,7 @@ import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
@ -56,9 +58,21 @@ import {
|
||||
useHandleLibrary,
|
||||
} from "@excalidraw/excalidraw/data/library";
|
||||
|
||||
import { getSelectedElements } from "@excalidraw/element/selection";
|
||||
|
||||
import {
|
||||
decodeConstraints,
|
||||
encodeConstraints,
|
||||
} from "@excalidraw/excalidraw/scene/scrollConstraints";
|
||||
|
||||
import { useApp } from "@excalidraw/excalidraw/components/App";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
|
||||
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
|
||||
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
@ -69,8 +83,9 @@ import type {
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
ScrollConstraints,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { Merge, ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
|
||||
import CustomStats from "./CustomStats";
|
||||
@ -140,6 +155,274 @@ import type { CollabAPI } from "./collab/Collab";
|
||||
|
||||
polyfill();
|
||||
|
||||
type DebugScrollConstraints = Merge<
|
||||
ScrollConstraints,
|
||||
{ viewportZoomFactor: number; enabled: boolean }
|
||||
>;
|
||||
|
||||
const ConstraintsSettings = ({
|
||||
initialConstraints,
|
||||
excalidrawAPI,
|
||||
}: {
|
||||
initialConstraints: DebugScrollConstraints;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}) => {
|
||||
const [constraints, setConstraints] =
|
||||
useState<DebugScrollConstraints>(initialConstraints);
|
||||
|
||||
const app = useApp();
|
||||
const frames = app.scene.getNonDeletedFramesLikes();
|
||||
const [activeFrameId, setActiveFrameId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("constraints", encodeConstraints(constraints));
|
||||
history.replaceState(null, "", `?${params.toString()}`);
|
||||
|
||||
constraints.enabled
|
||||
? excalidrawAPI.setScrollConstraints(constraints)
|
||||
: excalidrawAPI.setScrollConstraints(null);
|
||||
}, [constraints, excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frames.find((frame) => frame.id === activeFrameId);
|
||||
if (frame) {
|
||||
const { x, y, width, height } = frame;
|
||||
setConstraints((s) => ({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
enabled: s.enabled,
|
||||
viewportZoomFactor: s.viewportZoomFactor,
|
||||
lockZoom: s.lockZoom,
|
||||
}));
|
||||
}
|
||||
}, [activeFrameId, frames]);
|
||||
|
||||
const [selection, setSelection] = useState<ExcalidrawElement[]>([]);
|
||||
useEffect(() => {
|
||||
return excalidrawAPI.onChange((elements, appState) => {
|
||||
setSelection(getSelectedElements(elements, appState));
|
||||
});
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
const parseValue = (
|
||||
value: string,
|
||||
opts?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
},
|
||||
) => {
|
||||
const { min = -Infinity, max = Infinity } = opts || {};
|
||||
let parsedValue = parseInt(value);
|
||||
if (isNaN(parsedValue)) {
|
||||
parsedValue = 0;
|
||||
}
|
||||
return clamp(parsedValue, min, max);
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
width: "4rem",
|
||||
height: "1rem",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 10,
|
||||
left: "calc(50%)",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 999999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.6rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
enabled:{" "}
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={!!constraints.enabled}
|
||||
onChange={(e) =>
|
||||
setConstraints((s) => ({ ...s, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
x:{" "}
|
||||
<input
|
||||
placeholder="x"
|
||||
type="number"
|
||||
step={"10"}
|
||||
value={constraints.x.toString()}
|
||||
onChange={(e) => {
|
||||
setConstraints((s) => ({
|
||||
...s,
|
||||
x: parseValue(e.target.value),
|
||||
}));
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
y:{" "}
|
||||
<input
|
||||
placeholder="y"
|
||||
type="number"
|
||||
step={"10"}
|
||||
value={constraints.y.toString()}
|
||||
onChange={(e) =>
|
||||
setConstraints((s) => ({
|
||||
...s,
|
||||
y: parseValue(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
w:{" "}
|
||||
<input
|
||||
placeholder="width"
|
||||
type="number"
|
||||
step={"10"}
|
||||
value={constraints.width.toString()}
|
||||
onChange={(e) =>
|
||||
setConstraints((s) => ({
|
||||
...s,
|
||||
width: parseValue(e.target.value, {
|
||||
min: 200,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
h:{" "}
|
||||
<input
|
||||
placeholder="height"
|
||||
type="number"
|
||||
step={"10"}
|
||||
value={constraints.height.toString()}
|
||||
onChange={(e) =>
|
||||
setConstraints((s) => ({
|
||||
...s,
|
||||
height: parseValue(e.target.value, {
|
||||
min: 200,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
zoomFactor:
|
||||
<input
|
||||
placeholder="zoom factor"
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={constraints.viewportZoomFactor.toString()}
|
||||
onChange={(e) =>
|
||||
setConstraints((s) => ({
|
||||
...s,
|
||||
viewportZoomFactor: parseFloat(e.target.value.toString()) ?? 0.7,
|
||||
}))
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
overscrollAllowance:
|
||||
<input
|
||||
placeholder="overscroll allowance"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={constraints.overscrollAllowance?.toString()}
|
||||
onChange={(e) =>
|
||||
setConstraints((s) => ({
|
||||
...s,
|
||||
overscrollAllowance: parseFloat(e.target.value.toString()) ?? 0.5,
|
||||
}))
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
lockZoom:{" "}
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={!!constraints.lockZoom}
|
||||
onChange={(e) =>
|
||||
setConstraints((s) => ({ ...s, lockZoom: e.target.checked }))
|
||||
}
|
||||
value={constraints.lockZoom?.toString()}
|
||||
/>
|
||||
{selection.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const bbox = getCommonBounds(selection);
|
||||
setConstraints((s) => ({
|
||||
...s,
|
||||
x: Math.round(bbox[0]),
|
||||
y: Math.round(bbox[1]),
|
||||
width: Math.round(bbox[2] - bbox[0]),
|
||||
height: Math.round(bbox[3] - bbox[1]),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
use selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{frames.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.6rem",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentIndex = frames.findIndex(
|
||||
(frame) => frame.id === activeFrameId,
|
||||
);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
setActiveFrameId(frames[frames.length - 1].id);
|
||||
} else {
|
||||
const nextIndex =
|
||||
(currentIndex - 1 + frames.length) % frames.length;
|
||||
setActiveFrameId(frames[nextIndex].id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentIndex = frames.findIndex(
|
||||
(frame) => frame.id === activeFrameId,
|
||||
);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
setActiveFrameId(frames[0].id);
|
||||
} else {
|
||||
const nextIndex = (currentIndex + 1) % frames.length;
|
||||
setActiveFrameId(frames[nextIndex].id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
declare global {
|
||||
@ -211,10 +494,20 @@ const initializeScene = async (opts: {
|
||||
)
|
||||
> => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const id = searchParams.get("id");
|
||||
const jsonBackendMatch = window.location.hash.match(
|
||||
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
|
||||
const shareableLink = hashParams.get("json")?.split(",");
|
||||
|
||||
if (shareableLink) {
|
||||
hashParams.delete("json");
|
||||
const hash = `#${decodeURIComponent(hashParams.toString())}`;
|
||||
window.history.replaceState(
|
||||
{},
|
||||
APP_NAME,
|
||||
`${window.location.origin}${hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
@ -224,7 +517,7 @@ const initializeScene = async (opts: {
|
||||
} = await loadScene(null, null, localDataState);
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
const isExternalScene = !!(id || shareableLink || roomLinkData);
|
||||
if (isExternalScene) {
|
||||
if (
|
||||
// don't prompt if scene is empty
|
||||
@ -234,16 +527,16 @@ const initializeScene = async (opts: {
|
||||
// otherwise, prompt whether user wants to override current scene
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
if (shareableLink) {
|
||||
scene = await loadScene(
|
||||
jsonBackendMatch[1],
|
||||
jsonBackendMatch[2],
|
||||
shareableLink[0],
|
||||
shareableLink[1],
|
||||
localDataState,
|
||||
);
|
||||
}
|
||||
scene.scrollToContent = true;
|
||||
if (!roomLinkData) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
// window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
} else {
|
||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||
@ -260,7 +553,7 @@ const initializeScene = async (opts: {
|
||||
}
|
||||
|
||||
roomLinkData = null;
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
// window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
} else if (externalUrlMatch) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
@ -321,12 +614,12 @@ const initializeScene = async (opts: {
|
||||
key: roomLinkData.roomKey,
|
||||
};
|
||||
} else if (scene) {
|
||||
return isExternalScene && jsonBackendMatch
|
||||
return isExternalScene && shareableLink
|
||||
? {
|
||||
scene,
|
||||
isExternalScene,
|
||||
id: jsonBackendMatch[1],
|
||||
key: jsonBackendMatch[2],
|
||||
id: shareableLink[0],
|
||||
key: shareableLink[1],
|
||||
}
|
||||
: { scene, isExternalScene: false };
|
||||
}
|
||||
@ -498,6 +791,11 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
@ -588,6 +886,7 @@ const ExcalidrawWrapper = () => {
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
@ -732,6 +1031,32 @@ const ExcalidrawWrapper = () => {
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
const [constraints] = useState<DebugScrollConstraints>(() => {
|
||||
const stored = new URLSearchParams(location.search.slice(1)).get(
|
||||
"constraints",
|
||||
);
|
||||
let storedConstraints = {};
|
||||
if (stored) {
|
||||
try {
|
||||
storedConstraints = decodeConstraints(stored);
|
||||
} catch {
|
||||
console.error("Invalid scroll constraints in URL");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: document.body.clientWidth,
|
||||
height: document.body.clientHeight,
|
||||
lockZoom: false,
|
||||
viewportZoomFactor: 0.7,
|
||||
overscrollAllowance: 0.5,
|
||||
enabled: !isTestEnv(),
|
||||
...storedConstraints,
|
||||
};
|
||||
});
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
@ -857,6 +1182,7 @@ const ExcalidrawWrapper = () => {
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
scrollConstraints={constraints.enabled ? constraints : undefined}
|
||||
onLinkOpen={(element, event) => {
|
||||
if (element.link && isElementLink(element.link)) {
|
||||
event.preventDefault();
|
||||
@ -864,6 +1190,12 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{excalidrawAPI && !isTestEnv() && (
|
||||
<ConstraintsSettings
|
||||
excalidrawAPI={excalidrawAPI}
|
||||
initialConstraints={constraints}
|
||||
/>
|
||||
)}
|
||||
<AppMainMenu
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
isCollaborating={isCollaborating}
|
||||
|
||||
@ -8,8 +8,7 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
|
||||
@ -259,9 +259,7 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@ -258,16 +258,11 @@ export const loadScene = async (
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw Whiteboard</title>
|
||||
<title>
|
||||
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
|
||||
</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
|
||||
@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
|
||||
},
|
||||
"isTouchScreen": false,
|
||||
"viewport": {
|
||||
"isLandscape": true,
|
||||
"isLandscape": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
}
|
||||
|
||||
@ -18,20 +18,13 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
/iPad|iPhone/.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const isMobile =
|
||||
isIOS ||
|
||||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
navigator.userAgent,
|
||||
) ||
|
||||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@ -129,7 +122,6 @@ export const CLASSES = {
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||
};
|
||||
|
||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||
@ -260,17 +252,13 @@ export const IMAGE_MIME_TYPES = {
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const STRING_MIME_TYPES = {
|
||||
export const MIME_TYPES = {
|
||||
text: "text/plain",
|
||||
html: "text/html",
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
} as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
...STRING_MIME_TYPES,
|
||||
// image-encoded excalidraw data
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
"excalidraw.png": "image/png",
|
||||
@ -347,17 +335,10 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// mobile: up to 699px
|
||||
export const MQ_MAX_MOBILE = 599;
|
||||
|
||||
// tablets
|
||||
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||
|
||||
// desktop/laptop
|
||||
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||
|
||||
// md screen
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -534,5 +515,3 @@ export enum UserIdleState {
|
||||
* the start and end points)
|
||||
*/
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||
|
||||
@ -21,8 +21,6 @@ import {
|
||||
FONT_FAMILY,
|
||||
getFontFamilyFallbacks,
|
||||
isDarwin,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
|
||||
@ -1280,59 +1278,3 @@ export const reduceToCommonValue = <T, R = T>(
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
|
||||
export const isMobileOrTablet = (): boolean => {
|
||||
const ua = navigator.userAgent || "";
|
||||
const platform = navigator.platform || "";
|
||||
const uaData = (navigator as any).userAgentData as
|
||||
| { mobile?: boolean; platform?: string }
|
||||
| undefined;
|
||||
|
||||
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||
if (uaData) {
|
||||
const plat = (uaData.platform || "").toLowerCase();
|
||||
const isDesktopOS =
|
||||
plat === "windows" ||
|
||||
plat === "macos" ||
|
||||
plat === "linux" ||
|
||||
plat === "chrome os";
|
||||
if (uaData.mobile === true) {
|
||||
return true;
|
||||
}
|
||||
if (uaData.mobile === false && plat === "android") {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
if (isDesktopOS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) ios (includes ipad) --------------------------------------------
|
||||
if (isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 3) android legacy ua fallback -------------------------------------
|
||||
if (isAndroid) {
|
||||
const isAndroidPhone = /Mobile/i.test(ua);
|
||||
const isAndroidTablet = !isAndroidPhone;
|
||||
if (isAndroidPhone || isAndroidTablet) {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4) last resort desktop exclusion ----------------------------------
|
||||
const looksDesktopPlatform =
|
||||
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||
if (looksDesktopPlatform) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@ -164,14 +164,9 @@ export class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements, options);
|
||||
this.replaceAllElements(elements);
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,19 +263,12 @@ export class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(
|
||||
nextElements: ElementsMapOrArray,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
|
||||
@ -1126,9 +1126,7 @@ export interface BoundingBox {
|
||||
}
|
||||
|
||||
export const getCommonBoundingBox = (
|
||||
elements:
|
||||
| readonly ExcalidrawElement[]
|
||||
| readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
arrayToMap,
|
||||
arrayToObject,
|
||||
assertNever,
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isShallowEqual,
|
||||
isTestEnv,
|
||||
@ -55,10 +56,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
||||
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@ -150,27 +151,13 @@ export class Delta<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two deltas into a new one.
|
||||
*/
|
||||
public static merge<T>(
|
||||
delta1: Delta<T>,
|
||||
delta2: Delta<T>,
|
||||
delta3: Delta<T> = Delta.empty(),
|
||||
) {
|
||||
return Delta.create(
|
||||
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
|
||||
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges deleted and inserted object partials.
|
||||
*/
|
||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||
prev: T,
|
||||
added: T,
|
||||
removed: T = {} as T,
|
||||
removed: T,
|
||||
) {
|
||||
const cloned = { ...prev };
|
||||
|
||||
@ -510,11 +497,6 @@ export interface DeltaContainer<T> {
|
||||
*/
|
||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||
|
||||
/**
|
||||
* Squashes the current delta with the given one.
|
||||
*/
|
||||
squash(delta: DeltaContainer<T>): this;
|
||||
|
||||
/**
|
||||
* Checks whether all `Delta`s are empty.
|
||||
*/
|
||||
@ -522,11 +504,7 @@ export interface DeltaContainer<T> {
|
||||
}
|
||||
|
||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static calculate<T extends ObservedAppState>(
|
||||
prevAppState: T,
|
||||
@ -557,137 +535,76 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return new AppStateDelta(inversedDelta);
|
||||
}
|
||||
|
||||
public squash(delta: AppStateDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedElementIds ?? {},
|
||||
delta.delta.deleted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedElementIds ?? {},
|
||||
delta.delta.inserted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedGroupIds ?? {},
|
||||
delta.delta.deleted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedGroupIds ?? {},
|
||||
delta.delta.inserted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.deleted.lockedMultiSelections ?? {},
|
||||
delta.delta.deleted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.inserted.lockedMultiSelections ?? {},
|
||||
delta.delta.inserted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInserted: Partial<ObservedAppState> = {};
|
||||
const mergedDeleted: Partial<ObservedAppState> = {};
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedElementIds).length ||
|
||||
Object.keys(mergedInsertedSelectedElementIds).length
|
||||
) {
|
||||
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
||||
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
||||
Object.keys(mergedInsertedSelectedGroupIds).length
|
||||
) {
|
||||
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
||||
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
||||
Object.keys(mergedInsertedLockedMultiSelections).length
|
||||
) {
|
||||
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
||||
mergedInserted.lockedMultiSelections =
|
||||
mergedInsertedLockedMultiSelections;
|
||||
}
|
||||
|
||||
this.delta = Delta.merge(
|
||||
this.delta,
|
||||
delta.delta,
|
||||
Delta.create(mergedDeleted, mergedInserted),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
appState: AppState,
|
||||
nextElements: SceneElementsMap,
|
||||
): [AppState, boolean] {
|
||||
try {
|
||||
const {
|
||||
selectedElementIds: deletedSelectedElementIds = {},
|
||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||
selectedElementIds: removedSelectedElementIds = {},
|
||||
selectedGroupIds: removedSelectedGroupIds = {},
|
||||
} = this.delta.deleted;
|
||||
|
||||
const {
|
||||
selectedElementIds: insertedSelectedElementIds = {},
|
||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||
selectedLinearElement: insertedSelectedLinearElement,
|
||||
selectedElementIds: addedSelectedElementIds = {},
|
||||
selectedGroupIds: addedSelectedGroupIds = {},
|
||||
selectedLinearElementId,
|
||||
selectedLinearElementIsEditing,
|
||||
...directlyApplicablePartial
|
||||
} = this.delta.inserted;
|
||||
|
||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||
appState.selectedElementIds,
|
||||
insertedSelectedElementIds,
|
||||
deletedSelectedElementIds,
|
||||
addedSelectedElementIds,
|
||||
removedSelectedElementIds,
|
||||
);
|
||||
|
||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||
appState.selectedGroupIds,
|
||||
insertedSelectedGroupIds,
|
||||
deletedSelectedGroupIds,
|
||||
addedSelectedGroupIds,
|
||||
removedSelectedGroupIds,
|
||||
);
|
||||
|
||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||
appState.lockedMultiSelections,
|
||||
insertedLockedMultiSelections,
|
||||
deletedLockedMultiSelections,
|
||||
);
|
||||
let selectedLinearElement = appState.selectedLinearElement;
|
||||
|
||||
const selectedLinearElement =
|
||||
insertedSelectedLinearElement &&
|
||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||
? new LinearElementEditor(
|
||||
if (selectedLinearElementId === null) {
|
||||
// Unselect linear element (visible change)
|
||||
selectedLinearElement = null;
|
||||
} else if (
|
||||
selectedLinearElementId &&
|
||||
nextElements.has(selectedLinearElementId)
|
||||
) {
|
||||
selectedLinearElement = new LinearElementEditor(
|
||||
nextElements.get(
|
||||
insertedSelectedLinearElement.elementId,
|
||||
selectedLinearElementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
insertedSelectedLinearElement.isEditing,
|
||||
)
|
||||
: null;
|
||||
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
// Value being 'null' is equivaluent to unknown in this case because it only gets set
|
||||
// to null when 'selectedLinearElementId' is set to null
|
||||
selectedLinearElementIsEditing != null
|
||||
) {
|
||||
invariant(
|
||||
selectedLinearElement,
|
||||
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
|
||||
);
|
||||
|
||||
selectedLinearElement = {
|
||||
...selectedLinearElement,
|
||||
isEditing: selectedLinearElementIsEditing,
|
||||
};
|
||||
}
|
||||
|
||||
const nextAppState = {
|
||||
...appState,
|
||||
...directlyApplicablePartial,
|
||||
selectedElementIds: mergedSelectedElementIds,
|
||||
selectedGroupIds: mergedSelectedGroupIds,
|
||||
lockedMultiSelections: mergedLockedMultiSelections,
|
||||
selectedLinearElement:
|
||||
typeof insertedSelectedLinearElement !== "undefined"
|
||||
? selectedLinearElement
|
||||
: appState.selectedLinearElement,
|
||||
selectedLinearElement,
|
||||
};
|
||||
|
||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||
@ -816,48 +733,58 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "selectedLinearElement":
|
||||
const nextLinearElement = nextAppState[key];
|
||||
case "selectedLinearElementId": {
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
|
||||
if (!nextLinearElement) {
|
||||
if (!linearElement) {
|
||||
// previously there was a linear element (assuming visible), now there is none
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
const element = nextElements.get(nextLinearElement.elementId);
|
||||
const element = nextElements.get(linearElement.elementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// previously there wasn't a linear element, now there is one which is visible
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
// there was assigned a linear element now, but it's deleted
|
||||
nextAppState[key] = null;
|
||||
nextAppState[appStateKey] = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case "lockedMultiSelections":
|
||||
}
|
||||
case "selectedLinearElementIsEditing": {
|
||||
// Changes in editing state are always visible
|
||||
const prevIsEditing =
|
||||
prevAppState.selectedLinearElement?.isEditing ?? false;
|
||||
const nextIsEditing =
|
||||
nextAppState.selectedLinearElement?.isEditing ?? false;
|
||||
|
||||
if (prevIsEditing !== nextIsEditing) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "lockedMultiSelections": {
|
||||
const prevLockedUnits = prevAppState[key] || {};
|
||||
const nextLockedUnits = nextAppState[key] || {};
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
case "activeLockedId":
|
||||
}
|
||||
case "activeLockedId": {
|
||||
const prevHitLockedId = prevAppState[key] || null;
|
||||
const nextHitLockedId = nextAppState[key] || null;
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (prevHitLockedId !== nextHitLockedId) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
key,
|
||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||
@ -866,10 +793,20 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleDifferenceFlag.value;
|
||||
}
|
||||
|
||||
private static convertToAppStateKey(
|
||||
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
|
||||
): keyof Pick<AppState, "selectedLinearElement"> {
|
||||
switch (key) {
|
||||
case "selectedLinearElementId":
|
||||
return "selectedLinearElement";
|
||||
}
|
||||
}
|
||||
|
||||
private static filterSelectedElements(
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
elements: SceneElementsMap,
|
||||
@ -934,7 +871,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds,
|
||||
selectedLinearElement,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElementIsEditing,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
@ -988,6 +926,12 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||
@ -1016,13 +960,12 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||
|
||||
export type ApplyToOptions = {
|
||||
excludedProperties?: Set<keyof ElementPartial>;
|
||||
excludedProperties: Set<keyof ElementPartial>;
|
||||
};
|
||||
|
||||
type ApplyToFlags = {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
applyDirection: "forward" | "backward" | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -1111,27 +1054,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
inserted,
|
||||
}: Delta<ElementPartial>) =>
|
||||
!!(
|
||||
deleted.version &&
|
||||
inserted.version &&
|
||||
// versions are required integers
|
||||
(
|
||||
Number.isInteger(deleted.version) &&
|
||||
Number.isInteger(inserted.version) &&
|
||||
// versions should be positive, zero included
|
||||
deleted.version! >= 0 &&
|
||||
inserted.version! >= 0 &&
|
||||
deleted.version >= 0 &&
|
||||
inserted.version >= 0 &&
|
||||
// versions should never be the same
|
||||
deleted.version !== inserted.version
|
||||
)
|
||||
);
|
||||
|
||||
private static satisfiesUniqueInvariants = (
|
||||
elementsDelta: ElementsDelta,
|
||||
id: string,
|
||||
) => {
|
||||
const { added, removed, updated } = elementsDelta;
|
||||
// it's required that there is only one unique delta type per element
|
||||
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
||||
};
|
||||
|
||||
private static validate(
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
@ -1140,7 +1074,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||
if (
|
||||
!this.satisfiesCommmonInvariants(delta) ||
|
||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||
!satifiesSpecialInvariants(delta)
|
||||
) {
|
||||
console.error(
|
||||
@ -1177,7 +1110,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
const deleted = { ...prevElement } as ElementPartial;
|
||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||
|
||||
const inserted = {
|
||||
isDeleted: true,
|
||||
@ -1191,11 +1124,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
if (!prevElement.isDeleted) {
|
||||
removed[prevElement.id] = delta;
|
||||
} else {
|
||||
updated[prevElement.id] = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1211,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
const inserted = {
|
||||
...nextElement,
|
||||
isDeleted: false,
|
||||
} as ElementPartial;
|
||||
|
||||
const delta = Delta.create(
|
||||
@ -1219,12 +1149,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!nextElement.isDeleted) {
|
||||
added[nextElement.id] = delta;
|
||||
} else {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1253,9 +1178,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// making sure there are at least some changes
|
||||
if (!Delta.isEmpty(delta)) {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ElementsDelta.create(added, removed, updated);
|
||||
}
|
||||
@ -1268,8 +1196,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
|
||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||
}
|
||||
|
||||
return inversedDeltas;
|
||||
@ -1388,7 +1316,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||
options?: ApplyToOptions,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@ -1396,28 +1326,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const flags: ApplyToFlags = {
|
||||
containsVisibleDifference: false,
|
||||
containsZindexDifference: false,
|
||||
applyDirection: undefined,
|
||||
};
|
||||
|
||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
elements,
|
||||
nextElements,
|
||||
snapshot,
|
||||
flags,
|
||||
options,
|
||||
flags,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
const updatedElements = applyDeltas(this.updated);
|
||||
|
||||
const affectedElements = this.resolveConflicts(
|
||||
elements,
|
||||
nextElements,
|
||||
flags.applyDirection,
|
||||
);
|
||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||
|
||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||
changedElements = new Map([
|
||||
@ -1441,15 +1365,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
);
|
||||
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements);
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
@ -1464,113 +1395,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
}
|
||||
|
||||
public squash(delta: ElementsDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const { added, removed, updated } = delta;
|
||||
|
||||
const mergeBoundElements = (
|
||||
prevDelta: Delta<ElementPartial>,
|
||||
nextDelta: Delta<ElementPartial>,
|
||||
) => {
|
||||
const mergedDeletedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.deleted.boundElements ?? [],
|
||||
nextDelta.deleted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
const mergedInsertedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.inserted.boundElements ?? [],
|
||||
nextDelta.inserted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
if (
|
||||
!mergedDeletedBoundElements.length &&
|
||||
!mergedInsertedBoundElements.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Delta.create(
|
||||
{
|
||||
boundElements: mergedDeletedBoundElements,
|
||||
},
|
||||
{
|
||||
boundElements: mergedInsertedBoundElements,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(added)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.added[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.removed[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(removed)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.removed[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.added[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(updated)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.updated[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
|
||||
if (prevDelta === this.added[id]) {
|
||||
this.added[id] = updatedDelta;
|
||||
} else if (prevDelta === this.removed[id]) {
|
||||
this.removed[id] = updatedDelta;
|
||||
} else {
|
||||
this.updated[id] = updatedDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
|
||||
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
|
||||
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static createApplier =
|
||||
(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) =>
|
||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
@ -1583,26 +1413,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const nextElement = ElementsDelta.applyDelta(
|
||||
const newElement = ElementsDelta.applyDelta(
|
||||
element,
|
||||
delta,
|
||||
flags,
|
||||
options,
|
||||
flags,
|
||||
);
|
||||
|
||||
nextElements.set(nextElement.id, nextElement);
|
||||
acc.set(nextElement.id, nextElement);
|
||||
|
||||
if (!flags.applyDirection) {
|
||||
const prevElement = prevElements.get(id);
|
||||
|
||||
if (prevElement) {
|
||||
flags.applyDirection =
|
||||
prevElement.version > nextElement.version
|
||||
? "backward"
|
||||
: "forward";
|
||||
}
|
||||
}
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
}
|
||||
|
||||
return acc;
|
||||
@ -1647,8 +1466,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static applyDelta(
|
||||
element: OrderedExcalidrawElement,
|
||||
delta: Delta<ElementPartial>,
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) {
|
||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||
|
||||
@ -1662,7 +1481,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options?.excludedProperties?.has(key)) {
|
||||
if (options.excludedProperties.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1702,7 +1521,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
}
|
||||
|
||||
return newElementWith(element, directlyApplicablePartial, true);
|
||||
return newElementWith(element, directlyApplicablePartial);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1742,7 +1561,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private resolveConflicts(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
applyDirection: "forward" | "backward" = "forward",
|
||||
) {
|
||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
const updater = (
|
||||
@ -1754,36 +1572,21 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevElement = prevElements.get(element.id);
|
||||
const nextVersion =
|
||||
applyDirection === "forward"
|
||||
? nextElement.version + 1
|
||||
: nextElement.version - 1;
|
||||
|
||||
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
||||
|
||||
let affectedElement: OrderedExcalidrawElement;
|
||||
|
||||
if (prevElement === nextElement) {
|
||||
if (prevElements.get(element.id) === nextElement) {
|
||||
// create the new element instance in case we didn't modify the element yet
|
||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||
affectedElement = newElementWith(
|
||||
nextElement,
|
||||
{
|
||||
...elementUpdates,
|
||||
version: nextVersion,
|
||||
},
|
||||
true,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
} else {
|
||||
affectedElement = mutateElement(nextElement, nextElements, {
|
||||
...elementUpdates,
|
||||
// don't modify the version further, if it's already different
|
||||
version:
|
||||
prevElement?.version !== nextElement.version
|
||||
? nextElement.version
|
||||
: nextVersion,
|
||||
});
|
||||
affectedElement = mutateElement(
|
||||
nextElement,
|
||||
nextElements,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
}
|
||||
|
||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||
@ -1821,12 +1624,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||
);
|
||||
|
||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||
this.squash(
|
||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||
// technically we could do better here if perf. would become an issue
|
||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||
const { added, removed, updated } = ElementsDelta.calculate(
|
||||
prevAffectedElements,
|
||||
nextAffectedElements,
|
||||
);
|
||||
|
||||
for (const [id, delta] of Object.entries(added)) {
|
||||
this.added[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(removed)) {
|
||||
this.removed[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(updated)) {
|
||||
this.updated[id] = delta;
|
||||
}
|
||||
|
||||
return nextAffectedElements;
|
||||
}
|
||||
|
||||
@ -1888,31 +1704,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||
}
|
||||
|
||||
public static redrawElements(
|
||||
nextElements: SceneElementsMap,
|
||||
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
try {
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
|
||||
// needs ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
} catch (e) {
|
||||
console.error(`Couldn't redraw elements`, e);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return nextElements;
|
||||
}
|
||||
}
|
||||
|
||||
private static redrawTextBoundingBoxes(
|
||||
scene: Scene,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
@ -1967,7 +1758,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||
updateBoundElements(element, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
|
||||
@ -359,12 +359,6 @@ const handleSegmentRelease = (
|
||||
null,
|
||||
);
|
||||
|
||||
if (!restoredPoints || restoredPoints.length < 2) {
|
||||
throw new Error(
|
||||
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||
);
|
||||
}
|
||||
|
||||
const nextPoints: GlobalPoint[] = [];
|
||||
|
||||
// First part of the arrow are the old points
|
||||
@ -712,7 +706,7 @@ const handleEndpointDrag = (
|
||||
endGlobalPoint: GlobalPoint,
|
||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
) => {
|
||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||
@ -747,15 +741,8 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second point connection and add the start point
|
||||
{
|
||||
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
||||
|
||||
if (!secondPoint || !thirdPoint) {
|
||||
throw new Error(
|
||||
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
|
||||
);
|
||||
}
|
||||
|
||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||
const secondIsHorizontal = headingIsHorizontal(
|
||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||
@ -814,19 +801,10 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second to last point connection
|
||||
{
|
||||
const secondToLastPoint = globalUpdatedPoints.at(
|
||||
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||
);
|
||||
const thirdToLastPoint = globalUpdatedPoints.at(
|
||||
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
|
||||
);
|
||||
|
||||
if (!secondToLastPoint || !thirdToLastPoint) {
|
||||
throw new Error(
|
||||
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
|
||||
);
|
||||
}
|
||||
|
||||
const secondToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
||||
const thirdToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||
thirdToLastPoint,
|
||||
@ -2093,7 +2071,16 @@ const normalizeArrowElementUpdate = (
|
||||
nextFixedSegments: readonly FixedSegment[] | null,
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
} => {
|
||||
const offsetX = global[0][0];
|
||||
const offsetY = global[0][1];
|
||||
let points = global.map((p) =>
|
||||
|
||||
@ -97,7 +97,6 @@ export * from "./image";
|
||||
export * from "./linearElementEditor";
|
||||
export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
export * from "./positionElementsOnGrid";
|
||||
export * from "./renderElement";
|
||||
export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
// TODO rewrite (mostly vibe-coded)
|
||||
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
||||
elements: TElement[] | TElement[][],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
padding = 50,
|
||||
): TElement[] => {
|
||||
// Ensure there are elements to position
|
||||
if (!elements || elements.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res: TElement[] = [];
|
||||
// Normalize input to work with atomic units (groups of elements)
|
||||
// If elements is a flat array, treat each element as its own atomic unit
|
||||
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
||||
? (elements as TElement[][])
|
||||
: (elements as TElement[]).map((element) => [element]);
|
||||
|
||||
// Determine the number of columns for atomic units
|
||||
// A common approach for a "grid-like" layout without specific column constraints
|
||||
// is to aim for a roughly square arrangement.
|
||||
const numUnits = atomicUnits.length;
|
||||
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
||||
|
||||
// Group atomic units into rows based on the calculated number of columns
|
||||
const rows: TElement[][][] = [];
|
||||
for (let i = 0; i < numUnits; i += numColumns) {
|
||||
rows.push(atomicUnits.slice(i, i + numColumns));
|
||||
}
|
||||
|
||||
// Calculate properties for each row (total width, max height)
|
||||
// and the total actual height of all row content.
|
||||
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
||||
const rowProperties = rows.map((rowUnits) => {
|
||||
let rowWidth = 0;
|
||||
let maxUnitHeightInRow = 0;
|
||||
|
||||
const unitBounds = rowUnits.map((unit) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
||||
return {
|
||||
elements: unit,
|
||||
bounds: [minX, minY, maxX, maxY] as const,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
});
|
||||
|
||||
unitBounds.forEach((unitBound, index) => {
|
||||
rowWidth += unitBound.width;
|
||||
// Add padding between units in the same row, but not after the last one
|
||||
if (index < unitBounds.length - 1) {
|
||||
rowWidth += padding;
|
||||
}
|
||||
if (unitBound.height > maxUnitHeightInRow) {
|
||||
maxUnitHeightInRow = unitBound.height;
|
||||
}
|
||||
});
|
||||
|
||||
totalGridActualHeight += maxUnitHeightInRow;
|
||||
return {
|
||||
unitBounds,
|
||||
width: rowWidth,
|
||||
maxHeight: maxUnitHeightInRow,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate the total height of the grid including padding between rows
|
||||
const totalGridHeightWithPadding =
|
||||
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
||||
|
||||
// Calculate the starting Y position to center the entire grid vertically around centerY
|
||||
let currentY = centerY - totalGridHeightWithPadding / 2;
|
||||
|
||||
// Position atomic units row by row
|
||||
rowProperties.forEach((rowProp) => {
|
||||
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
||||
|
||||
// Calculate the starting X for the current row to center it horizontally around centerX
|
||||
let currentX = centerX - rowWidth / 2;
|
||||
|
||||
unitBounds.forEach((unitBound) => {
|
||||
// Calculate the offset needed to position this atomic unit
|
||||
const [originalMinX, originalMinY] = unitBound.bounds;
|
||||
const offsetX = currentX - originalMinX;
|
||||
const offsetY = currentY - originalMinY;
|
||||
|
||||
// Apply the offset to all elements in this atomic unit
|
||||
unitBound.elements.forEach((element) => {
|
||||
res.push(
|
||||
newElementWith(element, {
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
} as ElementUpdate<TElement>),
|
||||
);
|
||||
});
|
||||
|
||||
// Move X for the next unit in the row
|
||||
currentX += unitBound.width + padding;
|
||||
});
|
||||
|
||||
// Move Y to the starting position for the next row
|
||||
// This accounts for the tallest unit in the current row and the inter-row padding
|
||||
currentY += rowMaxHeight + padding;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
@ -1,14 +1,7 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import {
|
||||
type GlobalPoint,
|
||||
isRightAngleRads,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
import { isRightAngleRads } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
@ -21,7 +14,6 @@ import {
|
||||
getFontString,
|
||||
isRTL,
|
||||
getVerticalOffset,
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@ -40,7 +32,7 @@ import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { getUncroppedImageElement } from "./cropElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import {
|
||||
@ -1047,66 +1039,6 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
||||
}
|
||||
|
||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
|
||||
}
|
||||
|
||||
export function getFreedrawOutlineAsSegments(
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
points: [number, number][],
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
const bounds = getElementBounds(
|
||||
{
|
||||
...element,
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
(bounds[0] + bounds[2]) / 2,
|
||||
(bounds[1] + bounds[3]) / 2,
|
||||
);
|
||||
|
||||
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
|
||||
|
||||
return points.slice(2).reduce(
|
||||
(acc, curr) => {
|
||||
acc.push(
|
||||
lineSegment<GlobalPoint>(
|
||||
acc[acc.length - 1][1],
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
[
|
||||
lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
points[0][0] + element.x,
|
||||
points[0][1] + element.y,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
points[1][0] + element.x,
|
||||
points[1][1] + element.y,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
@ -1125,7 +1057,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||
};
|
||||
|
||||
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
||||
}
|
||||
|
||||
function med(A: number[], B: number[]) {
|
||||
|
||||
@ -35,7 +35,6 @@ import {
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
getMinTextElementWidth,
|
||||
@ -226,16 +225,7 @@ const rotateSingleElement = (
|
||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
textElement,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
scene.mutateElement(textElement, {
|
||||
angle,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
scene.mutateElement(textElement, { angle });
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -426,15 +416,9 @@ const rotateMultipleElements = (
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
boundText,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
scene.mutateElement(boundText, {
|
||||
x,
|
||||
y,
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
});
|
||||
}
|
||||
|
||||
@ -76,9 +76,8 @@ type MicroActionsQueue = (() => void)[];
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// for internal use by history
|
||||
// internally used by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
// for public use as part of onIncrement API
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
@ -240,6 +239,7 @@ export class Store {
|
||||
if (!storeDelta.isEmpty()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
@ -552,26 +552,10 @@ export class StoreDelta {
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
|
||||
return new this(id, elements, appState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash the passed deltas into the aggregated delta instance.
|
||||
*/
|
||||
public static squash(...deltas: StoreDelta[]) {
|
||||
const aggregatedDelta = StoreDelta.empty();
|
||||
|
||||
for (const delta of deltas) {
|
||||
aggregatedDelta.elements.squash(delta.elements);
|
||||
aggregatedDelta.appState.squash(delta.appState);
|
||||
}
|
||||
|
||||
return aggregatedDelta;
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -588,7 +572,9 @@ export class StoreDelta {
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options?: ApplyToOptions,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
@ -627,10 +613,6 @@ export class StoreDelta {
|
||||
);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
@ -996,7 +978,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedLinearElement: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
@ -1015,12 +998,14 @@ export const getObservedAppState = (
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
selectedLinearElement: appState.selectedLinearElement
|
||||
? {
|
||||
elementId: appState.selectedLinearElement.elementId,
|
||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||
}
|
||||
: null,
|
||||
selectedLinearElementId:
|
||||
(appState as AppState).selectedLinearElement?.elementId ??
|
||||
(appState as ObservedAppState).selectedLinearElementId ??
|
||||
null,
|
||||
selectedLinearElementIsEditing:
|
||||
(appState as AppState).selectedLinearElement?.isEditing ??
|
||||
(appState as ObservedAppState).selectedLinearElementIsEditing ??
|
||||
null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@ -10,12 +10,12 @@ import {
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
@ -254,26 +254,6 @@ export const computeBoundTextPosition = (
|
||||
x =
|
||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||
}
|
||||
const angle = (container.angle ?? 0) as Radians;
|
||||
|
||||
if (angle !== 0) {
|
||||
const contentCenter = pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
const textCenter = pointFrom(
|
||||
x + boundTextElement.width / 2,
|
||||
y + boundTextElement.height / 2,
|
||||
);
|
||||
|
||||
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
|
||||
|
||||
return {
|
||||
x: rx - boundTextElement.width / 2,
|
||||
y: ry - boundTextElement.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
|
||||
@ -1,345 +1,13 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||
|
||||
describe("ElementsDelta", () => {
|
||||
describe("elements delta calculation", () => {
|
||||
it("should not throw when element gets removed but was already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map([[element.id, element]]);
|
||||
const nextElements = new Map();
|
||||
|
||||
expect(() =>
|
||||
ElementsDelta.calculate(prevElements, nextElements),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not throw when adding element as already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map();
|
||||
const nextElements = new Map([[element.id, element]]);
|
||||
|
||||
expect(() =>
|
||||
ElementsDelta.calculate(prevElements, nextElements),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should create updated delta even when there is only version and versionNonce change", () => {
|
||||
const baseElement = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
const modifiedElement = {
|
||||
...baseElement,
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
};
|
||||
|
||||
// Create maps for the delta calculation
|
||||
const prevElements = new Map([[baseElement.id, baseElement]]);
|
||||
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
||||
|
||||
// Calculate the delta
|
||||
const delta = ElementsDelta.calculate(
|
||||
prevElements as SceneElementsMap,
|
||||
nextElements as SceneElementsMap,
|
||||
);
|
||||
|
||||
expect(delta).toEqual(
|
||||
ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[baseElement.id]: Delta.create(
|
||||
{
|
||||
version: baseElement.version,
|
||||
versionNonce: baseElement.versionNonce,
|
||||
},
|
||||
{
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id1: updatedDelta },
|
||||
);
|
||||
const elementsDelta2 = ElementsDelta.empty();
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash mutually exclusive delta types", () => {
|
||||
const addedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
);
|
||||
|
||||
const removedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
);
|
||||
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{ id1: addedDelta },
|
||||
{ id2: removedDelta },
|
||||
{},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id3: updatedDelta },
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toBe(addedDelta);
|
||||
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
||||
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash the same delta types", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.removed.id2).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.updated.id3).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash different delta types ", () => {
|
||||
// id1: added -> updated => added
|
||||
// id2: removed -> added => added
|
||||
// id3: updated -> removed => removed
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 300, version: 1, versionNonce: 1 },
|
||||
{ x: 301, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 101, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added).toEqual({
|
||||
id1: Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
id2: Delta.create(
|
||||
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.removed).toEqual({
|
||||
id3: Delta.create(
|
||||
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.updated).toEqual({});
|
||||
});
|
||||
|
||||
it("should squash bound elements", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
boundElements: [{ id: "t1", type: "text" }],
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "t2", type: "text" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "a1", type: "arrow" }],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
versionNonce: 3,
|
||||
boundElements: [{ id: "a2", type: "arrow" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
||||
{ id: "t1", type: "text" },
|
||||
{ id: "a1", type: "arrow" },
|
||||
]);
|
||||
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
||||
{ id: "t2", type: "text" },
|
||||
{ id: "a2", type: "arrow" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElement = {
|
||||
elementId: "id1" as LinearElementEditor["elementId"],
|
||||
isEditing: false,
|
||||
};
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
@ -356,23 +24,23 @@ describe("AppStateDelta", () => {
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElement: null,
|
||||
selectedLinearElementId: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElement,
|
||||
selectedLinearElementId,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElement: null,
|
||||
selectedLinearElementId: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElement,
|
||||
selectedLinearElementId,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
@ -390,7 +58,9 @@ describe("AppStateDelta", () => {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElement: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@ -436,7 +106,9 @@ describe("AppStateDelta", () => {
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElement: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@ -477,97 +149,4 @@ describe("AppStateDelta", () => {
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const delta = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta);
|
||||
const appStateDelta2 = AppStateDelta.empty();
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toBe(delta);
|
||||
});
|
||||
|
||||
it("should squash exclusive properties", () => {
|
||||
const delta1 = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
const delta2 = Delta.create(
|
||||
{ viewBackgroundColor: "#ffffff" },
|
||||
{ viewBackgroundColor: "#000000" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create(
|
||||
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
||||
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
||||
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true },
|
||||
selectedGroupIds: {},
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
);
|
||||
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
selectedElementIds: { id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
{
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true, id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true, g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import { getLineHeight } from "@excalidraw/common";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "../src/textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||
|
||||
@ -208,172 +207,3 @@ describe("Test getDefaultLineHeight", () => {
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeBoundTextPosition", () => {
|
||||
const createMockElementsMap = () => new Map();
|
||||
|
||||
// Helper function to create rectangle test case with 90-degree rotation
|
||||
const createRotatedRectangleTestCase = (
|
||||
textAlign: string,
|
||||
verticalAlign: string,
|
||||
) => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
angle: (Math.PI / 2) as any, // 90 degrees
|
||||
});
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
width: 80,
|
||||
height: 40,
|
||||
text: "hello darkness my old friend",
|
||||
textAlign: textAlign as any,
|
||||
verticalAlign: verticalAlign as any,
|
||||
containerId: container.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
const elementsMap = createMockElementsMap();
|
||||
|
||||
return { container, boundTextElement, elementsMap };
|
||||
};
|
||||
|
||||
describe("90-degree rotation with all alignment combinations", () => {
|
||||
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
|
||||
|
||||
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(
|
||||
TEXT_ALIGN.CENTER,
|
||||
VERTICAL_ALIGN.MIDDLE,
|
||||
);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(
|
||||
TEXT_ALIGN.CENTER,
|
||||
VERTICAL_ALIGN.BOTTOM,
|
||||
);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
ZoomResetIcon,
|
||||
} from "../components/icons";
|
||||
import { setCursor } from "../cursor";
|
||||
import { constrainScrollState } from "../scene/scrollConstraints";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
@ -69,7 +70,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||
return (
|
||||
<ColorPicker
|
||||
@ -83,7 +84,6 @@ export const actionChangeViewBackgroundColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@ -122,7 +122,7 @@ export const actionClearCanvas = register({
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@ -138,7 +138,7 @@ export const actionZoomIn = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
appState: constrainScrollState({
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
@ -149,7 +149,7 @@ export const actionZoomIn = register({
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
}),
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
@ -179,7 +179,7 @@ export const actionZoomOut = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
appState: constrainScrollState({
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
@ -190,7 +190,7 @@ export const actionZoomOut = register({
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
}),
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
@ -495,13 +495,13 @@ export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.defaultSelectionTool,
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
@ -531,9 +531,6 @@ export const actionToggleLassoTool = register({
|
||||
label: "toolBar.lasso",
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return app.defaultSelectionTool !== "lasso";
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
|
||||
@ -298,9 +298,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: null,
|
||||
|
||||
@ -5,11 +5,7 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import {
|
||||
isValidPolygon,
|
||||
LinearElementEditor,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
@ -82,14 +78,7 @@ export const actionFinalize = register({
|
||||
let newElements = elements;
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, {
|
||||
isDeleted: true,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
}
|
||||
return {
|
||||
elements: newElements,
|
||||
@ -128,12 +117,7 @@ export const actionFinalize = register({
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
? elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
})
|
||||
? elements.filter((el) => el.id !== element.id)
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
@ -188,12 +172,7 @@ export const actionFinalize = register({
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element?.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
});
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
}
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
@ -261,13 +240,13 @@ export const actionFinalize = register({
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.defaultSelectionTool,
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
type: "selection",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -88,10 +88,6 @@ export const actionToggleLinearEditor = register({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
if (!selectedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(
|
||||
selectedElement.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
|
||||
@ -137,11 +137,6 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
|
||||
import {
|
||||
withCaretPositionPreservation,
|
||||
restoreCaretPosition,
|
||||
} from "../hooks/useTextEditorFocus";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
@ -326,11 +321,9 @@ export const actionChangeStrokeColor = register({
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||
)}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||
@ -348,7 +341,6 @@ export const actionChangeStrokeColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@ -406,11 +398,9 @@ export const actionChangeBackgroundColor = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
)}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||
@ -428,7 +418,6 @@ export const actionChangeBackgroundColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@ -529,11 +518,9 @@ export const actionChangeStrokeWidth = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<fieldset>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
)}
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="stroke-width"
|
||||
@ -588,11 +575,9 @@ export const actionChangeSloppiness = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<fieldset>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.sloppiness")}</legend>
|
||||
)}
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="sloppiness"
|
||||
@ -643,11 +628,9 @@ export const actionChangeStrokeStyle = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<fieldset>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.strokeStyle")}</legend>
|
||||
)}
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="strokeStyle"
|
||||
@ -714,7 +697,7 @@ export const actionChangeFontSize = register({
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, () => value, value);
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontSize")}</legend>
|
||||
<div className="buttonList">
|
||||
@ -773,14 +756,7 @@ export const actionChangeFontSize = register({
|
||||
? null
|
||||
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
}}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -1040,7 +1016,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
return result;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, app, updateData, data }) => {
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||
@ -1118,28 +1094,20 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
)}
|
||||
<FontPicker
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
onSelect={(fontFamily) => {
|
||||
withCaretPositionPreservation(
|
||||
() => {
|
||||
setBatchedData({
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
currentItemFontFamily: fontFamily,
|
||||
});
|
||||
|
||||
// defensive clear so immediate close won't abuse the cached elements
|
||||
cachedElementsRef.current.clear();
|
||||
},
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
);
|
||||
}}
|
||||
onHover={(fontFamily) => {
|
||||
setBatchedData({
|
||||
@ -1196,28 +1164,25 @@ export const actionChangeFontFamily = register({
|
||||
}
|
||||
|
||||
setBatchedData({
|
||||
...batchedData,
|
||||
openPopup: "fontFamily",
|
||||
});
|
||||
} else {
|
||||
const fontFamilyData = {
|
||||
// close, use the cache and clear it afterwards
|
||||
const data = {
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
} as ChangeFontFamilyData;
|
||||
|
||||
setBatchedData({
|
||||
...fontFamilyData,
|
||||
});
|
||||
cachedElementsRef.current.clear();
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (
|
||||
appState.stylesPanelMode === "compact" &&
|
||||
appState.editingTextElement
|
||||
) {
|
||||
restoreCaretPosition(null); // Just refocus without saved position
|
||||
if (isUnmounted.current) {
|
||||
// in case the component was unmounted by the parent, trigger the update directly
|
||||
updateData({ ...batchedData, ...data });
|
||||
} else {
|
||||
setBatchedData(data);
|
||||
}
|
||||
|
||||
cachedElementsRef.current.clear();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -1260,9 +1225,8 @@ export const actionChangeTextAlign = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
@ -1311,14 +1275,7 @@ export const actionChangeTextAlign = register({
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemTextAlign,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
}}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -1360,7 +1317,7 @@ export const actionChangeVerticalAlign = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<div className="buttonList">
|
||||
@ -1410,14 +1367,7 @@ export const actionChangeVerticalAlign = register({
|
||||
) !== null,
|
||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||
)}
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
}}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -1666,25 +1616,6 @@ export const actionChangeArrowhead = register({
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeArrowProperties = register({
|
||||
name: "changeArrowProperties",
|
||||
label: "Change arrow properties",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
// This action doesn't perform any changes directly
|
||||
// It's just a container for the arrow type and arrowhead actions
|
||||
return false;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||
return (
|
||||
<div className="selected-shape-actions">
|
||||
{renderAction("changeArrowType")}
|
||||
{renderAction("changeArrowhead")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeArrowType = register({
|
||||
name: "changeArrowType",
|
||||
label: "Change arrow types",
|
||||
|
||||
@ -18,7 +18,6 @@ export {
|
||||
actionChangeFontFamily,
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
actionChangeArrowProperties,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
|
||||
@ -69,7 +69,6 @@ export type ActionName =
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
| "changeArrowProperties"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
|
||||
@ -21,7 +21,7 @@ const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
"offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
|
||||
> => {
|
||||
return {
|
||||
showWelcomeScreen: false,
|
||||
@ -123,7 +123,6 @@ export const getDefaultAppState = (): Omit<
|
||||
searchMatches: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
stylesPanelMode: "full",
|
||||
};
|
||||
};
|
||||
|
||||
@ -243,12 +242,12 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
userToFollow: { browser: false, export: false, server: false },
|
||||
followedBy: { browser: false, export: false, server: false },
|
||||
scrollConstraints: { browser: false, export: false, server: false },
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElementId: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||
activeLockedId: { browser: false, export: false, server: false },
|
||||
stylesPanelMode: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
createPasteEvent,
|
||||
parseClipboard,
|
||||
parseDataTransferEvent,
|
||||
serializeAsClipboardJSON,
|
||||
} from "./clipboard";
|
||||
import { API } from "./tests/helpers/api";
|
||||
@ -14,9 +13,7 @@ describe("parseClipboard()", () => {
|
||||
|
||||
text = "123";
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
@ -24,9 +21,7 @@ describe("parseClipboard()", () => {
|
||||
|
||||
text = "[123]";
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
@ -34,9 +29,7 @@ describe("parseClipboard()", () => {
|
||||
|
||||
text = JSON.stringify({ val: 42 });
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
});
|
||||
@ -46,13 +39,11 @@ describe("parseClipboard()", () => {
|
||||
|
||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||
const clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/plain": json,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.elements).toEqual([rect]);
|
||||
});
|
||||
@ -65,25 +56,21 @@ describe("parseClipboard()", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": json,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.elements).toEqual([rect]);
|
||||
// -------------------------------------------------------------------------
|
||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<div> ${json}</div>`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.elements).toEqual([rect]);
|
||||
// -------------------------------------------------------------------------
|
||||
@ -93,13 +80,11 @@ describe("parseClipboard()", () => {
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<img src="https://example.com/image.png" />`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.mixedContent).toEqual([
|
||||
{
|
||||
@ -109,13 +94,11 @@ describe("parseClipboard()", () => {
|
||||
]);
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.mixedContent).toEqual([
|
||||
{
|
||||
@ -131,13 +114,11 @@ describe("parseClipboard()", () => {
|
||||
|
||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||
const clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.mixedContent).toEqual([
|
||||
{
|
||||
@ -160,7 +141,6 @@ describe("parseClipboard()", () => {
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/plain": `a b
|
||||
@ -169,7 +149,6 @@ describe("parseClipboard()", () => {
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
@ -178,7 +157,6 @@ describe("parseClipboard()", () => {
|
||||
});
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `a b
|
||||
@ -187,7 +165,6 @@ describe("parseClipboard()", () => {
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
@ -196,7 +173,6 @@ describe("parseClipboard()", () => {
|
||||
});
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<html>
|
||||
@ -210,7 +186,6 @@ describe("parseClipboard()", () => {
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
|
||||
@ -5,7 +5,6 @@ import {
|
||||
arrayToMap,
|
||||
isMemberOf,
|
||||
isPromiseLike,
|
||||
EVENT,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
@ -17,26 +16,15 @@ import {
|
||||
|
||||
import { getContainingFrame } from "@excalidraw/element";
|
||||
|
||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { ExcalidrawError } from "./errors";
|
||||
import {
|
||||
createFile,
|
||||
getFileHandle,
|
||||
isSupportedImageFileType,
|
||||
normalizeFile,
|
||||
} from "./data/blob";
|
||||
|
||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
|
||||
import type { Spreadsheet } from "./charts";
|
||||
|
||||
import type { BinaryFiles } from "./types";
|
||||
@ -104,7 +92,7 @@ export const createPasteEvent = ({
|
||||
console.warn("createPasteEvent: no types or files provided");
|
||||
}
|
||||
|
||||
const event = new ClipboardEvent(EVENT.PASTE, {
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
@ -113,11 +101,10 @@ export const createPasteEvent = ({
|
||||
if (typeof value !== "string") {
|
||||
files = files || [];
|
||||
files.push(value);
|
||||
event.clipboardData?.items.add(value);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
event.clipboardData?.items.add(value, type);
|
||||
event.clipboardData?.setData(type, value);
|
||||
if (event.clipboardData?.getData(type) !== value) {
|
||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||
}
|
||||
@ -242,10 +229,14 @@ function parseHTMLTree(el: ChildNode) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const maybeParseHTMLDataItem = (
|
||||
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
|
||||
const maybeParseHTMLPaste = (
|
||||
event: ClipboardEvent,
|
||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||
const html = dataItem.value;
|
||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
||||
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||
@ -341,21 +332,18 @@ export const readSystemClipboard = async () => {
|
||||
* Parses "paste" ClipboardEvent.
|
||||
*/
|
||||
const parseClipboardEventTextData = async (
|
||||
dataList: ParsedDataTranferList,
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ParsedClipboardEventTextData> => {
|
||||
try {
|
||||
const htmlItem = dataList.findByType(MIME_TYPES.html);
|
||||
|
||||
const mixedContent =
|
||||
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
|
||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||
|
||||
if (mixedContent) {
|
||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||
return {
|
||||
type: "text",
|
||||
value:
|
||||
dataList.getData(MIME_TYPES.text) ??
|
||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
||||
mixedContent.value
|
||||
.map((item) => item.value)
|
||||
.join("\n")
|
||||
@ -366,155 +354,23 @@ const parseClipboardEventTextData = async (
|
||||
return mixedContent;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
|
||||
};
|
||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
||||
|
||||
return { type: "text", value: (text || "").trim() };
|
||||
} catch {
|
||||
return { type: "text", value: "" };
|
||||
}
|
||||
};
|
||||
|
||||
type AllowedParsedDataTransferItem =
|
||||
| {
|
||||
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
}
|
||||
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||
|
||||
type ParsedDataTransferItem =
|
||||
| {
|
||||
type: string;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
}
|
||||
| { type: string; kind: "string"; value: string };
|
||||
|
||||
type ParsedDataTransferItemType<
|
||||
T extends AllowedParsedDataTransferItem["type"],
|
||||
> = AllowedParsedDataTransferItem & { type: T };
|
||||
|
||||
export type ParsedDataTransferFile = Extract<
|
||||
AllowedParsedDataTransferItem,
|
||||
{ kind: "file" }
|
||||
>;
|
||||
|
||||
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||
/**
|
||||
* Only allows filtering by known `string` data types, since `file`
|
||||
* types can have multiple items of the same type (e.g. multiple image files)
|
||||
* unlike `string` data transfer items.
|
||||
*/
|
||||
findByType: typeof findDataTransferItemType;
|
||||
/**
|
||||
* Only allows filtering by known `string` data types, since `file`
|
||||
* types can have multiple items of the same type (e.g. multiple image files)
|
||||
* unlike `string` data transfer items.
|
||||
*/
|
||||
getData: typeof getDataTransferItemData;
|
||||
getFiles: typeof getDataTransferFiles;
|
||||
};
|
||||
|
||||
const findDataTransferItemType = function <
|
||||
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
|
||||
return (
|
||||
this.find(
|
||||
(item): item is ParsedDataTransferItemType<T> => item.type === type,
|
||||
) || null
|
||||
);
|
||||
};
|
||||
const getDataTransferItemData = function <
|
||||
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||
>(
|
||||
this: ParsedDataTranferList,
|
||||
type: T,
|
||||
):
|
||||
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
|
||||
| null {
|
||||
const item = this.find(
|
||||
(
|
||||
item,
|
||||
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
|
||||
item.type === type,
|
||||
);
|
||||
|
||||
return item?.value ?? null;
|
||||
};
|
||||
|
||||
const getDataTransferFiles = function (
|
||||
this: ParsedDataTranferList,
|
||||
): ParsedDataTransferFile[] {
|
||||
return this.filter(
|
||||
(item): item is ParsedDataTransferFile => item.kind === "file",
|
||||
);
|
||||
};
|
||||
|
||||
export const parseDataTransferEvent = async (
|
||||
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||
): Promise<ParsedDataTranferList> => {
|
||||
let items: DataTransferItemList | undefined = undefined;
|
||||
|
||||
if (isClipboardEvent(event)) {
|
||||
items = event.clipboardData?.items;
|
||||
} else {
|
||||
const dragEvent = event;
|
||||
items = dragEvent.dataTransfer?.items;
|
||||
}
|
||||
|
||||
const dataItems = (
|
||||
await Promise.all(
|
||||
Array.from(items || []).map(
|
||||
async (item): Promise<ParsedDataTransferItem | null> => {
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const fileHandle = await getFileHandle(item);
|
||||
return {
|
||||
type: file.type,
|
||||
kind: "file",
|
||||
file: await normalizeFile(file),
|
||||
fileHandle,
|
||||
};
|
||||
}
|
||||
} else if (item.kind === "string") {
|
||||
const { type } = item;
|
||||
let value: string;
|
||||
if ("clipboardData" in event && event.clipboardData) {
|
||||
value = event.clipboardData?.getData(type);
|
||||
} else {
|
||||
value = await new Promise<string>((resolve) => {
|
||||
item.getAsString((str) => resolve(str));
|
||||
});
|
||||
}
|
||||
return { type, kind: "string", value };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
)
|
||||
).filter((data): data is ParsedDataTransferItem => data != null);
|
||||
|
||||
return Object.assign(dataItems, {
|
||||
findByType: findDataTransferItemType,
|
||||
getData: getDataTransferItemData,
|
||||
getFiles: getDataTransferFiles,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to parse clipboard event.
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
dataList: ParsedDataTranferList,
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ClipboardData> => {
|
||||
const parsedEventData = await parseClipboardEventTextData(
|
||||
dataList,
|
||||
event,
|
||||
isPlainPaste,
|
||||
);
|
||||
|
||||
@ -663,14 +519,3 @@ const copyTextViaExecCommand = (text: string | null) => {
|
||||
|
||||
return success;
|
||||
};
|
||||
|
||||
export const isClipboardEvent = (
|
||||
event: React.SyntheticEvent | Event,
|
||||
): event is ClipboardEvent => {
|
||||
/** not using instanceof ClipboardEvent due to tests (jsdom) */
|
||||
return (
|
||||
event.type === EVENT.PASTE ||
|
||||
event.type === EVENT.COPY ||
|
||||
event.type === EVENT.CUT
|
||||
);
|
||||
};
|
||||
|
||||
@ -91,120 +91,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compact-shape-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
|
||||
.compact-action-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 2.5rem;
|
||||
|
||||
--default-button-size: 2rem;
|
||||
|
||||
.compact-action-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: transparent;
|
||||
color: var(--color-on-surface);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg, var(--island-bg-color));
|
||||
border-color: var(
|
||||
--button-hover-border,
|
||||
var(--button-border, var(--default-border-color))
|
||||
);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--button-active-bg, var(--island-bg-color));
|
||||
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||
}
|
||||
}
|
||||
|
||||
.compact-popover-content {
|
||||
.popover-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popover-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compact-shape-actions-island {
|
||||
width: fit-content;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.compact-popover-content {
|
||||
.popover-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popover-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shape-actions-theme-scope {
|
||||
--button-border: transparent;
|
||||
--button-bg: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
:root.theme--dark .shape-actions-theme-scope {
|
||||
--button-hover-bg: #363541;
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import {
|
||||
CLASSES,
|
||||
@ -20,7 +19,6 @@ import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isArrowElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
||||
@ -48,20 +46,15 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
|
||||
import { getFormValue } from "../actions/actionProperties";
|
||||
|
||||
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
|
||||
|
||||
import { getToolbarTools } from "./shapes";
|
||||
import { SHAPES } from "./shapes";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
import { useDevice, useExcalidrawContainer } from "./App";
|
||||
import { useDevice } from "./App";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { PropertiesPopover } from "./PropertiesPopover";
|
||||
import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
@ -70,29 +63,11 @@ import {
|
||||
laserPointerToolIcon,
|
||||
MagicIcon,
|
||||
LassoIcon,
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
TextSizeIcon,
|
||||
adjustmentsIcon,
|
||||
DotsHorizontalIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppProps,
|
||||
UIAppState,
|
||||
Zoom,
|
||||
AppState,
|
||||
} from "../types";
|
||||
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
|
||||
// Common CSS class combinations
|
||||
const PROPERTIES_CLASSES = clsx([
|
||||
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
|
||||
"properties-content",
|
||||
]);
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
targetElements: ExcalidrawElement[],
|
||||
@ -305,437 +280,6 @@ export const SelectedShapeActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CompactShapeActions = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
renderAction,
|
||||
app,
|
||||
setAppState,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
app: AppClassProperties;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(elementsMap, appState);
|
||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
const isEditingTextOrNewElement = Boolean(
|
||||
appState.editingTextElement || appState.newElement,
|
||||
);
|
||||
|
||||
const showFillIcons =
|
||||
(hasBackground(appState.activeTool.type) &&
|
||||
!isTransparent(appState.currentItemBackgroundColor)) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
|
||||
const showLinkIcon = targetElements.length === 1;
|
||||
|
||||
const showLineEditorAction =
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
const showCropEditorAction =
|
||||
!appState.croppingElementId &&
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
|
||||
const showAlignActions = alignActionsPredicate(appState, app);
|
||||
|
||||
let isSingleElementBoundContainer = false;
|
||||
if (
|
||||
targetElements.length === 2 &&
|
||||
(hasBoundTextElement(targetElements[0]) ||
|
||||
hasBoundTextElement(targetElements[1]))
|
||||
) {
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
return (
|
||||
<div className="compact-shape-actions">
|
||||
{/* Stroke Color */}
|
||||
{canChangeStrokeColor(appState, targetElements) && (
|
||||
<div className={clsx("compact-action-item")}>
|
||||
{renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Color */}
|
||||
{canChangeBackgroundColor(appState, targetElements) && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeBackgroundColor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Properties (Fill, Stroke, Opacity) */}
|
||||
{(showFillIcons ||
|
||||
hasStrokeWidth(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type)) ||
|
||||
hasStrokeStyle(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type)) ||
|
||||
canChangeRoundness(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canChangeRoundness(element.type))) && (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactStrokeStyles"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setAppState({ openPopup: "compactStrokeStyles" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.stroke")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setAppState({
|
||||
openPopup:
|
||||
appState.openPopup === "compactStrokeStyles"
|
||||
? null
|
||||
: "compactStrokeStyles",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{adjustmentsIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactStrokeStyles" && (
|
||||
<PropertiesPopover
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
hasStrokeWidth(element.type),
|
||||
)) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
hasStrokeStyle(element.type),
|
||||
)) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
)}
|
||||
{(canChangeRoundness(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
canChangeRoundness(element.type),
|
||||
)) &&
|
||||
renderAction("changeRoundness")}
|
||||
{renderAction("changeOpacity")}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Arrow Properties */}
|
||||
{(toolIsArrow(appState.activeTool.type) ||
|
||||
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactArrowProperties"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setAppState({ openPopup: "compactArrowProperties" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.arrowtypes")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setAppState({
|
||||
openPopup:
|
||||
appState.openPopup === "compactArrowProperties"
|
||||
? null
|
||||
: "compactArrowProperties",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Show an icon based on the current arrow type
|
||||
const arrowType = getFormValue(
|
||||
targetElements,
|
||||
app,
|
||||
(element) => {
|
||||
if (isArrowElement(element)) {
|
||||
return element.elbowed
|
||||
? "elbow"
|
||||
: element.roundness
|
||||
? "round"
|
||||
: "sharp";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) => isArrowElement(element),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemArrowType,
|
||||
);
|
||||
|
||||
if (arrowType === "elbow") {
|
||||
return elbowArrowIcon;
|
||||
}
|
||||
if (arrowType === "round") {
|
||||
return roundArrowIcon;
|
||||
}
|
||||
return sharpArrowIcon;
|
||||
})()}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactArrowProperties" && (
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
className="properties-content"
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onClose={() => {}}
|
||||
>
|
||||
{renderAction("changeArrowProperties")}
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linear Editor */}
|
||||
{showLineEditorAction && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Properties */}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeFontFamily")}
|
||||
</div>
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactTextProperties"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (appState.editingTextElement) {
|
||||
saveCaretPosition();
|
||||
}
|
||||
setAppState({ openPopup: "compactTextProperties" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
if (appState.editingTextElement) {
|
||||
restoreCaretPosition();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.textAlign")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (appState.openPopup === "compactTextProperties") {
|
||||
setAppState({ openPopup: null });
|
||||
} else {
|
||||
if (appState.editingTextElement) {
|
||||
saveCaretPosition();
|
||||
}
|
||||
setAppState({ openPopup: "compactTextProperties" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{TextSizeIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactTextProperties" && (
|
||||
<PropertiesPopover
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onClose={() => {
|
||||
// Refocus text editor when popover closes with caret restoration
|
||||
if (appState.editingTextElement) {
|
||||
restoreCaretPosition();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) &&
|
||||
renderAction("changeFontSize")}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
||||
renderAction("changeVerticalAlign")}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dedicated Copy Button */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("duplicateSelection")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dedicated Delete Button */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Other Actions */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactOtherProperties"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setAppState({ openPopup: "compactOtherProperties" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.actions")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAppState({
|
||||
openPopup:
|
||||
appState.openPopup === "compactOtherProperties"
|
||||
? null
|
||||
: "compactOtherProperties",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{DotsHorizontalIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactOtherProperties" && (
|
||||
<PropertiesPopover
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{
|
||||
maxWidth: "12rem",
|
||||
// center the popover content
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
<fieldset>
|
||||
<legend>{t("labels.layers")}</legend>
|
||||
<div className="buttonList">
|
||||
{renderAction("sendToBack")}
|
||||
{renderAction("sendBackward")}
|
||||
{renderAction("bringForward")}
|
||||
{renderAction("bringToFront")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{showAlignActions && !isSingleElementBoundContainer && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
{isRTL ? (
|
||||
<>
|
||||
{renderAction("alignRight")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignLeft")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderAction("alignLeft")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignRight")}
|
||||
</>
|
||||
)}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeHorizontally")}
|
||||
{/* breaks the row ˇˇ */}
|
||||
<div style={{ flexBasis: "100%", height: 0 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: ".5rem",
|
||||
marginTop: "-0.5rem",
|
||||
}}
|
||||
>
|
||||
{renderAction("alignTop")}
|
||||
{renderAction("alignVerticallyCentered")}
|
||||
{renderAction("alignBottom")}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeVertically")}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{showCropEditorAction && renderAction("cropEditor")}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
activeTool,
|
||||
appState,
|
||||
@ -751,8 +295,7 @@ export const ShapesSwitcher = ({
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const lassoToolSelected =
|
||||
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||
const lassoToolSelected = activeTool.type === "lasso";
|
||||
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
@ -760,14 +303,10 @@ export const ShapesSwitcher = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{getToolbarTools(app).map(
|
||||
({ value, icon, key, numericKey, fillable }, index) => {
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<
|
||||
typeof value,
|
||||
keyof AppProps["UIOptions"]["tools"]
|
||||
>
|
||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
@ -820,8 +359,7 @@ export const ShapesSwitcher = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
})}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
@ -880,7 +418,6 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
{app.defaultSelectionTool !== "lasso" && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
@ -889,7 +426,6 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -22,12 +22,6 @@
|
||||
@include isMobile {
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
&.color-picker-container--no-top-picks {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
grid-template-columns: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__top-picks {
|
||||
@ -86,16 +80,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__button-background {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.color-picker__button-outline {
|
||||
position: absolute;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import clsx from "clsx";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import {
|
||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||
@ -18,12 +18,7 @@ import { useExcalidrawContainer } from "../App";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
|
||||
import {
|
||||
saveCaretPosition,
|
||||
restoreCaretPosition,
|
||||
temporarilyDisableTextEditorBlur,
|
||||
} from "../../hooks/useTextEditorFocus";
|
||||
import { slashIcon } from "../icons";
|
||||
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { Picker } from "./Picker";
|
||||
@ -72,7 +67,6 @@ interface ColorPickerProps {
|
||||
palette?: ColorPaletteCustom | null;
|
||||
topPicks?: ColorTuple;
|
||||
updateData: (formData?: any) => void;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
const ColorPickerPopupContent = ({
|
||||
@ -83,8 +77,6 @@ const ColorPickerPopupContent = ({
|
||||
elements,
|
||||
palette = COLOR_PALETTE,
|
||||
updateData,
|
||||
getOpenPopup,
|
||||
appState,
|
||||
}: Pick<
|
||||
ColorPickerProps,
|
||||
| "type"
|
||||
@ -94,10 +86,7 @@ const ColorPickerPopupContent = ({
|
||||
| "elements"
|
||||
| "palette"
|
||||
| "updateData"
|
||||
| "appState"
|
||||
> & {
|
||||
getOpenPopup: () => AppState["openPopup"];
|
||||
}) => {
|
||||
>) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
@ -128,8 +117,6 @@ const ColorPickerPopupContent = ({
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
@ -144,23 +131,8 @@ const ColorPickerPopupContent = ({
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
// only clear if we're still the active popup (avoid racing with switch)
|
||||
if (getOpenPopup() === type) {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
setActiveColorPickerSection(null);
|
||||
|
||||
// Refocus text editor when popover closes if we were editing text
|
||||
if (appState.editingTextElement) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
@ -169,17 +141,7 @@ const ColorPickerPopupContent = ({
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
// Save caret position before color change if editing text
|
||||
const savedSelection = appState.editingTextElement
|
||||
? saveCaretPosition()
|
||||
: null;
|
||||
|
||||
onChange(changedColor);
|
||||
|
||||
// Restore caret position after color change if editing text
|
||||
if (appState.editingTextElement && savedSelection) {
|
||||
restoreCaretPosition(savedSelection);
|
||||
}
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
@ -206,7 +168,6 @@ const ColorPickerPopupContent = ({
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else {
|
||||
// close explicitly on Escape
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
@ -227,32 +188,11 @@ const ColorPickerTrigger = ({
|
||||
label,
|
||||
color,
|
||||
type,
|
||||
compactMode = false,
|
||||
mode = "background",
|
||||
onToggle,
|
||||
editingTextElement,
|
||||
}: {
|
||||
color: string | null;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
compactMode?: boolean;
|
||||
mode?: "background" | "stroke";
|
||||
onToggle: () => void;
|
||||
editingTextElement?: boolean;
|
||||
}) => {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// use pointerdown so we run before outside-close logic
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// If editing text, temporarily disable the wysiwyg blur event
|
||||
if (editingTextElement) {
|
||||
temporarilyDisableTextEditorBlur();
|
||||
}
|
||||
|
||||
onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
@ -268,37 +208,8 @@ const ColorPickerTrigger = ({
|
||||
? t("labels.showStroke")
|
||||
: t("labels.showBackground")
|
||||
}
|
||||
data-openpopup={type}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||
{compactMode && color && (
|
||||
<div className="color-picker__button-background">
|
||||
{mode === "background" ? (
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
? "#fff"
|
||||
: "#111",
|
||||
}}
|
||||
>
|
||||
{backgroundIcon}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
? "#fff"
|
||||
: "#111",
|
||||
}}
|
||||
>
|
||||
{strokeIcon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
||||
@ -313,59 +224,25 @@ export const ColorPicker = ({
|
||||
topPicks,
|
||||
updateData,
|
||||
appState,
|
||||
compactMode = false,
|
||||
}: ColorPickerProps) => {
|
||||
const openRef = useRef(appState.openPopup);
|
||||
useEffect(() => {
|
||||
openRef.current = appState.openPopup;
|
||||
}, [appState.openPopup]);
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={clsx("color-picker-container", {
|
||||
"color-picker-container--no-top-picks": compactMode,
|
||||
})}
|
||||
>
|
||||
{!compactMode && (
|
||||
<div role="dialog" aria-modal="true" className="color-picker-container">
|
||||
<TopPicks
|
||||
activeColor={color}
|
||||
onChange={onChange}
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
/>
|
||||
)}
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<ButtonSeparator />
|
||||
<Popover.Root
|
||||
open={appState.openPopup === type}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
updateData({ openPopup: type });
|
||||
}
|
||||
updateData({ openPopup: open ? type : null });
|
||||
}}
|
||||
>
|
||||
{/* serves as an active color indicator as well */}
|
||||
<ColorPickerTrigger
|
||||
color={color}
|
||||
label={label}
|
||||
type={type}
|
||||
compactMode={compactMode}
|
||||
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||
editingTextElement={!!appState.editingTextElement}
|
||||
onToggle={() => {
|
||||
// atomic switch: if another popup is open, close it first, then open this one next tick
|
||||
if (appState.openPopup === type) {
|
||||
// toggle off on same trigger
|
||||
updateData({ openPopup: null });
|
||||
} else if (appState.openPopup) {
|
||||
updateData({ openPopup: type });
|
||||
} else {
|
||||
// open this one
|
||||
updateData({ openPopup: type });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ColorPickerTrigger color={color} label={label} type={type} />
|
||||
{/* popup content */}
|
||||
{appState.openPopup === type && (
|
||||
<ColorPickerPopupContent
|
||||
@ -376,8 +253,6 @@ export const ColorPicker = ({
|
||||
elements={elements}
|
||||
palette={palette}
|
||||
updateData={updateData}
|
||||
getOpenPopup={() => openRef.current}
|
||||
appState={appState}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
|
||||
@ -11,10 +11,5 @@
|
||||
2rem + 4 * var(--default-button-size)
|
||||
); // 4 gaps + 4 buttons
|
||||
}
|
||||
|
||||
&--compact {
|
||||
display: block;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
@ -59,7 +58,6 @@ interface FontPickerProps {
|
||||
onHover: (fontFamily: FontFamilyValues) => void;
|
||||
onLeave: () => void;
|
||||
onPopupChange: (open: boolean) => void;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
export const FontPicker = React.memo(
|
||||
@ -71,7 +69,6 @@ export const FontPicker = React.memo(
|
||||
onHover,
|
||||
onLeave,
|
||||
onPopupChange,
|
||||
compactMode = false,
|
||||
}: FontPickerProps) => {
|
||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||
const onSelectCallback = useCallback(
|
||||
@ -84,14 +81,7 @@ export const FontPicker = React.memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={clsx("FontPicker__container", {
|
||||
"FontPicker__container--compact": compactMode,
|
||||
})}
|
||||
>
|
||||
{!compactMode && (
|
||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
||||
<div className="buttonList">
|
||||
<RadioSelection<FontFamilyValues | false>
|
||||
type="button"
|
||||
@ -100,13 +90,9 @@ export const FontPicker = React.memo(
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<ButtonSeparator />
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
isOpened={isOpened}
|
||||
/>
|
||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
|
||||
@ -90,8 +90,7 @@ export const FontPickerList = React.memo(
|
||||
onClose,
|
||||
}: FontPickerListProps) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const app = useApp();
|
||||
const { fonts } = app;
|
||||
const { fonts } = useApp();
|
||||
const { showDeprecatedFonts } = useAppProps();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@ -188,42 +187,6 @@ export const FontPickerList = React.memo(
|
||||
onLeave,
|
||||
]);
|
||||
|
||||
// Create a wrapped onSelect function that preserves caret position
|
||||
const wrappedOnSelect = useCallback(
|
||||
(fontFamily: FontFamilyValues) => {
|
||||
// Save caret position before font selection if editing text
|
||||
let savedSelection: { start: number; end: number } | null = null;
|
||||
if (app.state.editingTextElement) {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
savedSelection = {
|
||||
start: textEditor.selectionStart,
|
||||
end: textEditor.selectionEnd,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onSelect(fontFamily);
|
||||
|
||||
// Restore caret position after font selection if editing text
|
||||
if (app.state.editingTextElement && savedSelection) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor && savedSelection) {
|
||||
textEditor.focus();
|
||||
textEditor.selectionStart = savedSelection.start;
|
||||
textEditor.selectionEnd = savedSelection.end;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[onSelect, app.state.editingTextElement],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
const handled = fontPickerKeyHandler({
|
||||
@ -231,7 +194,7 @@ export const FontPickerList = React.memo(
|
||||
inputRef,
|
||||
hoveredFont,
|
||||
filteredFonts,
|
||||
onSelect: wrappedOnSelect,
|
||||
onSelect,
|
||||
onHover,
|
||||
onClose,
|
||||
});
|
||||
@ -241,7 +204,7 @@ export const FontPickerList = React.memo(
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
|
||||
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -277,7 +240,7 @@ export const FontPickerList = React.memo(
|
||||
// allow to tab between search and selected font
|
||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
wrappedOnSelect(Number(e.currentTarget.value));
|
||||
onSelect(Number(e.currentTarget.value));
|
||||
}}
|
||||
onMouseMove={() => {
|
||||
if (hoveredFont?.value !== font.value) {
|
||||
@ -319,24 +282,9 @@ export const FontPickerList = React.memo(
|
||||
className="properties-content"
|
||||
container={container}
|
||||
style={{ width: "15rem" }}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (app.state.editingTextElement) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onClose={onClose}
|
||||
onPointerLeave={onLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||
>
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||
|
||||
@ -6,38 +7,33 @@ import { t } from "../../i18n";
|
||||
import { ButtonIcon } from "../ButtonIcon";
|
||||
import { TextIcon } from "../icons";
|
||||
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { isDefaultFont } from "./FontPicker";
|
||||
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
isOpened?: boolean;
|
||||
}
|
||||
|
||||
export const FontPickerTrigger = ({
|
||||
selectedFontFamily,
|
||||
isOpened = false,
|
||||
}: FontPickerTriggerProps) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const isTriggerActive = useMemo(
|
||||
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
||||
[selectedFontFamily],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<div data-openpopup="fontFamily" className="properties-trigger">
|
||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
||||
<div>
|
||||
<ButtonIcon
|
||||
standalone
|
||||
icon={TextIcon}
|
||||
title={t("labels.showFonts")}
|
||||
className="properties-trigger"
|
||||
testId={"font-family-show-fonts"}
|
||||
active={isOpened}
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
openPopup:
|
||||
appState.openPopup === "fontFamily" ? null : appState.openPopup,
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
}}
|
||||
active={isTriggerActive}
|
||||
// no-op
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
|
||||
@ -24,10 +24,6 @@
|
||||
gap: 0.75rem;
|
||||
pointer-events: none !important;
|
||||
|
||||
&--compact {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import React from "react";
|
||||
import {
|
||||
CLASSES,
|
||||
DEFAULT_SIDEBAR,
|
||||
MQ_MIN_WIDTH_DESKTOP,
|
||||
TOOL_TYPE,
|
||||
arrayToMap,
|
||||
capitalizeString,
|
||||
@ -29,11 +28,7 @@ import { useAtom, useAtomValue } from "../editor-jotai";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
|
||||
import {
|
||||
SelectedShapeActions,
|
||||
ShapesSwitcher,
|
||||
CompactShapeActions,
|
||||
} from "./Actions";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
@ -162,25 +157,6 @@ const LayerUI = ({
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
|
||||
const spacing =
|
||||
appState.stylesPanelMode === "compact"
|
||||
? {
|
||||
menuTopGap: 4,
|
||||
toolbarColGap: 4,
|
||||
toolbarRowGap: 1,
|
||||
toolbarInnerRowGap: 0.5,
|
||||
islandPadding: 1,
|
||||
collabMarginLeft: 8,
|
||||
}
|
||||
: {
|
||||
menuTopGap: 6,
|
||||
toolbarColGap: 4,
|
||||
toolbarRowGap: 1,
|
||||
toolbarInnerRowGap: 1,
|
||||
islandPadding: 1,
|
||||
collabMarginLeft: 8,
|
||||
};
|
||||
|
||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||
@ -233,35 +209,13 @@ const LayerUI = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectedShapeActions = () => {
|
||||
const isCompactMode = appState.stylesPanelMode === "compact";
|
||||
|
||||
return (
|
||||
const renderSelectedShapeActions = () => (
|
||||
<Section
|
||||
heading="selectedShapeActions"
|
||||
className={clsx("selected-shape-actions zen-mode-transition", {
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{isCompactMode ? (
|
||||
<Island
|
||||
className={clsx("compact-shape-actions-island")}
|
||||
padding={0}
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so subtracting the
|
||||
// approximate height of hamburgerMenu + footer
|
||||
maxHeight: `${appState.height - 166}px`,
|
||||
}}
|
||||
>
|
||||
<CompactShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Island>
|
||||
) : (
|
||||
<Island
|
||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||
padding={2}
|
||||
@ -278,10 +232,8 @@ const LayerUI = ({
|
||||
app={app}
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFixedSideContainer = () => {
|
||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||
@ -298,19 +250,9 @@ const LayerUI = ({
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={spacing.menuTopGap}
|
||||
className={clsx("App-menu_top__left")}
|
||||
>
|
||||
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
||||
{renderCanvasActions()}
|
||||
<div
|
||||
className={clsx("selected-shape-actions-container", {
|
||||
"selected-shape-actions-container--compact":
|
||||
appState.stylesPanelMode === "compact",
|
||||
})}
|
||||
>
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</div>
|
||||
</Stack.Col>
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||
@ -320,19 +262,17 @@ const LayerUI = ({
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<Stack.Col gap={spacing.toolbarColGap} align="start">
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
gap={spacing.toolbarRowGap}
|
||||
gap={1}
|
||||
className={clsx("App-toolbar-container", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Island
|
||||
padding={spacing.islandPadding}
|
||||
padding={1}
|
||||
className={clsx("App-toolbar", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
"App-toolbar--compact":
|
||||
appState.stylesPanelMode === "compact",
|
||||
})}
|
||||
>
|
||||
<HintViewer
|
||||
@ -342,7 +282,7 @@ const LayerUI = ({
|
||||
app={app}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={spacing.toolbarInnerRowGap}>
|
||||
<Stack.Row gap={1}>
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
@ -376,7 +316,7 @@ const LayerUI = ({
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: spacing.collabMarginLeft,
|
||||
marginLeft: 8,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
@ -404,8 +344,6 @@ const LayerUI = ({
|
||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||
{
|
||||
"transition-right": appState.zenModeEnabled,
|
||||
"layer-ui__wrapper__top-right--compact":
|
||||
appState.stylesPanelMode === "compact",
|
||||
},
|
||||
)}
|
||||
>
|
||||
@ -480,9 +418,7 @@ const LayerUI = ({
|
||||
}}
|
||||
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||
>
|
||||
{appState.stylesPanelMode === "full" &&
|
||||
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
|
||||
t("toolBar.library")}
|
||||
{t("toolBar.library")}
|
||||
</DefaultSidebar.Trigger>
|
||||
<DefaultOverwriteConfirmDialog />
|
||||
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
||||
@ -613,7 +549,7 @@ const LayerUI = ({
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
{appState.scrolledOutside && (
|
||||
{appState.scrolledOutside && !appState.scrollConstraints && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
|
||||
@ -195,7 +195,8 @@ export const MobileMenu = ({
|
||||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
!appState.openSidebar && (
|
||||
!appState.openSidebar &&
|
||||
!appState.scrollConstraints && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
|
||||
@ -17,7 +17,6 @@ interface PropertiesPopoverProps {
|
||||
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
||||
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
|
||||
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
|
||||
preventAutoFocusOnTouch?: boolean;
|
||||
}
|
||||
|
||||
export const PropertiesPopover = React.forwardRef<
|
||||
@ -35,7 +34,6 @@ export const PropertiesPopover = React.forwardRef<
|
||||
onFocusOutside,
|
||||
onPointerLeave,
|
||||
onPointerDownOutside,
|
||||
preventAutoFocusOnTouch = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@ -66,12 +64,6 @@ export const PropertiesPopover = React.forwardRef<
|
||||
onKeyDown={onKeyDown}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onOpenAutoFocus={(e) => {
|
||||
// prevent auto-focus on touch devices to avoid keyboard popup
|
||||
if (preventAutoFocusOnTouch && device.isTouchScreen) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
|
||||
@ -10,16 +10,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--compact {
|
||||
.ToolIcon__keybinding {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.App-toolbar__divider {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
|
||||
@ -118,17 +118,6 @@ export const DotsIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical)
|
||||
export const DotsHorizontalIcon = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// tabler-icons: pinned
|
||||
export const PinIcon = createIcon(
|
||||
<svg strokeWidth="1.5">
|
||||
@ -407,19 +396,6 @@ export const TextIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const TextSizeIcon = createIcon(
|
||||
<g stroke="currentColor" strokeWidth="1.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 7v-2h13v2" />
|
||||
<path d="M10 5v14" />
|
||||
<path d="M12 19h-4" />
|
||||
<path d="M15 13v-1h6v1" />
|
||||
<path d="M18 12v7" />
|
||||
<path d="M17 19h2" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// modified tabler-icons: photo
|
||||
export const ImageIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
@ -2293,48 +2269,3 @@ export const elementLinkIcon = createIcon(
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const resizeIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" />
|
||||
<path d="M4 12h7a1 1 0 0 1 1 1v7" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const adjustmentsIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 6l8 0" />
|
||||
<path d="M16 6l4 0" />
|
||||
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 12l2 0" />
|
||||
<path d="M10 12l10 0" />
|
||||
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 18l11 0" />
|
||||
<path d="M19 18l1 0" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const backgroundIcon = createIcon(
|
||||
<g strokeWidth={1}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 10l4 -4" />
|
||||
<path d="M6 14l8 -8" />
|
||||
<path d="M6 18l12 -12" />
|
||||
<path d="M10 18l8 -8" />
|
||||
<path d="M14 18l4 -4" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const strokeIcon = createIcon(
|
||||
<g strokeWidth={1}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<rect x="6" y="6" width="12" height="12" fill="none" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { Button } from "../Button";
|
||||
import { share } from "../icons";
|
||||
@ -19,8 +17,7 @@ const LiveCollaborationTrigger = ({
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const appState = useUIAppState();
|
||||
|
||||
const showIconOnly =
|
||||
isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP;
|
||||
const showIconOnly = appState.width < 830;
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@ -13,8 +13,6 @@ import {
|
||||
EraserIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const SHAPES = [
|
||||
{
|
||||
icon: SelectionIcon,
|
||||
@ -88,23 +86,8 @@ export const SHAPES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
return app.defaultSelectionTool === "lasso"
|
||||
? ([
|
||||
{
|
||||
value: "lasso",
|
||||
icon: SelectionIcon,
|
||||
key: KEYS.V,
|
||||
numericKey: KEYS["1"],
|
||||
fillable: true,
|
||||
},
|
||||
...SHAPES.slice(1),
|
||||
] as const)
|
||||
: SHAPES;
|
||||
};
|
||||
|
||||
export const findShapeByKey = (key: string, app: AppClassProperties) => {
|
||||
const shape = getToolbarTools(app).find((shape, index) => {
|
||||
export const findShapeByKey = (key: string) => {
|
||||
const shape = SHAPES.find((shape, index) => {
|
||||
return (
|
||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||
(shape.key &&
|
||||
|
||||
@ -317,7 +317,7 @@ body.excalidraw-cursor-resize * {
|
||||
|
||||
.App-menu_top {
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
grid-gap: 2rem;
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
pointer-events: none !important;
|
||||
@ -336,14 +336,6 @@ body.excalidraw-cursor-resize * {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.selected-shape-actions-container {
|
||||
width: fit-content;
|
||||
|
||||
&--compact {
|
||||
min-width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.App-menu_top > *:last-child {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
@ -96,8 +96,6 @@ export const getMimeType = (blob: Blob | string): string => {
|
||||
return MIME_TYPES.jpg;
|
||||
} else if (/\.svg$/.test(name)) {
|
||||
return MIME_TYPES.svg;
|
||||
} else if (/\.excalidrawlib$/.test(name)) {
|
||||
return MIME_TYPES.excalidrawlib;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@ -172,11 +170,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
@ -391,18 +385,23 @@ export const ImageURLToFile = async (
|
||||
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
||||
};
|
||||
|
||||
export const getFileFromEvent = async (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const file = event.dataTransfer.files.item(0);
|
||||
const fileHandle = await getFileHandle(event);
|
||||
|
||||
return { file: file ? await normalizeFile(file) : null, fileHandle };
|
||||
};
|
||||
|
||||
export const getFileHandle = async (
|
||||
event: DragEvent | React.DragEvent | DataTransferItem,
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
): Promise<FileSystemHandle | null> => {
|
||||
if (nativeFileSystemSupported) {
|
||||
try {
|
||||
const dataTransferItem =
|
||||
event instanceof DataTransferItem
|
||||
? event
|
||||
: (event as DragEvent).dataTransfer?.items?.[0];
|
||||
|
||||
const item = event.dataTransfer.items[0];
|
||||
const handle: FileSystemHandle | null =
|
||||
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
|
||||
(await (item as any).getAsFileSystemHandle()) || null;
|
||||
|
||||
return handle;
|
||||
} catch (error: any) {
|
||||
|
||||
@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
||||
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||
MakeBrand<"RemoteExcalidrawElement">;
|
||||
|
||||
export const shouldDiscardRemoteElement = (
|
||||
const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: OrderedExcalidrawElement | undefined,
|
||||
remote: RemoteExcalidrawElement,
|
||||
@ -30,7 +30,7 @@ export const shouldDiscardRemoteElement = (
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingTextElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.newElement?.id ||
|
||||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
|
||||
@ -80,7 +80,7 @@ import type { ImportedDataState, LegacyAppState } from "./types";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
"offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
|
||||
>;
|
||||
|
||||
export const AllowedExcalidrawActiveTools: Record<
|
||||
@ -241,9 +241,8 @@ const restoreElementWithProperties = <
|
||||
return ret;
|
||||
};
|
||||
|
||||
export const restoreElement = (
|
||||
const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
opts?: { deleteInvisibleElements?: boolean },
|
||||
): typeof element | null => {
|
||||
element = { ...element };
|
||||
|
||||
@ -291,8 +290,7 @@ export const restoreElement = (
|
||||
|
||||
// if empty text, mark as deleted. We keep in array
|
||||
// for data integrity purposes (collab etc.)
|
||||
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
|
||||
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
|
||||
if (!text && !element.isDeleted) {
|
||||
element = { ...element, originalText: text, isDeleted: true };
|
||||
element = bumpVersion(element);
|
||||
}
|
||||
@ -387,10 +385,7 @@ export const restoreElement = (
|
||||
elbowed: true,
|
||||
startBinding: repairBinding(element, element.startBinding),
|
||||
endBinding: repairBinding(element, element.endBinding),
|
||||
fixedSegments:
|
||||
element.fixedSegments?.length && base.points.length >= 4
|
||||
? element.fixedSegments
|
||||
: null,
|
||||
fixedSegments: element.fixedSegments,
|
||||
startIsSpecial: element.startIsSpecial,
|
||||
endIsSpecial: element.endIsSpecial,
|
||||
})
|
||||
@ -528,13 +523,7 @@ export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
opts?:
|
||||
| {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
@ -543,30 +532,16 @@ export const restoreElements = (
|
||||
(elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type === "selection") {
|
||||
return elements;
|
||||
}
|
||||
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
|
||||
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
||||
});
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
|
||||
const shouldMarkAsDeleted =
|
||||
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
||||
|
||||
if (
|
||||
shouldMarkAsDeleted ||
|
||||
(localElement && localElement.version > migratedElement.version)
|
||||
) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(
|
||||
migratedElement,
|
||||
localElement.version,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldMarkAsDeleted) {
|
||||
migratedElement = { ...migratedElement, isDeleted: true };
|
||||
}
|
||||
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
@ -574,7 +549,7 @@ export const restoreElements = (
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
|
||||
}
|
||||
return elements;
|
||||
}, [] as ExcalidrawElement[]),
|
||||
);
|
||||
@ -815,11 +790,7 @@ export const restore = (
|
||||
*/
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
elementsConfig?: {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
},
|
||||
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
||||
|
||||
@ -1,26 +1,11 @@
|
||||
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
distanceToElement,
|
||||
doBoundsIntersect,
|
||||
getBoundTextElement,
|
||||
getElementBounds,
|
||||
getFreedrawOutlineAsSegments,
|
||||
getFreedrawOutlinePoints,
|
||||
intersectElementWithLineSegment,
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
isLineElement,
|
||||
isPointInElement,
|
||||
} from "@excalidraw/element";
|
||||
import {
|
||||
lineSegment,
|
||||
lineSegmentsDistance,
|
||||
pointFrom,
|
||||
polygon,
|
||||
polygonIncludesPointNonZero,
|
||||
} from "@excalidraw/math";
|
||||
import { lineSegment, pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
|
||||
@ -28,8 +13,6 @@ import { shouldTestInside } from "@excalidraw/element";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
|
||||
import { getBoundTextElementId } from "@excalidraw/element";
|
||||
|
||||
import type { Bounds } from "@excalidraw/element";
|
||||
|
||||
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@ -113,7 +96,6 @@ export class EraserTrail extends AnimatedTrail {
|
||||
pathSegment,
|
||||
element,
|
||||
candidateElementsMap,
|
||||
this.app.state.zoom.value,
|
||||
);
|
||||
|
||||
if (intersects) {
|
||||
@ -149,7 +131,6 @@ export class EraserTrail extends AnimatedTrail {
|
||||
pathSegment,
|
||||
element,
|
||||
candidateElementsMap,
|
||||
this.app.state.zoom.value,
|
||||
);
|
||||
|
||||
if (intersects) {
|
||||
@ -199,33 +180,8 @@ const eraserTest = (
|
||||
pathSegment: LineSegment<GlobalPoint>,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: number,
|
||||
): boolean => {
|
||||
const lastPoint = pathSegment[1];
|
||||
|
||||
// PERF: Do a quick bounds intersection test first because it's cheap
|
||||
const threshold = isFreeDrawElement(element) ? 15 : element.strokeWidth / 2;
|
||||
const segmentBounds = [
|
||||
Math.min(pathSegment[0][0], pathSegment[1][0]) - threshold,
|
||||
Math.min(pathSegment[0][1], pathSegment[1][1]) - threshold,
|
||||
Math.max(pathSegment[0][0], pathSegment[1][0]) + threshold,
|
||||
Math.max(pathSegment[0][1], pathSegment[1][1]) + threshold,
|
||||
] as Bounds;
|
||||
const origElementBounds = getElementBounds(element, elementsMap);
|
||||
const elementBounds: Bounds = [
|
||||
origElementBounds[0] - threshold,
|
||||
origElementBounds[1] - threshold,
|
||||
origElementBounds[2] + threshold,
|
||||
origElementBounds[3] + threshold,
|
||||
];
|
||||
|
||||
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// There are shapes where the inner area should trigger erasing
|
||||
// even though the eraser path segment doesn't intersect with or
|
||||
// get close to the shape's stroke
|
||||
if (
|
||||
shouldTestInside(element) &&
|
||||
isPointInElement(lastPoint, element, elementsMap)
|
||||
@ -233,50 +189,6 @@ const eraserTest = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// Freedraw elements are tested for erasure by measuring the distance
|
||||
// of the eraser path and the freedraw shape outline lines to a tolerance
|
||||
// which offers a good visual precision at various zoom levels
|
||||
if (isFreeDrawElement(element)) {
|
||||
const outlinePoints = getFreedrawOutlinePoints(element);
|
||||
const strokeSegments = getFreedrawOutlineAsSegments(
|
||||
element,
|
||||
outlinePoints,
|
||||
elementsMap,
|
||||
);
|
||||
const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation
|
||||
|
||||
for (const seg of strokeSegments) {
|
||||
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const poly = polygon(
|
||||
...(outlinePoints.map(([x, y]) =>
|
||||
pointFrom<GlobalPoint>(element.x + x, element.y + y),
|
||||
) as GlobalPoint[]),
|
||||
);
|
||||
|
||||
// PERF: Check only one point of the eraser segment. If the eraser segment
|
||||
// start is inside the closed freedraw shape, the other point is either also
|
||||
// inside or the eraser segment will intersect the shape outline anyway
|
||||
if (polygonIncludesPointNonZero(pathSegment[0], poly)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} else if (
|
||||
isArrowElement(element) ||
|
||||
(isLineElement(element) && !element.polygon)
|
||||
) {
|
||||
const tolerance = Math.max(
|
||||
element.strokeWidth,
|
||||
(element.strokeWidth * 2) / zoom,
|
||||
);
|
||||
|
||||
return distanceToElement(element, elementsMap, lastPoint) <= tolerance;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
return (
|
||||
|
||||
@ -175,7 +175,7 @@ export class History {
|
||||
let nextAppState = appState;
|
||||
let containsVisibleChange = false;
|
||||
|
||||
// iterate through the history entries in case they result in no visible changes
|
||||
// iterate through the history entries in case ;they result in no visible changes
|
||||
while (historyDelta) {
|
||||
try {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
// Utility type for caret position
|
||||
export type CaretPosition = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
// Utility function to get text editor element
|
||||
const getTextEditor = (): HTMLTextAreaElement | null => {
|
||||
return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
// Utility functions for caret position management
|
||||
export const saveCaretPosition = (): CaretPosition | null => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
return {
|
||||
start: textEditor.selectionStart,
|
||||
end: textEditor.selectionEnd,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const restoreCaretPosition = (position: CaretPosition | null): void => {
|
||||
setTimeout(() => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
if (position) {
|
||||
textEditor.selectionStart = position.start;
|
||||
textEditor.selectionEnd = position.end;
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const withCaretPositionPreservation = (
|
||||
callback: () => void,
|
||||
isCompactMode: boolean,
|
||||
isEditingText: boolean,
|
||||
onPreventClose?: () => void,
|
||||
): void => {
|
||||
// Prevent popover from closing in compact mode
|
||||
if (isCompactMode && onPreventClose) {
|
||||
onPreventClose();
|
||||
}
|
||||
|
||||
// Save caret position if editing text
|
||||
const savedPosition =
|
||||
isCompactMode && isEditingText ? saveCaretPosition() : null;
|
||||
|
||||
// Execute the callback
|
||||
callback();
|
||||
|
||||
// Restore caret position if needed
|
||||
if (isCompactMode && isEditingText) {
|
||||
restoreCaretPosition(savedPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// Hook for managing text editor caret position with state
|
||||
export const useTextEditorFocus = () => {
|
||||
const [savedCaretPosition, setSavedCaretPosition] =
|
||||
useState<CaretPosition | null>(null);
|
||||
|
||||
const saveCaretPositionToState = useCallback(() => {
|
||||
const position = saveCaretPosition();
|
||||
setSavedCaretPosition(position);
|
||||
}, []);
|
||||
|
||||
const restoreCaretPositionFromState = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
if (savedCaretPosition) {
|
||||
textEditor.selectionStart = savedCaretPosition.start;
|
||||
textEditor.selectionEnd = savedCaretPosition.end;
|
||||
setSavedCaretPosition(null);
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}, [savedCaretPosition]);
|
||||
|
||||
const clearSavedPosition = useCallback(() => {
|
||||
setSavedCaretPosition(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
saveCaretPosition: saveCaretPositionToState,
|
||||
restoreCaretPosition: restoreCaretPositionFromState,
|
||||
clearSavedPosition,
|
||||
hasSavedPosition: !!savedCaretPosition,
|
||||
};
|
||||
};
|
||||
|
||||
// Utility function to temporarily disable text editor blur
|
||||
export const temporarilyDisableTextEditorBlur = (
|
||||
duration: number = 100,
|
||||
): void => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
const originalOnBlur = textEditor.onblur;
|
||||
textEditor.onblur = null;
|
||||
|
||||
setTimeout(() => {
|
||||
textEditor.onblur = originalOnBlur;
|
||||
}, duration);
|
||||
}
|
||||
};
|
||||
@ -67,6 +67,7 @@ const canvas = exportToCanvas(
|
||||
offsetLeft: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
scrollConstraints: null,
|
||||
},
|
||||
{}, // files
|
||||
{
|
||||
|
||||
@ -50,6 +50,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onScrollChange,
|
||||
onDuplicate,
|
||||
children,
|
||||
scrollConstraints,
|
||||
validateEmbeddable,
|
||||
renderEmbeddable,
|
||||
aiEnabled,
|
||||
@ -122,7 +123,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onPointerUpdate={onPointerUpdate}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
langCode={langCode}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
viewModeEnabled={viewModeEnabled ?? !!scrollConstraints}
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
gridModeEnabled={gridModeEnabled}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
@ -141,6 +142,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onScrollChange={onScrollChange}
|
||||
scrollConstraints={scrollConstraints}
|
||||
onDuplicate={onDuplicate}
|
||||
validateEmbeddable={validateEmbeddable}
|
||||
renderEmbeddable={renderEmbeddable}
|
||||
@ -229,7 +231,6 @@ export { defaultLang, useI18n, languages } from "./i18n";
|
||||
export {
|
||||
restore,
|
||||
restoreAppState,
|
||||
restoreElement,
|
||||
restoreElements,
|
||||
restoreLibraryItems,
|
||||
} from "./data/restore";
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
"@excalidraw/element": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.1.3",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"@radix-ui/react-popover": "1.1.6",
|
||||
"@radix-ui/react-tabs": "1.1.3",
|
||||
|
||||
552
packages/excalidraw/scene/scrollConstraints.ts
Normal file
552
packages/excalidraw/scene/scrollConstraints.ts
Normal file
@ -0,0 +1,552 @@
|
||||
import { isShallowEqual } from "@excalidraw/common";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
|
||||
import { getNormalizedZoom } from "./normalize";
|
||||
|
||||
import type {
|
||||
AnimateTranslateCanvasValues,
|
||||
AppState,
|
||||
ScrollConstraints,
|
||||
} from "../types";
|
||||
|
||||
// Constants for viewport zoom factor and overscroll allowance
|
||||
const MIN_VIEWPORT_ZOOM_FACTOR = 0.1;
|
||||
const MAX_VIEWPORT_ZOOM_FACTOR = 1;
|
||||
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.2;
|
||||
const DEFAULT_OVERSCROLL_ALLOWANCE = 0.2;
|
||||
|
||||
// Memoization variable to cache constraints for performance optimization
|
||||
let memoizedValues: {
|
||||
previousState: Pick<
|
||||
AppState,
|
||||
"zoom" | "width" | "height" | "scrollConstraints"
|
||||
>;
|
||||
constraints: ReturnType<typeof calculateConstraints>;
|
||||
allowOverscroll: boolean;
|
||||
} | null = null;
|
||||
|
||||
type CanvasTranslate = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
|
||||
|
||||
/**
|
||||
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
|
||||
*
|
||||
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
|
||||
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
|
||||
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
|
||||
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
|
||||
*
|
||||
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
|
||||
* @param width - The width of the viewport.
|
||||
* @param height - The height of the viewport.
|
||||
* @returns An object containing the calculated zoom levels for the X and Y axes, and the initial zoom level.
|
||||
*/
|
||||
const calculateZoomLevel = (
|
||||
scrollConstraints: ScrollConstraints,
|
||||
width: AppState["width"],
|
||||
height: AppState["height"],
|
||||
) => {
|
||||
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
|
||||
? clamp(
|
||||
scrollConstraints.viewportZoomFactor,
|
||||
MIN_VIEWPORT_ZOOM_FACTOR,
|
||||
MAX_VIEWPORT_ZOOM_FACTOR,
|
||||
)
|
||||
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
|
||||
|
||||
const scrollableWidth = scrollConstraints.width;
|
||||
const scrollableHeight = scrollConstraints.height;
|
||||
const zoomLevelX = width / scrollableWidth;
|
||||
const zoomLevelY = height / scrollableHeight;
|
||||
const initialZoomLevel = getNormalizedZoom(
|
||||
Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor,
|
||||
);
|
||||
return { zoomLevelX, zoomLevelY, initialZoomLevel };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the effective zoom level based on the scroll constraints and current zoom.
|
||||
*
|
||||
* @param params - Object containing scrollConstraints, width, height, and zoom.
|
||||
* @returns An object with the effective zoom level, initial zoom level, and zoom levels for X and Y axes.
|
||||
*/
|
||||
const calculateZoom = ({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
}: {
|
||||
scrollConstraints: ScrollConstraints;
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
zoom: AppState["zoom"];
|
||||
}) => {
|
||||
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const effectiveZoom = scrollConstraints.lockZoom
|
||||
? Math.max(initialZoomLevel, zoom.value)
|
||||
: zoom.value;
|
||||
return {
|
||||
effectiveZoom: getNormalizedZoom(effectiveZoom),
|
||||
initialZoomLevel,
|
||||
zoomLevelX,
|
||||
zoomLevelY,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the scroll bounds (min and max scroll values) based on the scroll constraints and zoom level.
|
||||
*
|
||||
* @param params - Object containing scrollConstraints, width, height, effectiveZoom, zoomLevelX, zoomLevelY, and allowOverscroll.
|
||||
* @returns An object with min and max scroll values for X and Y axes.
|
||||
*/
|
||||
const calculateScrollBounds = ({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
effectiveZoom,
|
||||
zoomLevelX,
|
||||
zoomLevelY,
|
||||
allowOverscroll,
|
||||
}: {
|
||||
scrollConstraints: ScrollConstraints;
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
effectiveZoom: number;
|
||||
zoomLevelX: number;
|
||||
zoomLevelY: number;
|
||||
allowOverscroll: boolean;
|
||||
}) => {
|
||||
const overscrollAllowance =
|
||||
scrollConstraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE;
|
||||
const validatedOverscroll = clamp(overscrollAllowance, 0, 1);
|
||||
|
||||
const calculateCenter = (zoom: number) => {
|
||||
const centerX =
|
||||
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
|
||||
const centerY =
|
||||
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
|
||||
return { centerX, centerY };
|
||||
};
|
||||
|
||||
const { centerX, centerY } = calculateCenter(effectiveZoom);
|
||||
|
||||
const overscrollValue = Math.min(
|
||||
validatedOverscroll * scrollConstraints.width,
|
||||
validatedOverscroll * scrollConstraints.height,
|
||||
);
|
||||
|
||||
const fitsX = effectiveZoom <= zoomLevelX;
|
||||
const fitsY = effectiveZoom <= zoomLevelY;
|
||||
|
||||
const getScrollRange = (
|
||||
axis: "x" | "y",
|
||||
fits: boolean,
|
||||
constraint: ScrollConstraints,
|
||||
viewportSize: number,
|
||||
zoom: number,
|
||||
overscroll: number,
|
||||
) => {
|
||||
const { pos, size } =
|
||||
axis === "x"
|
||||
? { pos: constraint.x, size: constraint.width }
|
||||
: { pos: constraint.y, size: constraint.height };
|
||||
const center = axis === "x" ? centerX : centerY;
|
||||
if (allowOverscroll) {
|
||||
return fits
|
||||
? { min: center - overscroll, max: center + overscroll }
|
||||
: {
|
||||
min: pos - size + viewportSize / zoom - overscroll,
|
||||
max: pos + overscroll,
|
||||
};
|
||||
}
|
||||
return fits
|
||||
? { min: center, max: center }
|
||||
: { min: pos - size + viewportSize / zoom, max: pos };
|
||||
};
|
||||
|
||||
const xRange = getScrollRange(
|
||||
"x",
|
||||
fitsX,
|
||||
scrollConstraints,
|
||||
width,
|
||||
effectiveZoom,
|
||||
overscrollValue,
|
||||
);
|
||||
const yRange = getScrollRange(
|
||||
"y",
|
||||
fitsY,
|
||||
scrollConstraints,
|
||||
height,
|
||||
effectiveZoom,
|
||||
overscrollValue,
|
||||
);
|
||||
|
||||
return {
|
||||
minScrollX: xRange.min,
|
||||
maxScrollX: xRange.max,
|
||||
minScrollY: yRange.min,
|
||||
maxScrollY: yRange.max,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the scroll constraints including min and max scroll values and the effective zoom level.
|
||||
*
|
||||
* @param params - Object containing scrollConstraints, width, height, zoom, and allowOverscroll.
|
||||
* @returns An object with min and max scroll values, effective zoom, and initial zoom level.
|
||||
*/
|
||||
const calculateConstraints = ({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
allowOverscroll,
|
||||
}: {
|
||||
scrollConstraints: ScrollConstraints;
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
zoom: AppState["zoom"];
|
||||
allowOverscroll: boolean;
|
||||
}) => {
|
||||
const { effectiveZoom, initialZoomLevel, zoomLevelX, zoomLevelY } =
|
||||
calculateZoom({ scrollConstraints, width, height, zoom });
|
||||
const scrollBounds = calculateScrollBounds({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
effectiveZoom,
|
||||
zoomLevelX,
|
||||
zoomLevelY,
|
||||
allowOverscroll,
|
||||
});
|
||||
|
||||
return {
|
||||
...scrollBounds,
|
||||
effectiveZoom: { value: effectiveZoom },
|
||||
initialZoomLevel,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Constrains the scroll values within the provided min and max bounds.
|
||||
*
|
||||
* @param params - Object containing scrollX, scrollY, minScrollX, maxScrollX, minScrollY, maxScrollY, and constrainedZoom.
|
||||
* @returns An object with constrained scrollX, scrollY, and zoom.
|
||||
*/
|
||||
const constrainScrollValues = ({
|
||||
scrollX,
|
||||
scrollY,
|
||||
minScrollX,
|
||||
maxScrollX,
|
||||
minScrollY,
|
||||
maxScrollY,
|
||||
constrainedZoom,
|
||||
}: {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
minScrollX: number;
|
||||
maxScrollX: number;
|
||||
minScrollY: number;
|
||||
maxScrollY: number;
|
||||
constrainedZoom: AppState["zoom"];
|
||||
}): CanvasTranslate => {
|
||||
const constrainedScrollX = clamp(scrollX, minScrollX, maxScrollX);
|
||||
const constrainedScrollY = clamp(scrollY, minScrollY, maxScrollY);
|
||||
return {
|
||||
scrollX: constrainedScrollX,
|
||||
scrollY: constrainedScrollY,
|
||||
zoom: constrainedZoom,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Inverts the scroll constraints to align with the state scrollX and scrollY values, which are inverted.
|
||||
* This is a temporary fix and should be removed once issue #5965 is resolved.
|
||||
*
|
||||
* @param originalScrollConstraints - The original scroll constraints.
|
||||
* @returns The aligned scroll constraints with inverted x and y coordinates.
|
||||
*/
|
||||
const alignScrollConstraints = (
|
||||
originalScrollConstraints: ScrollConstraints,
|
||||
): ScrollConstraints => {
|
||||
return {
|
||||
...originalScrollConstraints,
|
||||
x: originalScrollConstraints.x * -1,
|
||||
y: originalScrollConstraints.y * -1,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the current viewport is outside the constrained area.
|
||||
*
|
||||
* @param state - The application state.
|
||||
* @returns True if the viewport is outside the constrained area, false otherwise.
|
||||
*/
|
||||
const isViewportOutsideOfConstrainedArea = (state: AppState): boolean => {
|
||||
if (!state.scrollConstraints) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints: inverseScrollConstraints,
|
||||
zoom,
|
||||
} = state;
|
||||
|
||||
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
|
||||
|
||||
const adjustedWidth = width / zoom.value;
|
||||
const adjustedHeight = height / zoom.value;
|
||||
|
||||
return (
|
||||
scrollX > scrollConstraints.x ||
|
||||
scrollX - adjustedWidth < scrollConstraints.x - scrollConstraints.width ||
|
||||
scrollY > scrollConstraints.y ||
|
||||
scrollY - adjustedHeight < scrollConstraints.y - scrollConstraints.height
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
|
||||
*
|
||||
* @param state - The application state.
|
||||
* @param scroll - Object containing current scrollX and scrollY.
|
||||
* @returns An object with the calculated scrollX, scrollY, and zoom.
|
||||
*/
|
||||
export const calculateConstrainedScrollCenter = (
|
||||
state: AppState,
|
||||
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
|
||||
): CanvasTranslate => {
|
||||
const { width, height, scrollConstraints } = state;
|
||||
if (!scrollConstraints) {
|
||||
return { scrollX, scrollY, zoom: state.zoom };
|
||||
}
|
||||
|
||||
const adjustedConstraints = alignScrollConstraints(scrollConstraints);
|
||||
const zoomLevels = calculateZoomLevel(adjustedConstraints, width, height);
|
||||
const initialZoom = { value: zoomLevels.initialZoomLevel };
|
||||
const constraints = calculateConstraints({
|
||||
scrollConstraints: adjustedConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom: initialZoom,
|
||||
allowOverscroll: false,
|
||||
});
|
||||
|
||||
return {
|
||||
scrollX: constraints.minScrollX,
|
||||
scrollY: constraints.minScrollY,
|
||||
zoom: constraints.effectiveZoom,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Encodes scroll constraints into a compact string.
|
||||
*
|
||||
* @param constraints - The scroll constraints to encode.
|
||||
* @returns A compact encoded string representing the scroll constraints.
|
||||
*/
|
||||
export const encodeConstraints = (constraints: ScrollConstraints): string => {
|
||||
const payload = {
|
||||
x: constraints.x,
|
||||
y: constraints.y,
|
||||
w: constraints.width,
|
||||
h: constraints.height,
|
||||
a: !!constraints.animateOnNextUpdate,
|
||||
l: !!constraints.lockZoom,
|
||||
v: constraints.viewportZoomFactor ?? 1,
|
||||
oa: constraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE,
|
||||
};
|
||||
const serialized = JSON.stringify(payload);
|
||||
return encodeURIComponent(window.btoa(serialized).replace(/=+/, ""));
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a compact string back into scroll constraints.
|
||||
*
|
||||
* @param encoded - The encoded string representing the scroll constraints.
|
||||
* @returns The decoded scroll constraints object.
|
||||
*/
|
||||
export const decodeConstraints = (encoded: string): ScrollConstraints => {
|
||||
try {
|
||||
const decodedStr = window.atob(decodeURIComponent(encoded));
|
||||
const parsed = JSON.parse(decodedStr) as {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
a: boolean;
|
||||
l: boolean;
|
||||
v: number;
|
||||
oa: number;
|
||||
};
|
||||
return {
|
||||
x: parsed.x || 0,
|
||||
y: parsed.y || 0,
|
||||
width: parsed.w || 0,
|
||||
height: parsed.h || 0,
|
||||
lockZoom: parsed.l || false,
|
||||
viewportZoomFactor: parsed.v || 1,
|
||||
animateOnNextUpdate: parsed.a || false,
|
||||
overscrollAllowance: parsed.oa || DEFAULT_OVERSCROLL_ALLOWANCE,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
animateOnNextUpdate: false,
|
||||
lockZoom: false,
|
||||
viewportZoomFactor: 1,
|
||||
overscrollAllowance: DEFAULT_OVERSCROLL_ALLOWANCE,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type Options = { allowOverscroll: boolean; disableAnimation: boolean };
|
||||
const DEFAULT_OPTION: Options = {
|
||||
allowOverscroll: true,
|
||||
disableAnimation: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Constrains the AppState scroll values within the defined scroll constraints.
|
||||
*
|
||||
* constraintMode can be "elastic", "rigid", or "loose":
|
||||
* - "elastic": snaps to constraints but allows overscroll
|
||||
* - "rigid": snaps to constraints without overscroll
|
||||
* - "loose": allows overscroll and disables animation/snapping to constraints
|
||||
*
|
||||
* @param state - The original AppState.
|
||||
* @param options - Options for allowing overscroll and disabling animation.
|
||||
* @returns A new AppState object with constrained scroll values.
|
||||
*/
|
||||
export const constrainScrollState = (
|
||||
state: AppState,
|
||||
constraintMode: "elastic" | "rigid" | "loose" = "elastic",
|
||||
): AppState => {
|
||||
if (!state.scrollConstraints) {
|
||||
return state;
|
||||
}
|
||||
const {
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints: inverseScrollConstraints,
|
||||
zoom,
|
||||
} = state;
|
||||
|
||||
let allowOverscroll: boolean;
|
||||
let disableAnimation: boolean;
|
||||
|
||||
switch (constraintMode) {
|
||||
case "elastic":
|
||||
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
|
||||
break;
|
||||
case "rigid":
|
||||
allowOverscroll = false;
|
||||
disableAnimation = false;
|
||||
break;
|
||||
case "loose":
|
||||
allowOverscroll = true;
|
||||
disableAnimation = true;
|
||||
break;
|
||||
default:
|
||||
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
|
||||
break;
|
||||
}
|
||||
|
||||
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
|
||||
|
||||
const canUseMemoizedValues =
|
||||
memoizedValues &&
|
||||
memoizedValues.previousState.scrollConstraints &&
|
||||
memoizedValues.allowOverscroll === allowOverscroll &&
|
||||
isShallowEqual(
|
||||
state.scrollConstraints,
|
||||
memoizedValues.previousState.scrollConstraints,
|
||||
) &&
|
||||
isShallowEqual(
|
||||
{ zoom: zoom.value, width, height },
|
||||
{
|
||||
zoom: memoizedValues.previousState.zoom.value,
|
||||
width: memoizedValues.previousState.width,
|
||||
height: memoizedValues.previousState.height,
|
||||
},
|
||||
);
|
||||
|
||||
const constraints = canUseMemoizedValues
|
||||
? memoizedValues!.constraints
|
||||
: calculateConstraints({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
allowOverscroll,
|
||||
});
|
||||
|
||||
if (!canUseMemoizedValues) {
|
||||
memoizedValues = {
|
||||
previousState: {
|
||||
zoom: state.zoom,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
scrollConstraints: state.scrollConstraints,
|
||||
},
|
||||
constraints,
|
||||
allowOverscroll,
|
||||
};
|
||||
}
|
||||
|
||||
const constrainedValues =
|
||||
zoom.value >= constraints.effectiveZoom.value
|
||||
? constrainScrollValues({
|
||||
scrollX,
|
||||
scrollY,
|
||||
minScrollX: constraints.minScrollX,
|
||||
maxScrollX: constraints.maxScrollX,
|
||||
minScrollY: constraints.minScrollY,
|
||||
maxScrollY: constraints.maxScrollY,
|
||||
constrainedZoom: constraints.effectiveZoom,
|
||||
})
|
||||
: calculateConstrainedScrollCenter(state, { scrollX, scrollY });
|
||||
|
||||
return {
|
||||
...state,
|
||||
scrollConstraints: {
|
||||
...state.scrollConstraints,
|
||||
animateOnNextUpdate: disableAnimation
|
||||
? false
|
||||
: isViewportOutsideOfConstrainedArea(state),
|
||||
},
|
||||
...constrainedValues,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if two canvas translate values are close within a threshold.
|
||||
*
|
||||
* @param from - First set of canvas translate values.
|
||||
* @param to - Second set of canvas translate values.
|
||||
* @returns True if the values are close, false otherwise.
|
||||
*/
|
||||
export const areCanvasTranslatesClose = (
|
||||
from: AnimateTranslateCanvasValues,
|
||||
to: AnimateTranslateCanvasValues,
|
||||
): boolean => {
|
||||
const threshold = 0.1;
|
||||
return (
|
||||
Math.abs(from.scrollX - to.scrollX) < threshold &&
|
||||
Math.abs(from.scrollY - to.scrollY) < threshold &&
|
||||
Math.abs(from.zoom - to.zoom) < threshold
|
||||
);
|
||||
};
|
||||
@ -141,6 +141,11 @@ export type ScrollBars = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ConstrainedScrollValues = Pick<
|
||||
AppState,
|
||||
"scrollX" | "scrollY" | "zoom"
|
||||
> | null;
|
||||
|
||||
export type ElementShape = Drawable | Drawable[] | null;
|
||||
|
||||
export type ElementShapes = {
|
||||
|
||||
@ -169,14 +169,8 @@ export const isSnappingEnabled = ({
|
||||
selectedElements: NonDeletedExcalidrawElement[];
|
||||
}) => {
|
||||
if (event) {
|
||||
// Allow snapping for lasso tool when dragging selected elements
|
||||
// but not during lasso selection phase
|
||||
const isLassoDragging =
|
||||
app.state.activeTool.type === "lasso" &&
|
||||
app.state.selectedElementsAreBeingDragged;
|
||||
|
||||
return (
|
||||
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
|
||||
app.state.activeTool.type !== "lasso" &&
|
||||
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
||||
(!app.state.objectsSnapModeEnabled &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
|
||||
@ -958,6 +958,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -981,7 +982,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -1153,6 +1153,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": true,
|
||||
@ -1173,7 +1174,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": {
|
||||
@ -1366,6 +1366,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1386,7 +1387,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -1696,6 +1696,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1716,7 +1717,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -2026,6 +2026,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": true,
|
||||
@ -2046,7 +2047,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": {
|
||||
@ -2239,6 +2239,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2257,7 +2258,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -2479,6 +2479,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2499,7 +2500,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -2778,6 +2778,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2801,7 +2802,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -3147,6 +3147,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3167,7 +3168,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": {
|
||||
@ -3639,6 +3639,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3659,7 +3660,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -3692,14 +3692,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 400692809,
|
||||
"seed": 1116226695,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 81784553,
|
||||
"versionNonce": 23633383,
|
||||
"width": 20,
|
||||
"x": 20,
|
||||
"y": 30,
|
||||
@ -3724,14 +3724,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 449462985,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 1150084233,
|
||||
"versionNonce": 401146281,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"y": 0,
|
||||
@ -3961,6 +3961,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3981,7 +3982,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -4285,6 +4285,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4306,7 +4307,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -5569,6 +5569,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5590,7 +5591,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -6785,6 +6785,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -6808,7 +6809,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -7720,6 +7720,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -7738,7 +7739,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -8716,6 +8716,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": true,
|
||||
@ -8736,7 +8737,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -9709,6 +9709,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9729,7 +9730,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
|
||||
@ -688,7 +688,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Canvas background"
|
||||
class="color-picker__button active-color properties-trigger has-outline"
|
||||
data-openpopup="canvasBackground"
|
||||
data-state="closed"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="Show background color picker"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -85,6 +85,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -108,7 +109,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -510,6 +510,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -535,7 +536,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -921,6 +921,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -941,7 +942,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -1486,6 +1486,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1506,7 +1507,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -1694,6 +1694,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1717,7 +1718,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -2077,6 +2077,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2097,7 +2098,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -2319,6 +2319,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2339,7 +2340,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -2500,6 +2500,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2520,7 +2521,6 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -2822,6 +2822,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2842,7 +2843,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -3078,6 +3078,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3098,7 +3099,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -3318,6 +3318,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3338,7 +3339,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -3553,6 +3553,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3573,7 +3574,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -3810,6 +3810,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3831,7 +3832,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -4121,6 +4121,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4143,7 +4144,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -4559,6 +4559,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4605,7 +4606,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -4841,6 +4841,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4859,7 +4860,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -5115,6 +5115,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5161,7 +5162,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -5322,6 +5322,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5340,7 +5341,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -5519,6 +5519,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5539,7 +5540,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -5914,6 +5914,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5935,7 +5936,6 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -6207,6 +6207,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -6225,7 +6226,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -6415,16 +6415,15 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id9": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id9",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id9",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id6": true,
|
||||
},
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -6492,19 +6491,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id12": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id12",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id12",
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id9": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id9",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id9",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -6570,16 +6563,15 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id15": true,
|
||||
},
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id12": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id12",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id12",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -6706,13 +6698,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id15",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id15",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -6730,16 +6721,15 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id22": true,
|
||||
},
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id15": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id15",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id15",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -6864,13 +6854,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id22",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id22",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -6905,13 +6894,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id22",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id22",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -7065,6 +7053,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -7085,7 +7074,6 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -7397,6 +7385,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -7418,7 +7407,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -7675,6 +7663,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -7695,7 +7684,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -7909,6 +7897,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -7929,7 +7918,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -8146,6 +8134,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -8166,7 +8155,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -8325,6 +8313,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -8345,7 +8334,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -8504,6 +8492,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -8524,7 +8513,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -8683,6 +8671,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -8730,7 +8719,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -8760,14 +8748,13 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -8912,6 +8899,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -8959,7 +8947,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -8989,14 +8976,13 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -9139,6 +9125,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9157,7 +9144,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -9334,6 +9320,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9381,7 +9368,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -9411,14 +9397,13 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -9563,6 +9548,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9583,7 +9569,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -9742,6 +9727,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9789,7 +9775,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -9819,14 +9804,13 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -9969,6 +9953,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9989,7 +9974,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -10148,6 +10132,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10166,7 +10151,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -10343,6 +10327,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10363,7 +10348,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -10526,6 +10510,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10550,7 +10535,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -11054,6 +11038,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11074,7 +11059,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -11331,6 +11315,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": "-6.25000",
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11349,7 +11334,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -11455,6 +11439,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11473,7 +11458,6 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -11655,6 +11639,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11676,7 +11661,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -11974,6 +11958,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11996,7 +11981,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -12403,6 +12387,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -12428,7 +12413,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -13040,6 +13024,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 60,
|
||||
"scrollY": 60,
|
||||
"scrolledOutside": false,
|
||||
@ -13058,7 +13043,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -13164,6 +13148,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -13184,7 +13169,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -13795,6 +13779,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -13843,7 +13828,6 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -14132,6 +14116,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"id3": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -14180,7 +14165,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -14393,6 +14377,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 20,
|
||||
"scrollY": "-18.53553",
|
||||
"scrolledOutside": false,
|
||||
@ -14411,7 +14396,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -14515,6 +14499,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -14535,7 +14520,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -14560,13 +14544,12 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElement": null,
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id6",
|
||||
"isEditing": false,
|
||||
},
|
||||
"selectedLinearElementId": "id6",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -14906,6 +14889,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -14924,7 +14908,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@ -15031,6 +15014,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"penMode": false,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -15049,7 +15033,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
|
||||
@ -35,10 +35,8 @@ describe("appState", () => {
|
||||
expect(h.state.viewBackgroundColor).toBe("#F00");
|
||||
});
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: new Blob(
|
||||
await API.drop(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
@ -50,8 +48,7 @@ describe("appState", () => {
|
||||
],
|
||||
{ type: MIME_TYPES.json },
|
||||
),
|
||||
},
|
||||
]);
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
|
||||
@ -60,11 +60,7 @@ describe("restoreElements", () => {
|
||||
const rectElement = API.createElement({ type: "rectangle" });
|
||||
mockSizeHelper.mockImplementation(() => true);
|
||||
|
||||
expect(
|
||||
restore.restoreElements([rectElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
).toEqual([expect.objectContaining({ isDeleted: true })]);
|
||||
expect(restore.restoreElements([rectElement], null).length).toBe(0);
|
||||
});
|
||||
|
||||
it("should restore text element correctly passing value for each attribute", () => {
|
||||
@ -89,23 +85,6 @@ describe("restoreElements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not delete empty text element when opts.deleteInvisibleElements is not defined", () => {
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "",
|
||||
isDeleted: false,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements([textElement], null);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: textElement.id,
|
||||
isDeleted: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
|
||||
const textElement: any = API.createElement({
|
||||
type: "text",
|
||||
@ -118,9 +97,10 @@ describe("restoreElements", () => {
|
||||
textElement.font = "10 unknown";
|
||||
|
||||
expect(textElement.isDeleted).toBe(false);
|
||||
const restoredText = restore.restoreElements([textElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
})[0] as ExcalidrawTextElement;
|
||||
const restoredText = restore.restoreElements(
|
||||
[textElement],
|
||||
null,
|
||||
)[0] as ExcalidrawTextElement;
|
||||
expect(restoredText.isDeleted).toBe(true);
|
||||
expect(restoredText).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
@ -197,16 +177,13 @@ describe("restoreElements", () => {
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as
|
||||
| ExcalidrawArrowElement
|
||||
| undefined;
|
||||
|
||||
expect(restoredArrow).not.toBeUndefined();
|
||||
expect(restoredArrow?.isDeleted).toBe(true);
|
||||
expect(restoredArrow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
||||
@ -871,7 +848,6 @@ describe("repairing bindings", () => {
|
||||
let restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
@ -879,11 +855,6 @@ describe("repairing bindings", () => {
|
||||
id: container.id,
|
||||
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: invisibleBoundElement.id,
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
@ -893,7 +864,7 @@ describe("repairing bindings", () => {
|
||||
restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
{ repairBindings: true, deleteInvisibleElements: true },
|
||||
{ repairBindings: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
@ -901,11 +872,6 @@ describe("repairing bindings", () => {
|
||||
id: container.id,
|
||||
boundElements: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: invisibleBoundElement.id,
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
|
||||
@ -315,12 +315,7 @@ describe("Test dragCreate", () => {
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "arrow",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("line", async () => {
|
||||
@ -349,12 +344,7 @@ describe("Test dragCreate", () => {
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "line",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -57,7 +57,7 @@ describe("export", () => {
|
||||
blob: pngBlob,
|
||||
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||
});
|
||||
await API.drop([{ kind: "file", file: pngBlobEmbedded }]);
|
||||
await API.drop(pngBlobEmbedded);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
@ -94,12 +94,7 @@ describe("export", () => {
|
||||
});
|
||||
|
||||
it("import embedded png (legacy v1)", async () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: await API.loadFile("./fixtures/test_embedded_v1.png"),
|
||||
},
|
||||
]);
|
||||
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "test" }),
|
||||
@ -108,12 +103,7 @@ describe("export", () => {
|
||||
});
|
||||
|
||||
it("import embedded png (v2)", async () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: await API.loadFile("./fixtures/smiley_embedded_v2.png"),
|
||||
},
|
||||
]);
|
||||
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
@ -122,12 +112,7 @@ describe("export", () => {
|
||||
});
|
||||
|
||||
it("import embedded svg (legacy v1)", async () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: await API.loadFile("./fixtures/test_embedded_v1.svg"),
|
||||
},
|
||||
]);
|
||||
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "test" }),
|
||||
@ -136,12 +121,7 @@ describe("export", () => {
|
||||
});
|
||||
|
||||
it("import embedded svg (v2)", async () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"),
|
||||
},
|
||||
]);
|
||||
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
export const DEER_IMAGE_DIMENSIONS = {
|
||||
width: 318,
|
||||
height: 335,
|
||||
};
|
||||
|
||||
export const SMILEY_IMAGE_DIMENSIONS = {
|
||||
width: 56,
|
||||
height: 77,
|
||||
};
|
||||
@ -25,7 +25,6 @@ import { Excalidraw } from "../index";
|
||||
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
|
||||
import * as blobModule from "../data/blob";
|
||||
|
||||
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
|
||||
import { API } from "./helpers/api";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import {
|
||||
@ -745,6 +744,11 @@ describe("freedraw", () => {
|
||||
//image
|
||||
//TODO: currently there is no test for pixel colors at flipped positions.
|
||||
describe("image", () => {
|
||||
const smileyImageDimensions = {
|
||||
width: 56,
|
||||
height: 77,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
||||
h.state.height = 1000;
|
||||
@ -752,8 +756,8 @@ describe("image", () => {
|
||||
|
||||
beforeAll(() => {
|
||||
mockHTMLImageElement(
|
||||
SMILEY_IMAGE_DIMENSIONS.width,
|
||||
SMILEY_IMAGE_DIMENSIONS.height,
|
||||
smileyImageDimensions.width,
|
||||
smileyImageDimensions.height,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -478,43 +478,33 @@ export class API {
|
||||
});
|
||||
};
|
||||
|
||||
static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
|
||||
|
||||
static drop = async (blob: Blob) => {
|
||||
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
||||
const text = await new Promise<string>((resolve, reject) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[];
|
||||
|
||||
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
|
||||
const files = [blob] as File[] & { item: (index: number) => File };
|
||||
files.item = (index: number) => files[index];
|
||||
|
||||
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||
value: {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
|
||||
files,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
|
||||
items: items.map((item, idx) => {
|
||||
if (item.kind === "string") {
|
||||
return {
|
||||
kind: "string",
|
||||
type: item.type,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
|
||||
getAsString: (cb: (text: string) => any) => cb(item.value),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "file",
|
||||
type: item.type || item.file.type,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
|
||||
getAsFile: () => item.file,
|
||||
};
|
||||
}),
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
|
||||
getData: (type: string) => {
|
||||
return items.find((item) => item.type === "string" && item.type === type) || "";
|
||||
if (type === blob.type || type === "text") {
|
||||
return text;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
|
||||
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
|
||||
types: [blob.type],
|
||||
},
|
||||
});
|
||||
Object.defineProperty(fileDropEvent, "clientX", {
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
export const INITIALIZED_IMAGE_PROPS = {
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
};
|
||||
@ -58,35 +58,3 @@ export const mockHTMLImageElement = (
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
|
||||
export const mockMultipleHTMLImageElements = (
|
||||
sizes: (readonly [number, number])[],
|
||||
) => {
|
||||
const _sizes = [...sizes];
|
||||
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class extends Image {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const size = _sizes.shift();
|
||||
if (!size) {
|
||||
throw new Error("Insufficient sizes");
|
||||
}
|
||||
|
||||
Object.defineProperty(this, "naturalWidth", {
|
||||
value: size[0],
|
||||
});
|
||||
Object.defineProperty(this, "naturalHeight", {
|
||||
value: size[1],
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.onload?.({} as Event);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -47,43 +47,42 @@ class DataTransferItem {
|
||||
}
|
||||
}
|
||||
|
||||
class DataTransferItemList extends Array<DataTransferItem> {
|
||||
class DataTransferList {
|
||||
items: DataTransferItem[] = [];
|
||||
|
||||
add(data: string | File, type: string = ""): void {
|
||||
if (typeof data === "string") {
|
||||
this.push(new DataTransferItem("string", type, data));
|
||||
this.items.push(new DataTransferItem("string", type, data));
|
||||
} else if (data instanceof File) {
|
||||
this.push(new DataTransferItem("file", type, data));
|
||||
this.items.push(new DataTransferItem("file", type, data));
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clear();
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
class DataTransfer {
|
||||
public items: DataTransferItemList = new DataTransferItemList();
|
||||
public items: DataTransferList = new DataTransferList();
|
||||
private _types: Record<string, string> = {};
|
||||
|
||||
get files() {
|
||||
return this.items
|
||||
return this.items.items
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile()!);
|
||||
}
|
||||
|
||||
add(data: string | File, type: string = ""): void {
|
||||
if (typeof data === "string") {
|
||||
this.items.add(data, type);
|
||||
} else {
|
||||
this.items.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
setData(type: string, value: string) {
|
||||
this.items.add(value, type);
|
||||
this._types[type] = value;
|
||||
}
|
||||
|
||||
getData(type: string) {
|
||||
return this.items.find((item) => item.type === type)?.data || "";
|
||||
return this._types[type] || "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,7 +20,6 @@ import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
reseed,
|
||||
randomId,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import "@excalidraw/utils/test-utils";
|
||||
@ -59,13 +58,9 @@ import { createPasteEvent } from "../clipboard";
|
||||
|
||||
import * as blobModule from "../data/blob";
|
||||
|
||||
import {
|
||||
DEER_IMAGE_DIMENSIONS,
|
||||
SMILEY_IMAGE_DIMENSIONS,
|
||||
} from "./fixtures/constants";
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
|
||||
import { mockHTMLImageElement } from "./helpers/mocks";
|
||||
import {
|
||||
GlobalTestState,
|
||||
act,
|
||||
@ -76,7 +71,6 @@ import {
|
||||
checkpointHistory,
|
||||
unmountComponent,
|
||||
} from "./test-utils";
|
||||
import { setupImageTest as _setupImageTest } from "./image.test";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
@ -129,9 +123,7 @@ describe("history", () => {
|
||||
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
|
||||
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
|
||||
|
||||
generateIdSpy.mockImplementation(() =>
|
||||
Promise.resolve(randomId() as FileId),
|
||||
);
|
||||
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
|
||||
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
|
||||
|
||||
Object.assign(document, {
|
||||
@ -568,10 +560,8 @@ describe("history", () => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
||||
);
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: new Blob(
|
||||
await API.drop(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
@ -584,8 +574,7 @@ describe("history", () => {
|
||||
],
|
||||
{ type: MIME_TYPES.json },
|
||||
),
|
||||
},
|
||||
]);
|
||||
);
|
||||
|
||||
await waitFor(() => expect(API.getUndoStack().length).toBe(1));
|
||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||
@ -623,141 +612,201 @@ describe("history", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create new history entry on image drag&drop", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
||||
h.state.height = 1000;
|
||||
|
||||
const deerImageDimensions = {
|
||||
width: 318,
|
||||
height: 335,
|
||||
};
|
||||
|
||||
mockHTMLImageElement(
|
||||
deerImageDimensions.width,
|
||||
deerImageDimensions.height,
|
||||
);
|
||||
|
||||
await API.drop(await API.loadFile("./fixtures/deer.png"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
...deerImageDimensions,
|
||||
}),
|
||||
]);
|
||||
|
||||
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
|
||||
expect(
|
||||
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
...deerImageDimensions,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
isDeleted: true,
|
||||
...deerImageDimensions,
|
||||
}),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
isDeleted: false,
|
||||
...deerImageDimensions,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create new history entry on embeddable link drag&drop", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
|
||||
await API.drop([
|
||||
{
|
||||
kind: "string",
|
||||
value: link,
|
||||
type: MIME_TYPES.text,
|
||||
},
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "embeddable",
|
||||
link,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "embeddable",
|
||||
link,
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "embeddable",
|
||||
link,
|
||||
isDeleted: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
const setupImageTest = () =>
|
||||
_setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
|
||||
|
||||
const assertImageTest = async () => {
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
|
||||
// need to check that delta actually contains initialized image elements (with fileId & natural dimensions)
|
||||
expect(
|
||||
Object.values(h.history.undoStack[0].elements.removed).map(
|
||||
(val) => val.deleted,
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
...DEER_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
...SMILEY_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
isDeleted: true,
|
||||
...DEER_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
isDeleted: true,
|
||||
...SMILEY_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
isDeleted: false,
|
||||
...DEER_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
isDeleted: false,
|
||||
...SMILEY_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
it("should create new history entry on image drag&drop", async () => {
|
||||
await setupImageTest();
|
||||
|
||||
await API.drop(
|
||||
(
|
||||
await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
])
|
||||
).map((file) => ({
|
||||
kind: "file",
|
||||
file,
|
||||
})),
|
||||
new Blob([link], {
|
||||
type: MIME_TYPES.text,
|
||||
}),
|
||||
);
|
||||
|
||||
await assertImageTest();
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "embeddable",
|
||||
link,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "embeddable",
|
||||
link,
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "embeddable",
|
||||
link,
|
||||
isDeleted: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create new history entry on image paste", async () => {
|
||||
await setupImageTest();
|
||||
await render(
|
||||
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
|
||||
);
|
||||
|
||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
||||
h.state.height = 1000;
|
||||
|
||||
const smileyImageDimensions = {
|
||||
width: 56,
|
||||
height: 77,
|
||||
};
|
||||
|
||||
mockHTMLImageElement(
|
||||
smileyImageDimensions.width,
|
||||
smileyImageDimensions.height,
|
||||
);
|
||||
|
||||
document.dispatchEvent(
|
||||
createPasteEvent({
|
||||
files: await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
]),
|
||||
files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
|
||||
}),
|
||||
);
|
||||
|
||||
await assertImageTest();
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
...smileyImageDimensions,
|
||||
}),
|
||||
]);
|
||||
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
|
||||
expect(
|
||||
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
...smileyImageDimensions,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
isDeleted: true,
|
||||
...smileyImageDimensions,
|
||||
}),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
isDeleted: false,
|
||||
...smileyImageDimensions,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create new history entry on embeddable link paste", async () => {
|
||||
@ -2994,14 +3043,15 @@ describe("history", () => {
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(4);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
});
|
||||
|
||||
@ -4006,7 +4056,7 @@ describe("history", () => {
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [{ id: remoteText.id, type: "text" }],
|
||||
isDeleted: false,
|
||||
isDeleted: false, // isDeleted got remotely updated to false
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: text.id,
|
||||
@ -4015,6 +4065,7 @@ describe("history", () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: remoteText.id,
|
||||
// unbound
|
||||
containerId: container.id,
|
||||
isDeleted: false,
|
||||
}),
|
||||
@ -4305,8 +4356,8 @@ describe("history", () => {
|
||||
expect.objectContaining({
|
||||
...textProps,
|
||||
// text element got redrawn!
|
||||
x: 241.295259647664,
|
||||
y: 247.59240920619527,
|
||||
x: 205,
|
||||
y: 205,
|
||||
angle: 90,
|
||||
id: text.id,
|
||||
containerId: container.id,
|
||||
@ -4349,8 +4400,8 @@ describe("history", () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...textProps,
|
||||
x: 241.295259647664,
|
||||
y: 247.59240920619527,
|
||||
x: 205,
|
||||
y: 205,
|
||||
angle: 90,
|
||||
id: text.id,
|
||||
containerId: container.id,
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
import { randomId, reseed } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
import * as blobModule from "../data/blob";
|
||||
import * as filesystemModule from "../data/filesystem";
|
||||
import { Excalidraw } from "../index";
|
||||
import { createPasteEvent } from "../clipboard";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { mockMultipleHTMLImageElements } from "./helpers/mocks";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { GlobalTestState, render, waitFor } from "./test-utils";
|
||||
import {
|
||||
DEER_IMAGE_DIMENSIONS,
|
||||
SMILEY_IMAGE_DIMENSIONS,
|
||||
} from "./fixtures/constants";
|
||||
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
export const setupImageTest = async (
|
||||
sizes: { width: number; height: number }[],
|
||||
) => {
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
|
||||
h.state.height = 1000;
|
||||
|
||||
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
|
||||
};
|
||||
|
||||
describe("image insertion", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
reseed(7);
|
||||
|
||||
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
|
||||
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
|
||||
|
||||
generateIdSpy.mockImplementation(() =>
|
||||
Promise.resolve(randomId() as FileId),
|
||||
);
|
||||
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
|
||||
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
});
|
||||
});
|
||||
|
||||
const setup = () =>
|
||||
setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
|
||||
|
||||
const assert = async () => {
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
...DEER_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
...SMILEY_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
// Not placed on top of each other
|
||||
const dimensionsSet = new Set(h.elements.map((el) => `${el.x}-${el.y}`));
|
||||
expect(dimensionsSet.size).toEqual(h.elements.length);
|
||||
};
|
||||
|
||||
it("should eventually initialize all dropped images", async () => {
|
||||
await setup();
|
||||
|
||||
const files = await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
]);
|
||||
await API.drop(files.map((file) => ({ kind: "file", file })));
|
||||
|
||||
await assert();
|
||||
});
|
||||
|
||||
it("should eventually initialize all pasted images", async () => {
|
||||
await setup();
|
||||
|
||||
document.dispatchEvent(
|
||||
createPasteEvent({
|
||||
files: await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await assert();
|
||||
});
|
||||
|
||||
it("should eventually initialize all images added through image tool", async () => {
|
||||
await setup();
|
||||
|
||||
const fileOpenSpy = vi.spyOn(filesystemModule, "fileOpen");
|
||||
fileOpenSpy.mockImplementation(
|
||||
async () =>
|
||||
await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
]),
|
||||
);
|
||||
UI.clickTool("image");
|
||||
|
||||
await assert();
|
||||
});
|
||||
});
|
||||
@ -56,13 +56,9 @@ describe("library", () => {
|
||||
|
||||
it("import library via drag&drop", async () => {
|
||||
expect(await h.app.library.getLatestLibrary()).toEqual([]);
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
|
||||
},
|
||||
]);
|
||||
await API.drop(
|
||||
await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
expect(await h.app.library.getLatestLibrary()).toEqual([
|
||||
{
|
||||
@ -79,13 +75,11 @@ describe("library", () => {
|
||||
it("drop library item onto canvas", async () => {
|
||||
expect(h.elements).toEqual([]);
|
||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||
await API.drop([
|
||||
{
|
||||
kind: "string",
|
||||
value: serializeLibraryAsJSON(libraryItems),
|
||||
await API.drop(
|
||||
new Blob([serializeLibraryAsJSON(libraryItems)], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||
});
|
||||
@ -117,10 +111,10 @@ describe("library", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "string",
|
||||
value: serializeLibraryAsJSON([
|
||||
await API.drop(
|
||||
new Blob(
|
||||
[
|
||||
serializeLibraryAsJSON([
|
||||
{
|
||||
id: "item1",
|
||||
status: "published",
|
||||
@ -128,9 +122,12 @@ describe("library", () => {
|
||||
created: 1,
|
||||
},
|
||||
]),
|
||||
],
|
||||
{
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
]);
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual(
|
||||
@ -173,13 +170,11 @@ describe("library", () => {
|
||||
created: 1,
|
||||
};
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "string",
|
||||
value: serializeLibraryAsJSON([item1, item1]),
|
||||
await API.drop(
|
||||
new Blob([serializeLibraryAsJSON([item1, item1])], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
@ -198,13 +193,11 @@ describe("library", () => {
|
||||
UI.clickTool("rectangle");
|
||||
expect(h.elements).toEqual([]);
|
||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||
await API.drop([
|
||||
{
|
||||
kind: "string",
|
||||
value: serializeLibraryAsJSON(libraryItems),
|
||||
await API.drop(
|
||||
new Blob([serializeLibraryAsJSON(libraryItems)], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||
});
|
||||
|
||||
@ -248,10 +248,8 @@ export type ObservedElementsAppState = {
|
||||
editingGroupId: AppState["editingGroupId"];
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
selectedLinearElement: {
|
||||
elementId: LinearElementEditor["elementId"];
|
||||
isEditing: boolean;
|
||||
} | null;
|
||||
selectedLinearElementId: LinearElementEditor["elementId"] | null;
|
||||
selectedLinearElementIsEditing: boolean | null;
|
||||
croppingElementId: AppState["croppingElementId"];
|
||||
lockedMultiSelections: AppState["lockedMultiSelections"];
|
||||
activeLockedId: AppState["activeLockedId"];
|
||||
@ -352,10 +350,6 @@ export interface AppState {
|
||||
| "elementBackground"
|
||||
| "elementStroke"
|
||||
| "fontFamily"
|
||||
| "compactTextProperties"
|
||||
| "compactStrokeStyles"
|
||||
| "compactOtherProperties"
|
||||
| "compactArrowProperties"
|
||||
| null;
|
||||
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
||||
openDialog:
|
||||
@ -428,6 +422,7 @@ export interface AppState {
|
||||
userToFollow: UserToFollow | null;
|
||||
/** the socket ids of the users following the current user */
|
||||
followedBy: Set<SocketId>;
|
||||
scrollConstraints: ScrollConstraints | null;
|
||||
|
||||
/** image cropping */
|
||||
isCropping: boolean;
|
||||
@ -446,9 +441,6 @@ export interface AppState {
|
||||
// as elements are unlocked, we remove the groupId from the elements
|
||||
// and also remove groupId from this map
|
||||
lockedMultiSelections: { [groupId: string]: true };
|
||||
|
||||
/** properties sidebar mode - determines whether to show compact or complete sidebar */
|
||||
stylesPanelMode: "compact" | "full";
|
||||
}
|
||||
|
||||
export type SearchMatch = {
|
||||
@ -612,6 +604,7 @@ export interface ExcalidrawProps {
|
||||
onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void;
|
||||
onUserFollow?: (payload: OnUserFollowedPayload) => void;
|
||||
children?: React.ReactNode;
|
||||
scrollConstraints?: AppState["scrollConstraints"];
|
||||
validateEmbeddable?:
|
||||
| boolean
|
||||
| string[]
|
||||
@ -738,8 +731,6 @@ export type AppClassProperties = {
|
||||
|
||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||
updateEditorAtom: App["updateEditorAtom"];
|
||||
|
||||
defaultSelectionTool: "selection" | "lasso";
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@ -789,10 +780,6 @@ export type PointerDownState = Readonly<{
|
||||
// by default same as PointerDownState.origin. On alt-duplication, reset
|
||||
// to current pointer position at time of duplication.
|
||||
origin: { x: number; y: number };
|
||||
// Whether to block drag after lasso selection
|
||||
// this is meant to be used to block dragging after lasso selection on PCs
|
||||
// until the next pointer down
|
||||
blockDragging: boolean;
|
||||
};
|
||||
// We need to have these in the state so that we can unsubscribe them
|
||||
eventListeners: {
|
||||
@ -814,7 +801,6 @@ export type UnsubscribeCallback = () => void;
|
||||
|
||||
export interface ExcalidrawImperativeAPI {
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
||||
mutateElement: InstanceType<typeof App>["mutateElement"];
|
||||
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
||||
resetScene: InstanceType<typeof App>["resetScene"];
|
||||
@ -877,6 +863,7 @@ export interface ExcalidrawImperativeAPI {
|
||||
onUserFollow: (
|
||||
callback: (payload: OnUserFollowedPayload) => void,
|
||||
) => UnsubscribeCallback;
|
||||
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
|
||||
}
|
||||
|
||||
export type Device = Readonly<{
|
||||
@ -912,6 +899,12 @@ export type FrameNameBoundsCache = {
|
||||
>;
|
||||
};
|
||||
|
||||
export type AnimateTranslateCanvasValues = {
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
zoom: AppState["zoom"]["value"];
|
||||
};
|
||||
|
||||
export type KeyboardModifiersObject = {
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
@ -937,6 +930,29 @@ export type EmbedsValidationStatus = Map<
|
||||
|
||||
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
|
||||
|
||||
export type ScrollConstraints = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
animateOnNextUpdate?: boolean;
|
||||
/**
|
||||
* a facotr <0-1> that determines how much you can zoom out beyond the scroll
|
||||
* constraints.
|
||||
*/
|
||||
viewportZoomFactor?: number;
|
||||
/**
|
||||
* If true, the user will not be able to zoom out beyond the scroll
|
||||
* constraints (taking into account the viewportZoomFactor).
|
||||
*/
|
||||
lockZoom?: boolean;
|
||||
/**
|
||||
* <0-1> - how much can you scroll beyond the constrained area within the
|
||||
* timeout window. Note you will still be snapped back to the constrained area
|
||||
* after the timeout.
|
||||
*/
|
||||
overscrollAllowance?: number;
|
||||
};
|
||||
export type PendingExcalidrawElements = ExcalidrawElement[];
|
||||
|
||||
/** Runtime gridSize value. Null indicates disabled grid. */
|
||||
|
||||
@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
@ -1198,11 +1198,7 @@ describe("textWysiwyg", () => {
|
||||
updateTextEditor(editor, " ");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
isDeleted: true,
|
||||
}),
|
||||
);
|
||||
expect(h.elements[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
getFontString,
|
||||
getFontFamilyString,
|
||||
isTestEnv,
|
||||
MIME_TYPES,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@ -46,7 +45,7 @@ import type {
|
||||
|
||||
import { actionSaveToActiveFile } from "../actions";
|
||||
|
||||
import { parseDataTransferEvent } from "../clipboard";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
actionIncreaseFontSize,
|
||||
@ -216,12 +215,11 @@ export const textWysiwyg = ({
|
||||
);
|
||||
app.scene.mutateElement(container, { height: targetContainerHeight });
|
||||
} else {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
);
|
||||
coordX = x;
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
@ -333,14 +331,12 @@ export const textWysiwyg = ({
|
||||
|
||||
if (onChange) {
|
||||
editable.onpaste = async (event) => {
|
||||
const textItem = (await parseDataTransferEvent(event)).findByType(
|
||||
MIME_TYPES.text,
|
||||
);
|
||||
if (!textItem) {
|
||||
const clipboardData = await parseClipboard(event, true);
|
||||
if (!clipboardData.text) {
|
||||
return;
|
||||
}
|
||||
const text = normalizeText(textItem.value);
|
||||
if (!text) {
|
||||
const data = normalizeText(clipboardData.text);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const container = getContainerElement(
|
||||
@ -358,7 +354,7 @@ export const textWysiwyg = ({
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const wrappedText = wrapText(
|
||||
`${editable.value}${text}`,
|
||||
`${editable.value}${data}`,
|
||||
font,
|
||||
getBoundTextMaxWidth(container, boundTextElement),
|
||||
);
|
||||
@ -542,7 +538,6 @@ export const textWysiwyg = ({
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDestroyed = true;
|
||||
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
||||
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
||||
@ -626,24 +621,14 @@ export const textWysiwyg = ({
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
const isPropertiesContent =
|
||||
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||
!!(target as Element).closest(".properties-content");
|
||||
const inShapeActionsMenu =
|
||||
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||
(!!(target as Element).closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
|
||||
!!(target as Element).closest(".compact-shape-actions-island"));
|
||||
|
||||
setTimeout(() => {
|
||||
// If we interacted within shape actions menu or its popovers/triggers,
|
||||
// keep submit disabled and don't steal focus back to textarea.
|
||||
if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, re-enable submit on blur and refocus the editor.
|
||||
editable.onblur = handleSubmit;
|
||||
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
if (!isPropertiesTrigger) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -666,7 +651,6 @@ export const textWysiwyg = ({
|
||||
event.preventDefault();
|
||||
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
|
||||
}
|
||||
|
||||
temporarilyDisableSubmit();
|
||||
return;
|
||||
}
|
||||
@ -674,20 +658,15 @@ export const textWysiwyg = ({
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
const isPropertiesContent =
|
||||
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||
!!(target as Element).closest(".properties-content");
|
||||
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
(event.target.closest(
|
||||
event.target.closest(
|
||||
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
|
||||
) ||
|
||||
event.target.closest(".compact-shape-actions-island")) &&
|
||||
) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isPropertiesTrigger ||
|
||||
isPropertiesContent
|
||||
isPropertiesTrigger
|
||||
) {
|
||||
temporarilyDisableSubmit();
|
||||
} else if (
|
||||
|
||||
@ -177,19 +177,3 @@ export function lineSegmentIntersectionPoints<
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function lineSegmentsDistance<Point extends GlobalPoint | LocalPoint>(
|
||||
s1: LineSegment<Point>,
|
||||
s2: LineSegment<Point>,
|
||||
): number {
|
||||
if (lineSegmentIntersectionPoints(s1, s2)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
distanceToLineSegment(s1[0], s2),
|
||||
distanceToLineSegment(s1[1], s2),
|
||||
distanceToLineSegment(s2[0], s1),
|
||||
distanceToLineSegment(s2[1], s1),
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,12 +49,18 @@ export const exportToCanvas = ({
|
||||
{ elements, appState },
|
||||
null,
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||
return _exportToCanvas(
|
||||
restoredElements,
|
||||
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
||||
{
|
||||
...restoredAppState,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
scrollConstraints: null,
|
||||
},
|
||||
files || {},
|
||||
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
|
||||
(width: number, height: number) => {
|
||||
@ -180,7 +186,6 @@ export const exportToSvg = async ({
|
||||
{ elements, appState },
|
||||
null,
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
const exportAppState = {
|
||||
|
||||
@ -100,7 +100,6 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
|
||||
@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
|
||||
});
|
||||
|
||||
rl.question(
|
||||
"Would you like to commit these changes to git? (Y/n): ",
|
||||
"Do you want to commit these changes to git? (Y/n): ",
|
||||
(answer) => {
|
||||
rl.close();
|
||||
|
||||
@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
|
||||
});
|
||||
|
||||
rl.question(
|
||||
"Would you like to publish these changes to npm? (Y/n): ",
|
||||
"Do you want to publish these changes to npm? (Y/n): ",
|
||||
(answer) => {
|
||||
rl.close();
|
||||
|
||||
|
||||
34
yarn.lock
34
yarn.lock
@ -1452,15 +1452,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
||||
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
||||
|
||||
"@excalidraw/mermaid-to-excalidraw@1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.3.tgz#3204642c99f3d49c2ad41108217a5d493ef7fd09"
|
||||
integrity sha512-/50GUWlGotc+FCMX7nM1P1kWm9vNd3fuq38v7upBp9IHqlw6Zmfyj79eG/0vz1heifuYrSW9yzzv0q9jVALzxg==
|
||||
"@excalidraw/mermaid-to-excalidraw@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz#74d9507971976a7d3d960a1b2e8fb49a9f1f0d22"
|
||||
integrity sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ==
|
||||
dependencies:
|
||||
"@excalidraw/markdown-to-text" "0.1.2"
|
||||
mermaid "10.9.4"
|
||||
mermaid "10.9.3"
|
||||
nanoid "4.0.2"
|
||||
react-split "^2.0.14"
|
||||
|
||||
"@excalidraw/prettier-config@1.0.2":
|
||||
version "1.0.2"
|
||||
@ -7058,10 +7057,10 @@ merge2@^1.3.0, merge2@^1.4.1:
|
||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||
|
||||
mermaid@10.9.4:
|
||||
version "10.9.4"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.4.tgz#985fd4b6d73ae795b87f0b32f620a56d3d6bf1f8"
|
||||
integrity sha512-VIG2B0R9ydvkS+wShA8sXqkzfpYglM2Qwj7VyUeqzNVqSGPoP/tcaUr3ub4ESykv8eqQJn3p99bHNvYdg3gCHQ==
|
||||
mermaid@10.9.3:
|
||||
version "10.9.3"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.3.tgz#90bc6f15c33dbe5d9507fed31592cc0d88fee9f7"
|
||||
integrity sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==
|
||||
dependencies:
|
||||
"@braintree/sanitize-url" "^6.0.1"
|
||||
"@types/d3-scale" "^4.0.3"
|
||||
@ -7964,7 +7963,7 @@ progress@2.0.3, progress@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
|
||||
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
|
||||
|
||||
prop-types@^15.5.7, prop-types@^15.8.1:
|
||||
prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@ -8109,14 +8108,6 @@ react-remove-scroll@^2.6.3:
|
||||
use-callback-ref "^1.3.3"
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-split@^2.0.14:
|
||||
version "2.0.14"
|
||||
resolved "https://registry.yarnpkg.com/react-split/-/react-split-2.0.14.tgz#ef198259bf43264d605f792fb3384f15f5b34432"
|
||||
integrity sha512-bKWydgMgaKTg/2JGQnaJPg51T6dmumTWZppFgEbbY0Fbme0F5TuatAScCLaqommbGQQf/ZT1zaejuPDriscISA==
|
||||
dependencies:
|
||||
prop-types "^15.5.7"
|
||||
split.js "^1.6.0"
|
||||
|
||||
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
|
||||
@ -8756,11 +8747,6 @@ sourcemap-codec@^1.4.8:
|
||||
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||
|
||||
split.js@^1.6.0:
|
||||
version "1.6.5"
|
||||
resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300"
|
||||
integrity sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw==
|
||||
|
||||
sprintf-js@~1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user