mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-02 03:44:34 +01:00
Compare commits
14 Commits
ryan-di/fi
...
ryan-di/li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a459b135 | ||
|
|
7332e76d56 | ||
|
|
dceaa53b0c | ||
|
|
6e968324fb | ||
|
|
09b18cacec | ||
|
|
178eca5828 | ||
|
|
cb33de25f4 | ||
|
|
0e197ef5c4 | ||
|
|
a0f7edadec | ||
|
|
58c9bb4712 | ||
|
|
d1c6304d42 | ||
|
|
c1a54455bb | ||
|
|
07640dd756 | ||
|
|
5403fa8a0d |
@@ -112,9 +112,7 @@ import {
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import {
|
||||
importFromLocalStorage,
|
||||
importFromIndexedDB,
|
||||
importUsernameFromLocalStorage,
|
||||
migrateFromLocalStorageToIndexedDB,
|
||||
} from "./data/localStorage";
|
||||
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
@@ -220,15 +218,7 @@ const initializeScene = async (opts: {
|
||||
);
|
||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||
|
||||
// migrate from localStorage to IndexedDB if needed
|
||||
await migrateFromLocalStorageToIndexedDB();
|
||||
|
||||
// try to load from IndexedDB first, fallback to localStorage
|
||||
let localDataState = await importFromIndexedDB();
|
||||
if (!localDataState.elements.length && !localDataState.appState) {
|
||||
// fallback to localStorage if IndexedDB is empty
|
||||
localDataState = importFromLocalStorage();
|
||||
}
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: RestoredDataState & {
|
||||
scrollToContent?: boolean;
|
||||
@@ -514,7 +504,7 @@ const ExcalidrawWrapper = () => {
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(async () => {
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
}
|
||||
@@ -524,12 +514,7 @@ const ExcalidrawWrapper = () => {
|
||||
) {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
// try to load from IndexedDB first, fallback to localStorage
|
||||
let localDataState = await importFromIndexedDB();
|
||||
if (!localDataState.elements.length && !localDataState.appState) {
|
||||
// fallback to localStorage if IndexedDB is empty
|
||||
localDataState = importFromLocalStorage();
|
||||
}
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username = importUsernameFromLocalStorage();
|
||||
setLangCode(getPreferredLanguage());
|
||||
excalidrawAPI.updateScene({
|
||||
|
||||
@@ -21,23 +21,11 @@ type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const STORAGE_SIZE_TIMEOUT = 500;
|
||||
|
||||
const getStorageSizes = debounce(async (cb: (sizes: StorageSizes) => void) => {
|
||||
try {
|
||||
const [scene, total] = await Promise.all([
|
||||
getElementsStorageSize(),
|
||||
getTotalStorageSize(),
|
||||
]);
|
||||
cb({
|
||||
scene,
|
||||
total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get storage sizes:", error);
|
||||
cb({
|
||||
scene: 0,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
cb({
|
||||
scene: getElementsStorageSize(),
|
||||
total: getTotalStorageSize(),
|
||||
});
|
||||
}, STORAGE_SIZE_TIMEOUT);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -65,7 +65,7 @@ class LocalFileManager extends FileManager {
|
||||
};
|
||||
}
|
||||
|
||||
const saveDataStateToIndexedDB = async (
|
||||
const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
@@ -79,15 +79,17 @@ const saveDataStateToIndexedDB = async (
|
||||
_appState.openSidebar = null;
|
||||
}
|
||||
|
||||
// save to IndexedDB
|
||||
await Promise.all([
|
||||
ElementsIndexedDBAdapter.save(clearElementsForLocalStorage(elements)),
|
||||
AppStateIndexedDBAdapter.save(_appState),
|
||||
]);
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
// unable to access IndexedDB
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -102,7 +104,7 @@ export class LocalData {
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
) => {
|
||||
await saveDataStateToIndexedDB(elements, appState);
|
||||
saveDataStateToLocalStorage(elements, appState);
|
||||
|
||||
await this.fileStorage.saveFiles({
|
||||
elements,
|
||||
@@ -254,63 +256,3 @@ export class LibraryLocalStorageMigrationAdapter {
|
||||
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||
}
|
||||
}
|
||||
|
||||
/** IndexedDB Adapter for storing app state */
|
||||
export class AppStateIndexedDBAdapter {
|
||||
/** IndexedDB database and store name */
|
||||
private static idb_name = "excalidraw-app-state";
|
||||
/** app state data store key */
|
||||
private static key = "appStateData";
|
||||
|
||||
private static store = createStore(
|
||||
`${AppStateIndexedDBAdapter.idb_name}-db`,
|
||||
`${AppStateIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async load() {
|
||||
const IDBData = await get<Partial<AppState>>(
|
||||
AppStateIndexedDBAdapter.key,
|
||||
AppStateIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
return IDBData || null;
|
||||
}
|
||||
|
||||
static save(data: Partial<AppState>): MaybePromise<void> {
|
||||
return set(
|
||||
AppStateIndexedDBAdapter.key,
|
||||
data,
|
||||
AppStateIndexedDBAdapter.store,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** IndexedDB Adapter for storing elements */
|
||||
export class ElementsIndexedDBAdapter {
|
||||
/** IndexedDB database and store name */
|
||||
private static idb_name = "excalidraw-elements";
|
||||
/** elements data store key */
|
||||
private static key = "elementsData";
|
||||
|
||||
private static store = createStore(
|
||||
`${ElementsIndexedDBAdapter.idb_name}-db`,
|
||||
`${ElementsIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async load() {
|
||||
const IDBData = await get<ExcalidrawElement[]>(
|
||||
ElementsIndexedDBAdapter.key,
|
||||
ElementsIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
return IDBData || null;
|
||||
}
|
||||
|
||||
static save(data: ExcalidrawElement[]): MaybePromise<void> {
|
||||
return set(
|
||||
ElementsIndexedDBAdapter.key,
|
||||
data,
|
||||
ElementsIndexedDBAdapter.store,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,6 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
import {
|
||||
AppStateIndexedDBAdapter,
|
||||
ElementsIndexedDBAdapter,
|
||||
} from "./LocalData";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
@@ -79,146 +74,28 @@ export const importFromLocalStorage = () => {
|
||||
return { elements, appState };
|
||||
};
|
||||
|
||||
export const importFromIndexedDB = async () => {
|
||||
let savedElements = null;
|
||||
let savedState = null;
|
||||
|
||||
export const getElementsStorageSize = () => {
|
||||
try {
|
||||
savedElements = await ElementsIndexedDBAdapter.load();
|
||||
savedState = await AppStateIndexedDBAdapter.load();
|
||||
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
const elementsSize = elements?.length || 0;
|
||||
return elementsSize;
|
||||
} catch (error: any) {
|
||||
// unable to access IndexedDB
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
let elements: ExcalidrawElement[] = [];
|
||||
if (savedElements) {
|
||||
try {
|
||||
elements = clearElementsForLocalStorage(savedElements);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
let appState = null;
|
||||
if (savedState) {
|
||||
try {
|
||||
appState = {
|
||||
...getDefaultAppState(),
|
||||
...clearAppStateForLocalStorage(savedState),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
return { elements, appState };
|
||||
};
|
||||
|
||||
export const migrateFromLocalStorageToIndexedDB = async () => {
|
||||
try {
|
||||
// check if we have data in localStorage
|
||||
const savedElements = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
);
|
||||
const savedState = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
);
|
||||
|
||||
if (savedElements || savedState) {
|
||||
// parse and migrate elements
|
||||
if (savedElements) {
|
||||
try {
|
||||
const elements = JSON.parse(savedElements);
|
||||
await ElementsIndexedDBAdapter.save(elements);
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate elements:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// parse and migrate app state
|
||||
if (savedState) {
|
||||
try {
|
||||
const appState = JSON.parse(savedState);
|
||||
await AppStateIndexedDBAdapter.save(appState);
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate app state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// clear localStorage after successful migration
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the size of elements stored in IndexedDB (with localStorage fallback)
|
||||
* @returns Promise<number> - Size in bytes
|
||||
*/
|
||||
export const getElementsStorageSize = async () => {
|
||||
try {
|
||||
const elements = await ElementsIndexedDBAdapter.load();
|
||||
if (elements) {
|
||||
// calculate size by stringifying the data
|
||||
const elementsString = JSON.stringify(elements);
|
||||
return elementsString.length;
|
||||
}
|
||||
return 0;
|
||||
} catch (error: any) {
|
||||
console.error("Failed to get elements size from IndexedDB:", error);
|
||||
// fallback to localStorage
|
||||
try {
|
||||
const elements = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
);
|
||||
return elements?.length || 0;
|
||||
} catch (localStorageError: any) {
|
||||
console.error(
|
||||
"Failed to get elements size from localStorage:",
|
||||
localStorageError,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the total size of all data stored in IndexedDB and localStorage
|
||||
* @returns Promise<number> - Size in bytes
|
||||
*/
|
||||
export const getTotalStorageSize = async () => {
|
||||
export const getTotalStorageSize = () => {
|
||||
try {
|
||||
const appState = await AppStateIndexedDBAdapter.load();
|
||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
|
||||
const appStateSize = appState ? JSON.stringify(appState).length : 0;
|
||||
const appStateSize = appState?.length || 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
|
||||
const elementsSize = await getElementsStorageSize();
|
||||
return appStateSize + collabSize + elementsSize;
|
||||
return appStateSize + collabSize + getElementsStorageSize();
|
||||
} catch (error: any) {
|
||||
console.error("Failed to get total storage size from IndexedDB:", error);
|
||||
// fallback to localStorage
|
||||
try {
|
||||
const appState = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
);
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
|
||||
const appStateSize = appState?.length || 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
|
||||
const elementsSize = await getElementsStorageSize();
|
||||
return appStateSize + collabSize + elementsSize;
|
||||
} catch (localStorageError: any) {
|
||||
console.error(
|
||||
"Failed to get total storage size from localStorage:",
|
||||
localStorageError,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
console.error(error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,6 +105,7 @@ export * from "./selection";
|
||||
export * from "./shape";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./snapping";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./textElement";
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
line,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
} from "@excalidraw/math";
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
import {
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
isPathALoop,
|
||||
snapLinearElementPoint,
|
||||
snapToDiscreteAngle,
|
||||
type SnapLine,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -321,9 +325,10 @@ export class LinearElementEditor {
|
||||
: 0
|
||||
: linearElementEditor.pointerDownState.lastClickedPoint;
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[lastClickedPoint];
|
||||
|
||||
let _snapLines: SnapLine[] = [];
|
||||
|
||||
if (selectedPointsIndices && draggingPoint) {
|
||||
if (
|
||||
shouldRotateWithDiscreteAngle(event) &&
|
||||
@@ -340,13 +345,85 @@ export class LinearElementEditor {
|
||||
element.points[selectedIndex][0] - referencePoint[0],
|
||||
);
|
||||
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
elementsMap,
|
||||
referencePoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
customLineAngle,
|
||||
const referencePointCoords =
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
referencePoint,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
let dxFromReference = gridX - referencePointCoords[0];
|
||||
let dyFromReference = gridY - referencePointCoords[1];
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event)) {
|
||||
({ width: dxFromReference, height: dyFromReference } =
|
||||
getLockedLinearCursorAlignSize(
|
||||
referencePointCoords[0],
|
||||
referencePointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
customLineAngle,
|
||||
));
|
||||
}
|
||||
|
||||
const effectiveGridX = referencePointCoords[0] + dxFromReference;
|
||||
const effectiveGridY = referencePointCoords[1] + dyFromReference;
|
||||
|
||||
if (!isElbowArrow(element)) {
|
||||
const { snapOffset, snapLines } = snapLinearElementPoint(
|
||||
app.scene.getNonDeletedElements(),
|
||||
element,
|
||||
lastClickedPoint,
|
||||
pointFrom<GlobalPoint>(effectiveGridX, effectiveGridY),
|
||||
app,
|
||||
event,
|
||||
elementsMap,
|
||||
{ includeSelfPoints: true },
|
||||
);
|
||||
|
||||
_snapLines = snapLines;
|
||||
|
||||
if (snapLines.length > 0 && shouldRotateWithDiscreteAngle(event)) {
|
||||
const angleLine = line<GlobalPoint>(
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
pointFrom(referencePointCoords[0], referencePointCoords[1]),
|
||||
);
|
||||
|
||||
const result = snapToDiscreteAngle(
|
||||
snapLines,
|
||||
angleLine,
|
||||
pointFrom(gridX, gridY),
|
||||
referencePointCoords,
|
||||
);
|
||||
|
||||
dxFromReference = result.dxFromReference;
|
||||
dyFromReference = result.dyFromReference;
|
||||
_snapLines = result.snapLines;
|
||||
} else if (snapLines.length > 0) {
|
||||
const snappedGridX = effectiveGridX + snapOffset.x;
|
||||
const snappedGridY = effectiveGridY + snapOffset.y;
|
||||
dxFromReference = snappedGridX - referencePointCoords[0];
|
||||
dyFromReference = snappedGridY - referencePointCoords[1];
|
||||
}
|
||||
}
|
||||
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
pointFrom(dxFromReference, dyFromReference),
|
||||
pointFrom(0, 0),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
const newDraggingPointPosition = pointFrom(
|
||||
referencePoint[0] + rotatedX,
|
||||
referencePoint[1] + rotatedY,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
@@ -356,21 +433,41 @@ export class LinearElementEditor {
|
||||
[
|
||||
selectedIndex,
|
||||
{
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
point: newDraggingPointPosition,
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
]),
|
||||
]) as PointsPositionUpdates,
|
||||
);
|
||||
} else {
|
||||
// Apply object snapping for the point being dragged
|
||||
const originalPointerX =
|
||||
scenePointerX - linearElementEditor.pointerOffset.x;
|
||||
const originalPointerY =
|
||||
scenePointerY - linearElementEditor.pointerOffset.y;
|
||||
|
||||
const { snapOffset, snapLines } = snapLinearElementPoint(
|
||||
app.scene.getNonDeletedElements(),
|
||||
element,
|
||||
lastClickedPoint,
|
||||
pointFrom(originalPointerX, originalPointerY),
|
||||
app,
|
||||
event,
|
||||
elementsMap,
|
||||
{ includeSelfPoints: true, selectedPointsIndices },
|
||||
);
|
||||
|
||||
_snapLines = snapLines;
|
||||
|
||||
// Apply snap offset to get final coordinates
|
||||
const snappedPointerX = originalPointerX + snapOffset.x;
|
||||
const snappedPointerY = originalPointerY + snapOffset.y;
|
||||
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
snappedPointerX,
|
||||
snappedPointerY,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
@@ -384,15 +481,7 @@ export class LinearElementEditor {
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
)
|
||||
? newDraggingPointPosition
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
@@ -488,6 +577,7 @@ export class LinearElementEditor {
|
||||
...app.state,
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBindings,
|
||||
snapLines: _snapLines,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1025,7 +1115,10 @@ export class LinearElementEditor {
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
app: AppClassProperties,
|
||||
): LinearElementEditor | null {
|
||||
): {
|
||||
editingLinearElement: LinearElementEditor;
|
||||
snapLines: readonly SnapLine[];
|
||||
} | null {
|
||||
const appState = app.state;
|
||||
if (!appState.selectedLinearElement?.isEditing) {
|
||||
return null;
|
||||
@@ -1034,7 +1127,10 @@ export class LinearElementEditor {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return appState.selectedLinearElement;
|
||||
return {
|
||||
editingLinearElement: appState.selectedLinearElement,
|
||||
snapLines: appState.snapLines,
|
||||
};
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@@ -1044,37 +1140,131 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||
}
|
||||
return appState.selectedLinearElement?.lastUncommittedPoint
|
||||
? {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
}
|
||||
: appState.selectedLinearElement;
|
||||
return {
|
||||
editingLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
},
|
||||
snapLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
let newPoint: LocalPoint;
|
||||
let snapLines: SnapLine[] = [];
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
const [lastCommittedX, lastCommittedY] = points[points.length - 2] ?? [
|
||||
0, 0,
|
||||
];
|
||||
|
||||
const lastCommittedPointCoords =
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
pointFrom(lastCommittedX, lastCommittedY),
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
let dxFromLastCommitted = gridX - lastCommittedPointCoords[0];
|
||||
let dyFromLastCommitted = gridY - lastCommittedPointCoords[1];
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||
const lastCommittedPoint = points[points.length - 2];
|
||||
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
|
||||
getLockedLinearCursorAlignSize(
|
||||
lastCommittedPointCoords[0],
|
||||
lastCommittedPointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
));
|
||||
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
elementsMap,
|
||||
lastCommittedPoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
const effectiveGridX = lastCommittedPointCoords[0] + dxFromLastCommitted;
|
||||
const effectiveGridY = lastCommittedPointCoords[1] + dyFromLastCommitted;
|
||||
|
||||
if (!isElbowArrow(element)) {
|
||||
const { snapOffset, snapLines: _snapLines } = snapLinearElementPoint(
|
||||
app.scene.getNonDeletedElements(),
|
||||
element,
|
||||
points.length - 1,
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
app,
|
||||
event,
|
||||
elementsMap,
|
||||
{ includeSelfPoints: true },
|
||||
);
|
||||
|
||||
snapLines = _snapLines;
|
||||
|
||||
if (_snapLines.length > 0 && shouldRotateWithDiscreteAngle(event)) {
|
||||
const angleLine = line<GlobalPoint>(
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
pointFrom(lastCommittedPointCoords[0], lastCommittedPointCoords[1]),
|
||||
);
|
||||
|
||||
const result = snapToDiscreteAngle(
|
||||
_snapLines,
|
||||
angleLine,
|
||||
pointFrom(gridX, gridY),
|
||||
lastCommittedPointCoords,
|
||||
);
|
||||
|
||||
dxFromLastCommitted = result.dxFromReference;
|
||||
dyFromLastCommitted = result.dyFromReference;
|
||||
snapLines = result.snapLines;
|
||||
} else if (_snapLines.length > 0) {
|
||||
const snappedGridX = effectiveGridX + snapOffset.x;
|
||||
const snappedGridY = effectiveGridY + snapOffset.y;
|
||||
dxFromLastCommitted = snappedGridX - lastCommittedPointCoords[0];
|
||||
dyFromLastCommitted = snappedGridY - lastCommittedPointCoords[1];
|
||||
} else {
|
||||
snapLines = [];
|
||||
}
|
||||
}
|
||||
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
pointFrom(dxFromLastCommitted, dyFromLastCommitted),
|
||||
pointFrom(0, 0),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
newPoint = pointFrom(
|
||||
width + lastCommittedPoint[0],
|
||||
height + lastCommittedPoint[1],
|
||||
lastCommittedX + rotatedX,
|
||||
lastCommittedY + rotatedY,
|
||||
);
|
||||
} else {
|
||||
const originalPointerX =
|
||||
scenePointerX - appState.selectedLinearElement.pointerOffset.x;
|
||||
const originalPointerY =
|
||||
scenePointerY - appState.selectedLinearElement.pointerOffset.y;
|
||||
|
||||
const { snapOffset, snapLines: snappingLines } = snapLinearElementPoint(
|
||||
app.scene.getNonDeletedElements(),
|
||||
element,
|
||||
points.length - 1,
|
||||
pointFrom(originalPointerX, originalPointerY),
|
||||
app,
|
||||
event,
|
||||
elementsMap,
|
||||
{ includeSelfPoints: true },
|
||||
);
|
||||
|
||||
snapLines = snappingLines;
|
||||
|
||||
const snappedPointerX = originalPointerX + snapOffset.x;
|
||||
const snappedPointerY = originalPointerY + snapOffset.y;
|
||||
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
||||
snappedPointerX,
|
||||
snappedPointerY,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
@@ -1087,7 +1277,7 @@ export class LinearElementEditor {
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
points.length - 1,
|
||||
{
|
||||
point: newPoint,
|
||||
},
|
||||
@@ -1097,9 +1287,13 @@ export class LinearElementEditor {
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
}
|
||||
|
||||
return {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
editingLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
},
|
||||
snapLines,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1125,18 +1319,53 @@ export class LinearElementEditor {
|
||||
static getPointsGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
options: {
|
||||
dragOffset?: { x: number; y: number };
|
||||
excludePointsIndices?: readonly number[];
|
||||
} = {},
|
||||
): GlobalPoint[] {
|
||||
const { dragOffset, excludePointsIndices } = options;
|
||||
|
||||
if (!element.points || element.points.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
return element.points.map((p) => {
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
|
||||
let elementX = element.x;
|
||||
let elementY = element.y;
|
||||
|
||||
if (dragOffset) {
|
||||
elementX += dragOffset.x;
|
||||
elementY += dragOffset.y;
|
||||
}
|
||||
|
||||
const globalPoints: GlobalPoint[] = [];
|
||||
|
||||
for (let i = 0; i < element.points.length; i++) {
|
||||
// Skip the point being edited if specified
|
||||
if (
|
||||
excludePointsIndices?.length &&
|
||||
excludePointsIndices.find((index) => index === i) !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const p = element.points[i];
|
||||
const globalX = elementX + p[0];
|
||||
const globalY = elementY + p[1];
|
||||
|
||||
const rotated = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(globalX, globalY),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
});
|
||||
globalPoints.push(rotated);
|
||||
}
|
||||
|
||||
return globalPoints;
|
||||
}
|
||||
|
||||
static getPointAtIndexGlobalCoordinates(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import {
|
||||
isCloseTo,
|
||||
line,
|
||||
linesIntersectAt,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
rangeInclusive,
|
||||
@@ -13,7 +17,7 @@ import {
|
||||
getDraggedElementsBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
|
||||
import { isBoundToContainer, isElbowArrow } from "@excalidraw/element";
|
||||
|
||||
import { getMaximumGroups } from "@excalidraw/element";
|
||||
|
||||
@@ -29,14 +33,18 @@ import type { MaybeTransformHandleType } from "@excalidraw/element";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
KeyboardModifiersObject,
|
||||
} from "./types";
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
const SNAP_DISTANCE = 8;
|
||||
|
||||
@@ -229,6 +237,19 @@ export const getElementsCorners = (
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
|
||||
if (
|
||||
(element.type === "line" || element.type === "arrow") &&
|
||||
!boundingBoxCorners
|
||||
) {
|
||||
// For linear elements, use actual points instead of bounding box
|
||||
const linearPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element as NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap,
|
||||
{
|
||||
dragOffset,
|
||||
},
|
||||
);
|
||||
result = linearPoints;
|
||||
} else if (
|
||||
(element.type === "diamond" || element.type === "ellipse") &&
|
||||
!boundingBoxCorners
|
||||
) {
|
||||
@@ -311,20 +332,13 @@ const getReferenceElements = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const selectedFrames = selectedElements
|
||||
.filter((element) => isFrameLikeElement(element))
|
||||
.map((frame) => frame.id);
|
||||
|
||||
return getVisibleAndNonSelectedElements(
|
||||
) =>
|
||||
getVisibleAndNonSelectedElements(
|
||||
elements,
|
||||
selectedElements,
|
||||
appState,
|
||||
elementsMap,
|
||||
).filter(
|
||||
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getVisibleGaps = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -634,6 +648,183 @@ export const getReferenceSnapPoints = (
|
||||
.flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap));
|
||||
};
|
||||
|
||||
export const getReferenceSnapPointsForLinearElementPoint = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
editingElement: ExcalidrawLinearElement,
|
||||
editingPointIndex: number,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
options: {
|
||||
includeSelfPoints?: boolean;
|
||||
selectedPointsIndices?: readonly number[];
|
||||
} = {},
|
||||
) => {
|
||||
const { includeSelfPoints = false } = options;
|
||||
|
||||
// Get all reference elements (excluding the one being edited)
|
||||
const referenceElements = getReferenceElements(
|
||||
elements,
|
||||
[editingElement],
|
||||
appState,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const allSnapPoints: GlobalPoint[] = [];
|
||||
|
||||
// Add snap points from all reference elements
|
||||
const referenceGroups = getMaximumGroups(
|
||||
referenceElements,
|
||||
elementsMap,
|
||||
).filter(
|
||||
(elementsGroup) =>
|
||||
!(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
|
||||
);
|
||||
|
||||
for (const elementGroup of referenceGroups) {
|
||||
allSnapPoints.push(...getElementsCorners(elementGroup, elementsMap));
|
||||
}
|
||||
|
||||
// Include other points from the same linear element when creating new points or in editing mode
|
||||
if (includeSelfPoints) {
|
||||
const elementPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
editingElement as NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap,
|
||||
{
|
||||
excludePointsIndices: options.selectedPointsIndices,
|
||||
},
|
||||
);
|
||||
allSnapPoints.push(...elementPoints);
|
||||
}
|
||||
|
||||
return allSnapPoints;
|
||||
};
|
||||
|
||||
export const snapLinearElementPoint = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
editingElement: ExcalidrawLinearElement,
|
||||
editingPointIndex: number,
|
||||
pointerPosition: GlobalPoint,
|
||||
app: AppClassProperties,
|
||||
event: KeyboardModifiersObject,
|
||||
elementsMap: ElementsMap,
|
||||
options: {
|
||||
includeSelfPoints?: boolean;
|
||||
selectedPointsIndices?: readonly number[];
|
||||
} = {},
|
||||
) => {
|
||||
if (
|
||||
!isSnappingEnabled({ app, event, selectedElements: [editingElement] }) ||
|
||||
isElbowArrow(editingElement)
|
||||
) {
|
||||
return {
|
||||
snapOffset: { x: 0, y: 0 },
|
||||
snapLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
const snapDistance = getSnapDistance(app.state.zoom.value);
|
||||
const minOffset = {
|
||||
x: snapDistance,
|
||||
y: snapDistance,
|
||||
};
|
||||
|
||||
const nearestSnapsX: Snaps = [];
|
||||
const nearestSnapsY: Snaps = [];
|
||||
|
||||
// Get reference snap points (all elements except the current point)
|
||||
const referenceSnapPoints = getReferenceSnapPointsForLinearElementPoint(
|
||||
elements,
|
||||
editingElement,
|
||||
editingPointIndex,
|
||||
app.state,
|
||||
elementsMap,
|
||||
options,
|
||||
);
|
||||
|
||||
// Find nearest snaps
|
||||
for (const referencePoint of referenceSnapPoints) {
|
||||
const offsetX = referencePoint[0] - pointerPosition[0];
|
||||
const offsetY = referencePoint[1] - pointerPosition[1];
|
||||
|
||||
if (Math.abs(offsetX) <= minOffset.x) {
|
||||
if (Math.abs(offsetX) < minOffset.x) {
|
||||
nearestSnapsX.length = 0;
|
||||
}
|
||||
|
||||
nearestSnapsX.push({
|
||||
type: "point",
|
||||
points: [pointerPosition, referencePoint],
|
||||
offset: offsetX,
|
||||
});
|
||||
|
||||
minOffset.x = Math.abs(offsetX);
|
||||
}
|
||||
|
||||
if (Math.abs(offsetY) <= minOffset.y) {
|
||||
if (Math.abs(offsetY) < minOffset.y) {
|
||||
nearestSnapsY.length = 0;
|
||||
}
|
||||
|
||||
nearestSnapsY.push({
|
||||
type: "point",
|
||||
points: [pointerPosition, referencePoint],
|
||||
offset: offsetY,
|
||||
});
|
||||
|
||||
minOffset.y = Math.abs(offsetY);
|
||||
}
|
||||
}
|
||||
|
||||
const snapOffset = {
|
||||
x: nearestSnapsX[0]?.offset ?? 0,
|
||||
y: nearestSnapsY[0]?.offset ?? 0,
|
||||
};
|
||||
|
||||
// Create snap lines using the snapped position (fixed position)
|
||||
let pointSnapLines: SnapLine[] = [];
|
||||
|
||||
if (snapOffset.x !== 0 || snapOffset.y !== 0) {
|
||||
// Recalculate snap lines with the snapped position
|
||||
const snappedPosition = pointFrom<GlobalPoint>(
|
||||
pointerPosition[0] + snapOffset.x,
|
||||
pointerPosition[1] + snapOffset.y,
|
||||
);
|
||||
|
||||
const snappedSnapsX: Snaps = [];
|
||||
const snappedSnapsY: Snaps = [];
|
||||
|
||||
// Find the reference points that we're snapping to
|
||||
for (const referencePoint of referenceSnapPoints) {
|
||||
const offsetX = referencePoint[0] - snappedPosition[0];
|
||||
const offsetY = referencePoint[1] - snappedPosition[1];
|
||||
|
||||
// Only include points that we're actually snapping to
|
||||
if (isCloseTo(offsetX, 0, 0.01)) {
|
||||
snappedSnapsX.push({
|
||||
type: "point",
|
||||
points: [snappedPosition, referencePoint],
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCloseTo(offsetY, 0, 0.01)) {
|
||||
snappedSnapsY.push({
|
||||
type: "point",
|
||||
points: [snappedPosition, referencePoint],
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pointSnapLines = createPointSnapLines(snappedSnapsX, snappedSnapsY);
|
||||
}
|
||||
|
||||
return {
|
||||
snapOffset,
|
||||
snapLines: pointSnapLines,
|
||||
};
|
||||
};
|
||||
|
||||
const getPointSnaps = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
selectionSnapPoints: GlobalPoint[],
|
||||
@@ -1413,3 +1604,79 @@ export const isActiveToolNonLinearSnappable = (
|
||||
activeToolType === TOOL_TYPE.text
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Snaps to discrete angle rotation logic.
|
||||
* This function handles the common pattern of finding intersections between
|
||||
* angle lines and snap lines, and updating the snap lines accordingly.
|
||||
*
|
||||
* @param snapLines - The original snap lines from snapping
|
||||
* @param angleLine - The line representing the discrete angle constraint
|
||||
* @param gridPosition - The grid position (original pointer position)
|
||||
* @param referencePosition - The reference position (usually the start point)
|
||||
* @returns Object containing updated snap lines and position deltas
|
||||
*/
|
||||
export const snapToDiscreteAngle = (
|
||||
snapLines: SnapLine[],
|
||||
angleLine: [GlobalPoint, GlobalPoint],
|
||||
gridPosition: GlobalPoint,
|
||||
referencePosition: GlobalPoint,
|
||||
): {
|
||||
snapLines: SnapLine[];
|
||||
dxFromReference: number;
|
||||
dyFromReference: number;
|
||||
} => {
|
||||
if (snapLines.length === 0) {
|
||||
return {
|
||||
snapLines: [],
|
||||
dxFromReference: gridPosition[0] - referencePosition[0],
|
||||
dyFromReference: gridPosition[1] - referencePosition[1],
|
||||
};
|
||||
}
|
||||
|
||||
const firstSnapLine = snapLines[0];
|
||||
if (firstSnapLine.type === "points" && firstSnapLine.points.length > 1) {
|
||||
const snapLine = line(firstSnapLine.points[0], firstSnapLine.points[1]);
|
||||
const intersection = linesIntersectAt<GlobalPoint>(
|
||||
line(angleLine[0], angleLine[1]),
|
||||
snapLine,
|
||||
);
|
||||
|
||||
if (intersection) {
|
||||
const dxFromReference = intersection[0] - referencePosition[0];
|
||||
const dyFromReference = intersection[1] - referencePosition[1];
|
||||
|
||||
const furthestPoint = firstSnapLine.points.reduce(
|
||||
(furthest, point) => {
|
||||
const distance = pointDistance(intersection, point);
|
||||
if (distance > furthest.distance) {
|
||||
return { point, distance };
|
||||
}
|
||||
return furthest;
|
||||
},
|
||||
{
|
||||
point: firstSnapLine.points[0],
|
||||
distance: pointDistance(intersection, firstSnapLine.points[0]),
|
||||
},
|
||||
);
|
||||
|
||||
const updatedSnapLine: PointSnapLine = {
|
||||
type: "points",
|
||||
points: [furthestPoint.point, intersection],
|
||||
};
|
||||
|
||||
return {
|
||||
snapLines: [updatedSnapLine],
|
||||
dxFromReference,
|
||||
dyFromReference,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If no intersection found, return original snap lines with grid position
|
||||
return {
|
||||
snapLines,
|
||||
dxFromReference: gridPosition[0] - referencePosition[0],
|
||||
dyFromReference: gridPosition[1] - referencePosition[1],
|
||||
};
|
||||
};
|
||||
@@ -377,7 +377,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -479,7 +479,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -547,7 +547,7 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`14`,
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -598,7 +598,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -639,7 +639,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -687,7 +687,7 @@ describe("Test Linear Elements", () => {
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`17`,
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -745,7 +745,7 @@ describe("Test Linear Elements", () => {
|
||||
),
|
||||
);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`14`,
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -843,7 +843,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
vectorSubtract,
|
||||
vectorDot,
|
||||
vectorNormalize,
|
||||
line,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
@@ -233,9 +234,21 @@ import {
|
||||
hitElementBoundingBox,
|
||||
isLineElement,
|
||||
isSimpleArrow,
|
||||
isGridModeEnabled,
|
||||
SnapCache,
|
||||
isActiveToolNonLinearSnappable,
|
||||
getSnapLinesAtPointer,
|
||||
snapLinearElementPoint,
|
||||
snapToDiscreteAngle,
|
||||
isSnappingEnabled,
|
||||
getReferenceSnapPoints,
|
||||
getVisibleGaps,
|
||||
snapDraggedElements,
|
||||
snapNewElement,
|
||||
snapResizingElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -361,18 +374,6 @@ import {
|
||||
import { Fonts } from "../fonts";
|
||||
import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
|
||||
import { ImageSceneDataError } from "../errors";
|
||||
import {
|
||||
getSnapLinesAtPointer,
|
||||
snapDraggedElements,
|
||||
isActiveToolNonLinearSnappable,
|
||||
snapNewElement,
|
||||
snapResizingElements,
|
||||
isSnappingEnabled,
|
||||
getVisibleGaps,
|
||||
getReferenceSnapPoints,
|
||||
SnapCache,
|
||||
isGridModeEnabled,
|
||||
} from "../snapping";
|
||||
import { convertToExcalidrawElements } from "../data/transform";
|
||||
import { Renderer } from "../scene/Renderer";
|
||||
import {
|
||||
@@ -5809,9 +5810,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
|
||||
const { x: scenePointerX, y: scenePointerY } = scenePointer;
|
||||
|
||||
// snap origin of the new element that's to be created
|
||||
if (
|
||||
!this.state.newElement &&
|
||||
isActiveToolNonLinearSnappable(this.state.activeTool.type)
|
||||
(isActiveToolNonLinearSnappable(this.state.activeTool.type) ||
|
||||
((this.state.activeTool.type === "line" ||
|
||||
this.state.activeTool.type === "arrow") &&
|
||||
this.state.currentItemArrowType !== ARROW_TYPE.elbow))
|
||||
) {
|
||||
const { originOffset, snapLines } = getSnapLinesAtPointer(
|
||||
this.scene.getNonDeletedElements(),
|
||||
@@ -5860,40 +5865,45 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.selectedLinearElement?.isEditing &&
|
||||
!this.state.selectedLinearElement.isDragging
|
||||
) {
|
||||
const editingLinearElement = LinearElementEditor.handlePointerMove(
|
||||
const result = LinearElementEditor.handlePointerMove(
|
||||
event,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this,
|
||||
);
|
||||
const linearElement = editingLinearElement
|
||||
? this.scene.getElement(editingLinearElement.elementId)
|
||||
: null;
|
||||
|
||||
if (
|
||||
editingLinearElement &&
|
||||
editingLinearElement !== this.state.selectedLinearElement
|
||||
) {
|
||||
// Since we are reading from previous state which is not possible with
|
||||
// automatic batching in React 18 hence using flush sync to synchronously
|
||||
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
|
||||
flushSync(() => {
|
||||
this.setState({
|
||||
selectedLinearElement: editingLinearElement,
|
||||
if (result) {
|
||||
const { editingLinearElement, snapLines } = result;
|
||||
|
||||
if (
|
||||
editingLinearElement &&
|
||||
editingLinearElement !== this.state.selectedLinearElement
|
||||
) {
|
||||
// Since we are reading from previous state which is not possible with
|
||||
// automatic batching in React 18 hence using flush sync to synchronously
|
||||
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
|
||||
flushSync(() => {
|
||||
this.setState({
|
||||
selectedLinearElement: editingLinearElement,
|
||||
snapLines,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
if (
|
||||
editingLinearElement?.lastUncommittedPoint != null &&
|
||||
linearElement &&
|
||||
isBindingElementType(linearElement.type)
|
||||
) {
|
||||
this.maybeSuggestBindingAtCursor(
|
||||
scenePointer,
|
||||
editingLinearElement.elbowed,
|
||||
}
|
||||
const latestLinearElement = this.scene.getElement(
|
||||
editingLinearElement.elementId,
|
||||
);
|
||||
} else if (this.state.suggestedBindings.length) {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
if (
|
||||
editingLinearElement.lastUncommittedPoint != null &&
|
||||
latestLinearElement &&
|
||||
isBindingElementType(latestLinearElement.type)
|
||||
) {
|
||||
this.maybeSuggestBindingAtCursor(
|
||||
scenePointer,
|
||||
editingLinearElement.elbowed,
|
||||
);
|
||||
} else if (this.state.suggestedBindings.length) {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5980,7 +5990,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let dxFromLastCommitted = gridX - rx - lastCommittedX;
|
||||
let dyFromLastCommitted = gridY - ry - lastCommittedY;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event)) {
|
||||
const rotateWithDiscreteAngle = shouldRotateWithDiscreteAngle(event);
|
||||
|
||||
if (rotateWithDiscreteAngle) {
|
||||
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
|
||||
getLockedLinearCursorAlignSize(
|
||||
// actual coordinate of the last committed point
|
||||
@@ -5992,10 +6004,65 @@ class App extends React.Component<AppProps, AppState> {
|
||||
));
|
||||
}
|
||||
|
||||
const effectiveGridX = lastCommittedX + dxFromLastCommitted + rx;
|
||||
const effectiveGridY = lastCommittedY + dyFromLastCommitted + ry;
|
||||
|
||||
if (!isElbowArrow(multiElement)) {
|
||||
const { snapOffset, snapLines } = snapLinearElementPoint(
|
||||
this.scene.getNonDeletedElements(),
|
||||
multiElement,
|
||||
points.length - 1,
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
this,
|
||||
event,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
{
|
||||
includeSelfPoints: true,
|
||||
selectedPointsIndices: [points.length - 1],
|
||||
},
|
||||
);
|
||||
|
||||
if (snapLines.length > 0) {
|
||||
if (rotateWithDiscreteAngle) {
|
||||
// Create line from effective position to last committed point
|
||||
const angleLine = line<GlobalPoint>(
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
pointFrom(lastCommittedX + rx, lastCommittedY + ry),
|
||||
);
|
||||
|
||||
const result = snapToDiscreteAngle(
|
||||
snapLines,
|
||||
angleLine,
|
||||
pointFrom(gridX, gridY),
|
||||
pointFrom(lastCommittedX + rx, lastCommittedY + ry),
|
||||
);
|
||||
|
||||
dxFromLastCommitted = result.dxFromReference;
|
||||
dyFromLastCommitted = result.dyFromReference;
|
||||
|
||||
this.setState({
|
||||
snapLines: result.snapLines,
|
||||
});
|
||||
} else {
|
||||
const snappedGridX = effectiveGridX + snapOffset.x;
|
||||
const snappedGridY = effectiveGridY + snapOffset.y;
|
||||
dxFromLastCommitted = snappedGridX - rx - lastCommittedX;
|
||||
dyFromLastCommitted = snappedGridY - ry - lastCommittedY;
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
snapLines: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isPathALoop(points, this.state.zoom.value)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
}
|
||||
|
||||
// update last uncommitted point
|
||||
this.scene.mutateElement(
|
||||
multiElement,
|
||||
@@ -8674,7 +8741,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let dx = gridX - newElement.x;
|
||||
let dy = gridY - newElement.y;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
|
||||
const rotateWithDiscreteAngle =
|
||||
shouldRotateWithDiscreteAngle(event) && points.length === 2;
|
||||
|
||||
if (rotateWithDiscreteAngle) {
|
||||
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
|
||||
newElement.x,
|
||||
newElement.y,
|
||||
@@ -8683,6 +8753,60 @@ class App extends React.Component<AppProps, AppState> {
|
||||
));
|
||||
}
|
||||
|
||||
const effectiveGridX = newElement.x + dx;
|
||||
const effectiveGridY = newElement.y + dy;
|
||||
|
||||
// Snap a two-point line/arrow as well
|
||||
if (!isElbowArrow(newElement)) {
|
||||
const { snapOffset, snapLines } = snapLinearElementPoint(
|
||||
this.scene.getNonDeletedElements(),
|
||||
newElement,
|
||||
points.length - 1,
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
this,
|
||||
event,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
{
|
||||
includeSelfPoints: true,
|
||||
selectedPointsIndices: [points.length - 1],
|
||||
},
|
||||
);
|
||||
|
||||
if (snapLines.length > 0) {
|
||||
if (rotateWithDiscreteAngle) {
|
||||
const angleLine = line<GlobalPoint>(
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
pointFrom(newElement.x, newElement.y),
|
||||
);
|
||||
|
||||
const result = snapToDiscreteAngle(
|
||||
snapLines,
|
||||
angleLine,
|
||||
pointFrom(gridX, gridY),
|
||||
pointFrom(newElement.x, newElement.y),
|
||||
);
|
||||
|
||||
dx = result.dxFromReference;
|
||||
dy = result.dyFromReference;
|
||||
|
||||
this.setState({
|
||||
snapLines: result.snapLines,
|
||||
});
|
||||
} else {
|
||||
dx = gridX + snapOffset.x - newElement.x;
|
||||
dy = gridY + snapOffset.y - newElement.y;
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
snapLines: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
this.scene.mutateElement(
|
||||
newElement,
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
|
||||
import { getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { isNodeInFlowchart } from "@excalidraw/element";
|
||||
import { isNodeInFlowchart, isGridModeEnabled } from "@excalidraw/element";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isEraserActive } from "../appState";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
|
||||
|
||||
import { elementsAreInSameGroup } from "@excalidraw/element";
|
||||
|
||||
import { isGridModeEnabled } from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { isGridModeEnabled } from "../../snapping";
|
||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||
import { Island } from "../Island";
|
||||
import { CloseIcon } from "../icons";
|
||||
|
||||
@@ -4,13 +4,7 @@ import {
|
||||
supported as nativeFileSystemSupported,
|
||||
} from "browser-fs-access";
|
||||
|
||||
import {
|
||||
EVENT,
|
||||
MIME_TYPES,
|
||||
debounce,
|
||||
isIOS,
|
||||
isAndroid,
|
||||
} from "@excalidraw/common";
|
||||
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
|
||||
@@ -19,8 +13,6 @@ import type { FileSystemHandle } from "browser-fs-access";
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
// increase timeout for mobile devices to give more time for file selection
|
||||
const MOBILE_INPUT_CHANGE_INTERVAL_MS = 2000;
|
||||
|
||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
@@ -49,22 +41,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const isMobile = isIOS || isAndroid;
|
||||
const intervalMs = isMobile
|
||||
? MOBILE_INPUT_CHANGE_INTERVAL_MS
|
||||
: INPUT_CHANGE_INTERVAL_MS;
|
||||
const scheduleRejection = debounce(reject, intervalMs);
|
||||
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
// on mobile, be less aggressive with rejection
|
||||
if (!isMobile) {
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
}
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
@@ -72,15 +55,12 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||
});
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, intervalMs);
|
||||
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
@@ -89,9 +69,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn(
|
||||
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
|
||||
);
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new AbortError());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement, renderElement } from "@excalidraw/element";
|
||||
import {
|
||||
getTargetFrame,
|
||||
isInvisiblySmallElement,
|
||||
renderElement,
|
||||
shouldApplyFrameClip,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
||||
|
||||
import { frameClip } from "./staticScene";
|
||||
|
||||
import type { NewElementSceneRenderConfig } from "../scene/types";
|
||||
|
||||
const _renderNewElementScene = ({
|
||||
@@ -29,8 +36,9 @@ const _renderNewElementScene = ({
|
||||
normalizedHeight,
|
||||
});
|
||||
|
||||
// Apply zoom
|
||||
context.save();
|
||||
|
||||
// Apply zoom
|
||||
context.scale(appState.zoom.value, appState.zoom.value);
|
||||
|
||||
if (newElement && newElement.type !== "selection") {
|
||||
@@ -42,6 +50,23 @@ const _renderNewElementScene = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = newElement.frameId || appState.frameToHighlight?.id;
|
||||
|
||||
if (
|
||||
frameId &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
const frame = getTargetFrame(newElement, elementsMap, appState);
|
||||
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
}
|
||||
|
||||
renderElement(
|
||||
newElement,
|
||||
elementsMap,
|
||||
@@ -54,6 +79,8 @@ const _renderNewElementScene = ({
|
||||
} else {
|
||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { THEME } from "@excalidraw/common";
|
||||
|
||||
import type { PointSnapLine, PointerSnapLine } from "../snapping";
|
||||
import type { PointSnapLine, PointerSnapLine } from "@excalidraw/element";
|
||||
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
|
||||
const SNAP_COLOR_LIGHT = "#ff6b6b";
|
||||
|
||||
@@ -113,7 +113,7 @@ const strokeGrid = (
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const frameClip = (
|
||||
export const frameClip = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
|
||||
@@ -8634,7 +8634,10 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -9280,7 +9283,10 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import type { MaybeTransformHandleType } from "@excalidraw/element";
|
||||
|
||||
import type { SnapLine } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
PointerType,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -55,7 +57,6 @@ import type App from "./components/App";
|
||||
import type Library from "./data/library";
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||
import type { SnapLine } from "./snapping";
|
||||
import type { ImportedDataState } from "./data/types";
|
||||
|
||||
import type { Language } from "./i18n";
|
||||
|
||||
Reference in New Issue
Block a user