Fix many syncing issues (#952)
This commit is contained in:
parent
b20d4539c0
commit
3f8144ef85
2
.gitignore
vendored
2
.gitignore
vendored
@ -9,3 +9,5 @@ static
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
.envrc
|
||||||
|
firebase/
|
||||||
|
@ -9,6 +9,7 @@ import { KEYS } from "../keys";
|
|||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@ -33,9 +34,11 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
commitToHistory: () => true,
|
commitToHistory: () => true,
|
||||||
perform: () => {
|
perform: elements => {
|
||||||
return {
|
return {
|
||||||
elements: [],
|
elements: elements.map(element =>
|
||||||
|
newElementWith(element, { isDeleted: true }),
|
||||||
|
),
|
||||||
appState: getDefaultAppState(),
|
appState: getDefaultAppState(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -7,8 +7,11 @@ import { SceneHistory } from "../history";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { getElementMap } from "../element";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
|
prevElements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
updater: () => { elements: ExcalidrawElement[]; appState: AppState } | null,
|
updater: () => { elements: ExcalidrawElement[]; appState: AppState } | null,
|
||||||
) => {
|
) => {
|
||||||
@ -19,13 +22,32 @@ const writeData = (
|
|||||||
!appState.draggingElement
|
!appState.draggingElement
|
||||||
) {
|
) {
|
||||||
const data = updater();
|
const data = updater();
|
||||||
|
if (data === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
return data === null
|
const prevElementMap = getElementMap(prevElements);
|
||||||
? {}
|
const nextElements = data.elements;
|
||||||
: {
|
const nextElementMap = getElementMap(nextElements);
|
||||||
elements: data.elements,
|
return {
|
||||||
appState: { ...appState, ...data.appState },
|
elements: nextElements
|
||||||
};
|
.map(nextElement =>
|
||||||
|
newElementWith(
|
||||||
|
prevElementMap[nextElement.id] || nextElement,
|
||||||
|
nextElement,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.concat(
|
||||||
|
prevElements
|
||||||
|
.filter(
|
||||||
|
prevElement => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||||
|
)
|
||||||
|
.map(prevElement =>
|
||||||
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
appState: { ...appState, ...data.appState },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
@ -37,7 +59,8 @@ type ActionCreator = (history: SceneHistory) => Action;
|
|||||||
|
|
||||||
export const createUndoAction: ActionCreator = history => ({
|
export const createUndoAction: ActionCreator = history => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
perform: (_, appState) => writeData(appState, () => history.undoOnce()),
|
perform: (elements, appState) =>
|
||||||
|
writeData(elements, appState, () => history.undoOnce()),
|
||||||
keyTest: testUndo(false),
|
keyTest: testUndo(false),
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@ -52,7 +75,8 @@ export const createUndoAction: ActionCreator = history => ({
|
|||||||
|
|
||||||
export const createRedoAction: ActionCreator = history => ({
|
export const createRedoAction: ActionCreator = history => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
perform: (_, appState) => writeData(appState, () => history.redoOnce()),
|
perform: (elements, appState) =>
|
||||||
|
writeData(elements, appState, () => history.redoOnce()),
|
||||||
keyTest: testUndo(true),
|
keyTest: testUndo(true),
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
@ -34,7 +34,6 @@ export function getDefaultAppState(): AppState {
|
|||||||
openMenu: null,
|
openMenu: null,
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
deletedIds: {},
|
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,10 @@ import {
|
|||||||
getCursorForResizingElement,
|
getCursorForResizingElement,
|
||||||
getPerfectElementSize,
|
getPerfectElementSize,
|
||||||
normalizeDimensions,
|
normalizeDimensions,
|
||||||
|
getElementMap,
|
||||||
|
getDrawingVersion,
|
||||||
|
getSyncableElements,
|
||||||
|
hasNonDeletedElements,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import {
|
import {
|
||||||
deleteSelectedElements,
|
deleteSelectedElements,
|
||||||
@ -160,6 +164,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initalized
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initalized
|
||||||
roomID: string | null = null;
|
roomID: string | null = null;
|
||||||
roomKey: string | null = null;
|
roomKey: string | null = null;
|
||||||
|
lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
|
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
canvasOnlyActions = ["selectAll"];
|
canvasOnlyActions = ["selectAll"];
|
||||||
@ -275,15 +280,11 @@ export class App extends React.Component<any, AppState> {
|
|||||||
iv,
|
iv,
|
||||||
);
|
);
|
||||||
|
|
||||||
let deletedIds = this.state.deletedIds;
|
|
||||||
switch (decryptedData.type) {
|
switch (decryptedData.type) {
|
||||||
case "INVALID_RESPONSE":
|
case "INVALID_RESPONSE":
|
||||||
return;
|
return;
|
||||||
case "SCENE_UPDATE":
|
case "SCENE_UPDATE":
|
||||||
const {
|
const { elements: remoteElements } = decryptedData.payload;
|
||||||
elements: remoteElements,
|
|
||||||
appState: remoteAppState,
|
|
||||||
} = decryptedData.payload;
|
|
||||||
const restoredState = restore(remoteElements || [], null, {
|
const restoredState = restore(remoteElements || [], null, {
|
||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
});
|
});
|
||||||
@ -295,32 +296,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
} else {
|
} else {
|
||||||
// create a map of ids so we don't have to iterate
|
// create a map of ids so we don't have to iterate
|
||||||
// over the array more than once.
|
// over the array more than once.
|
||||||
const localElementMap = elements.reduce(
|
const localElementMap = getElementMap(elements);
|
||||||
(
|
|
||||||
acc: { [key: string]: ExcalidrawElement },
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
) => {
|
|
||||||
acc[element.id] = element;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
deletedIds = { ...deletedIds };
|
|
||||||
|
|
||||||
for (const [id, remoteDeletedEl] of Object.entries(
|
|
||||||
remoteAppState.deletedIds,
|
|
||||||
)) {
|
|
||||||
if (
|
|
||||||
!localElementMap[id] ||
|
|
||||||
// don't remove local element if it's newer than the one
|
|
||||||
// deleted on remote
|
|
||||||
remoteDeletedEl.version >= localElementMap[id].version
|
|
||||||
) {
|
|
||||||
deletedIds[id] = remoteDeletedEl;
|
|
||||||
delete localElementMap[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconcile
|
// Reconcile
|
||||||
elements = restoredState.elements
|
elements = restoredState.elements
|
||||||
@ -342,17 +318,27 @@ export class App extends React.Component<any, AppState> {
|
|||||||
) {
|
) {
|
||||||
elements.push(localElementMap[element.id]);
|
elements.push(localElementMap[element.id]);
|
||||||
delete localElementMap[element.id];
|
delete localElementMap[element.id];
|
||||||
} else {
|
} else if (
|
||||||
if (deletedIds.hasOwnProperty(element.id)) {
|
localElementMap.hasOwnProperty(element.id) &&
|
||||||
if (element.version > deletedIds[element.id].version) {
|
localElementMap[element.id].version === element.version &&
|
||||||
elements.push(element);
|
localElementMap[element.id].versionNonce !==
|
||||||
delete deletedIds[element.id];
|
element.versionNonce
|
||||||
delete localElementMap[element.id];
|
) {
|
||||||
}
|
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
||||||
|
if (
|
||||||
|
localElementMap[element.id].versionNonce <
|
||||||
|
element.versionNonce
|
||||||
|
) {
|
||||||
|
elements.push(localElementMap[element.id]);
|
||||||
} else {
|
} else {
|
||||||
|
// it should be highly unlikely that the two versionNonces are the same. if we are
|
||||||
|
// really worried about this, we can replace the versionNonce with the socket id.
|
||||||
elements.push(element);
|
elements.push(element);
|
||||||
delete localElementMap[element.id];
|
|
||||||
}
|
}
|
||||||
|
delete localElementMap[element.id];
|
||||||
|
} else {
|
||||||
|
elements.push(element);
|
||||||
|
delete localElementMap[element.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
@ -360,9 +346,15 @@ export class App extends React.Component<any, AppState> {
|
|||||||
// add local elements that weren't deleted or on remote
|
// add local elements that weren't deleted or on remote
|
||||||
.concat(...Object.values(localElementMap));
|
.concat(...Object.values(localElementMap));
|
||||||
}
|
}
|
||||||
this.setState({
|
this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
|
||||||
deletedIds,
|
elements,
|
||||||
});
|
);
|
||||||
|
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
||||||
|
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
||||||
|
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
||||||
|
// right now we think this is the right tradeoff.
|
||||||
|
history.clear();
|
||||||
|
this.setState({});
|
||||||
if (this.socketInitialized === false) {
|
if (this.socketInitialized === false) {
|
||||||
this.socketInitialized = true;
|
this.socketInitialized = true;
|
||||||
}
|
}
|
||||||
@ -370,13 +362,13 @@ export class App extends React.Component<any, AppState> {
|
|||||||
case "MOUSE_LOCATION":
|
case "MOUSE_LOCATION":
|
||||||
const { socketID, pointerCoords } = decryptedData.payload;
|
const { socketID, pointerCoords } = decryptedData.payload;
|
||||||
this.setState(state => {
|
this.setState(state => {
|
||||||
if (state.collaborators.has(socketID)) {
|
if (!state.collaborators.has(socketID)) {
|
||||||
const user = state.collaborators.get(socketID)!;
|
state.collaborators.set(socketID, {});
|
||||||
user.pointer = pointerCoords;
|
|
||||||
state.collaborators.set(socketID, user);
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
return null;
|
const user = state.collaborators.get(socketID)!;
|
||||||
|
user.pointer = pointerCoords;
|
||||||
|
state.collaborators.set(socketID, user);
|
||||||
|
return state;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -428,24 +420,16 @@ export class App extends React.Component<any, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private broadcastSceneUpdate = () => {
|
private broadcastSceneUpdate = () => {
|
||||||
const deletedIds = { ...this.state.deletedIds };
|
|
||||||
const _elements = elements.filter(element => {
|
|
||||||
if (element.id in deletedIds) {
|
|
||||||
delete deletedIds[element.id];
|
|
||||||
}
|
|
||||||
return element.id !== this.state.editingElement?.id;
|
|
||||||
});
|
|
||||||
const data: SocketUpdateDataSource["SCENE_UPDATE"] = {
|
const data: SocketUpdateDataSource["SCENE_UPDATE"] = {
|
||||||
type: "SCENE_UPDATE",
|
type: "SCENE_UPDATE",
|
||||||
payload: {
|
payload: {
|
||||||
elements: _elements,
|
elements: getSyncableElements(elements),
|
||||||
appState: {
|
|
||||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
|
||||||
name: this.state.name,
|
|
||||||
deletedIds,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion = Math.max(
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion,
|
||||||
|
getDrawingVersion(elements),
|
||||||
|
);
|
||||||
return this._broadcastSocketData(
|
return this._broadcastSocketData(
|
||||||
data as typeof data & { _brand: "socketUpdateData" },
|
data as typeof data & { _brand: "socketUpdateData" },
|
||||||
);
|
);
|
||||||
@ -840,7 +824,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
action: () => this.pasteFromClipboard(null),
|
action: () => this.pasteFromClipboard(null),
|
||||||
},
|
},
|
||||||
probablySupportsClipboardBlob &&
|
probablySupportsClipboardBlob &&
|
||||||
elements.length > 0 && {
|
hasNonDeletedElements(elements) && {
|
||||||
label: t("labels.copyAsPng"),
|
label: t("labels.copyAsPng"),
|
||||||
action: this.copyToClipboardAsPng,
|
action: this.copyToClipboardAsPng,
|
||||||
},
|
},
|
||||||
@ -1102,6 +1086,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
const pnt = points[points.length - 1];
|
const pnt = points[points.length - 1];
|
||||||
pnt[0] = x - originX;
|
pnt[0] = x - originX;
|
||||||
pnt[1] = y - originY;
|
pnt[1] = y - originY;
|
||||||
|
mutateElement(multiElement);
|
||||||
invalidateShapeForElement(multiElement);
|
invalidateShapeForElement(multiElement);
|
||||||
this.setState({});
|
this.setState({});
|
||||||
return;
|
return;
|
||||||
@ -1485,6 +1470,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
multiElement.points.push([x - rx, y - ry]);
|
multiElement.points.push([x - rx, y - ry]);
|
||||||
|
mutateElement(multiElement);
|
||||||
invalidateShapeForElement(multiElement);
|
invalidateShapeForElement(multiElement);
|
||||||
} else {
|
} else {
|
||||||
this.setState(prevState => ({
|
this.setState(prevState => ({
|
||||||
@ -1494,6 +1480,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
element.points.push([0, 0]);
|
element.points.push([0, 0]);
|
||||||
|
mutateElement(element);
|
||||||
invalidateShapeForElement(element);
|
invalidateShapeForElement(element);
|
||||||
elements = [...elements, element];
|
elements = [...elements, element];
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -1548,20 +1535,19 @@ export class App extends React.Component<any, AppState> {
|
|||||||
|
|
||||||
const dx = element.x + width + p1[0];
|
const dx = element.x + width + p1[0];
|
||||||
const dy = element.y + height + p1[1];
|
const dy = element.y + height + p1[1];
|
||||||
|
p1[0] = absPx - element.x;
|
||||||
|
p1[1] = absPy - element.y;
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
x: dx,
|
x: dx,
|
||||||
y: dy,
|
y: dy,
|
||||||
});
|
});
|
||||||
p1[0] = absPx - element.x;
|
|
||||||
p1[1] = absPy - element.y;
|
|
||||||
} else {
|
} else {
|
||||||
|
p1[0] -= deltaX;
|
||||||
|
p1[1] -= deltaY;
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
x: element.x + deltaX,
|
x: element.x + deltaX,
|
||||||
y: element.y + deltaY,
|
y: element.y + deltaY,
|
||||||
});
|
});
|
||||||
|
|
||||||
p1[0] -= deltaX;
|
|
||||||
p1[1] -= deltaY;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1586,6 +1572,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
p1[0] += deltaX;
|
p1[0] += deltaX;
|
||||||
p1[1] += deltaY;
|
p1[1] += deltaY;
|
||||||
}
|
}
|
||||||
|
mutateElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerMove = (event: PointerEvent) => {
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
@ -1925,6 +1912,8 @@ export class App extends React.Component<any, AppState> {
|
|||||||
pnt[0] = dx;
|
pnt[0] = dx;
|
||||||
pnt[1] = dy;
|
pnt[1] = dy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutateElement(draggingElement, { points });
|
||||||
} else {
|
} else {
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
({ width, height } = getPerfectElementSize(
|
({ width, height } = getPerfectElementSize(
|
||||||
@ -2005,6 +1994,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
x - draggingElement.x,
|
x - draggingElement.x,
|
||||||
y - draggingElement.y,
|
y - draggingElement.y,
|
||||||
]);
|
]);
|
||||||
|
mutateElement(draggingElement);
|
||||||
invalidateShapeForElement(draggingElement);
|
invalidateShapeForElement(draggingElement);
|
||||||
this.setState({
|
this.setState({
|
||||||
multiElement: this.state.draggingElement,
|
multiElement: this.state.draggingElement,
|
||||||
@ -2263,13 +2253,20 @@ export class App extends React.Component<any, AppState> {
|
|||||||
if (scrollBars) {
|
if (scrollBars) {
|
||||||
currentScrollBars = scrollBars;
|
currentScrollBars = scrollBars;
|
||||||
}
|
}
|
||||||
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
|
const scrolledOutside =
|
||||||
|
!atLeastOneVisibleElement && hasNonDeletedElements(elements);
|
||||||
if (this.state.scrolledOutside !== scrolledOutside) {
|
if (this.state.scrolledOutside !== scrolledOutside) {
|
||||||
this.setState({ scrolledOutside: scrolledOutside });
|
this.setState({ scrolledOutside: scrolledOutside });
|
||||||
}
|
}
|
||||||
this.saveDebounced();
|
this.saveDebounced();
|
||||||
if (history.isRecording()) {
|
|
||||||
|
if (
|
||||||
|
getDrawingVersion(elements) > this.lastBroadcastedOrReceivedSceneVersion
|
||||||
|
) {
|
||||||
this.broadcastSceneUpdate();
|
this.broadcastSceneUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (history.isRecording()) {
|
||||||
history.pushEntry(this.state, elements);
|
history.pushEntry(this.state, elements);
|
||||||
history.skipRecording();
|
history.skipRecording();
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import { serializeAsJSON } from "./json";
|
|||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { restore } from "./restore";
|
import { restore } from "./restore";
|
||||||
import { restoreFromLocalStorage } from "./localStorage";
|
import { restoreFromLocalStorage } from "./localStorage";
|
||||||
|
import { hasNonDeletedElements } from "../element";
|
||||||
|
|
||||||
export { loadFromBlob } from "./blob";
|
export { loadFromBlob } from "./blob";
|
||||||
export { saveAsJSON, loadFromJSON } from "./json";
|
export { saveAsJSON, loadFromJSON } from "./json";
|
||||||
@ -35,7 +36,6 @@ export type SocketUpdateDataSource = {
|
|||||||
type: "SCENE_UPDATE";
|
type: "SCENE_UPDATE";
|
||||||
payload: {
|
payload: {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: Pick<AppState, "viewBackgroundColor" | "name" | "deletedIds">;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
MOUSE_LOCATION: {
|
MOUSE_LOCATION: {
|
||||||
@ -288,7 +288,7 @@ export async function exportCanvas(
|
|||||||
scale?: number;
|
scale?: number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (!elements.length) {
|
if (!hasNonDeletedElements(elements)) {
|
||||||
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
||||||
}
|
}
|
||||||
// calculate smallest area to fit the contents in
|
// calculate smallest area to fit the contents in
|
||||||
|
@ -52,7 +52,8 @@ export function restore(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
version: element.version || 0,
|
// all elements must have version > 0 so getDrawingVersion() will pick up newly added elements
|
||||||
|
version: element.version || 1,
|
||||||
id: element.id || nanoid(),
|
id: element.id || nanoid(),
|
||||||
fillStyle: element.fillStyle || "hachure",
|
fillStyle: element.fillStyle || "hachure",
|
||||||
strokeWidth: element.strokeWidth || 1,
|
strokeWidth: element.strokeWidth || 1,
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { ExcalidrawElement } from "./types";
|
||||||
|
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||||
|
|
||||||
export { newElement, newTextElement, duplicateElement } from "./newElement";
|
export { newElement, newTextElement, duplicateElement } from "./newElement";
|
||||||
export {
|
export {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
@ -24,3 +27,28 @@ export {
|
|||||||
normalizeDimensions,
|
normalizeDimensions,
|
||||||
} from "./sizeHelpers";
|
} from "./sizeHelpers";
|
||||||
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
||||||
|
|
||||||
|
export function getSyncableElements(elements: readonly ExcalidrawElement[]) {
|
||||||
|
// There are places in Excalidraw where synthetic invisibly small elements are added and removed.
|
||||||
|
// It's probably best to keep those local otherwise there might be a race condition that
|
||||||
|
// gets the app into an invalid state. I've never seen it happen but I'm worried about it :)
|
||||||
|
return elements.filter(el => !isInvisiblySmallElement(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementMap(elements: readonly ExcalidrawElement[]) {
|
||||||
|
return getSyncableElements(elements).reduce(
|
||||||
|
(acc: { [key: string]: ExcalidrawElement }, element: ExcalidrawElement) => {
|
||||||
|
acc[element.id] = element;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDrawingVersion(elements: readonly ExcalidrawElement[]) {
|
||||||
|
return elements.reduce((acc, el) => acc + el.version, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNonDeletedElements(elements: readonly ExcalidrawElement[]) {
|
||||||
|
return elements.some(element => !element.isDeleted);
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "./types";
|
import { ExcalidrawElement, ExcalidrawTextElement } from "./types";
|
||||||
|
import { randomSeed } from "roughjs/bin/math";
|
||||||
|
|
||||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
@ -10,17 +11,25 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
|||||||
// the same drawing.
|
// the same drawing.
|
||||||
export function mutateElement(
|
export function mutateElement(
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
updates: ElementUpdate<ExcalidrawElement>,
|
updates?: ElementUpdate<ExcalidrawElement>,
|
||||||
) {
|
) {
|
||||||
Object.assign(element, updates);
|
if (updates) {
|
||||||
|
Object.assign(element, updates);
|
||||||
|
}
|
||||||
(element as any).version++;
|
(element as any).version++;
|
||||||
|
(element as any).versionNonce = randomSeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newElementWith(
|
export function newElementWith(
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
updates: ElementUpdate<ExcalidrawElement>,
|
updates: ElementUpdate<ExcalidrawElement>,
|
||||||
): ExcalidrawElement {
|
): ExcalidrawElement {
|
||||||
return { ...element, ...updates, version: element.version + 1 };
|
return {
|
||||||
|
...element,
|
||||||
|
...updates,
|
||||||
|
version: element.version + 1,
|
||||||
|
versionNonce: randomSeed(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function tracks updates of text elements for the purposes for collaboration.
|
// This function tracks updates of text elements for the purposes for collaboration.
|
||||||
@ -32,11 +41,17 @@ export function mutateTextElement(
|
|||||||
): void {
|
): void {
|
||||||
Object.assign(element, updates);
|
Object.assign(element, updates);
|
||||||
(element as any).version++;
|
(element as any).version++;
|
||||||
|
(element as any).versionNonce = randomSeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newTextElementWith(
|
export function newTextElementWith(
|
||||||
element: ExcalidrawTextElement,
|
element: ExcalidrawTextElement,
|
||||||
updates: ElementUpdate<ExcalidrawTextElement>,
|
updates: ElementUpdate<ExcalidrawTextElement>,
|
||||||
): ExcalidrawTextElement {
|
): ExcalidrawTextElement {
|
||||||
return { ...element, ...updates, version: element.version + 1 };
|
return {
|
||||||
|
...element,
|
||||||
|
...updates,
|
||||||
|
version: element.version + 1,
|
||||||
|
versionNonce: randomSeed(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,8 @@ export function newElement(
|
|||||||
seed: randomSeed(),
|
seed: randomSeed(),
|
||||||
points: [] as Point[],
|
points: [] as Point[],
|
||||||
version: 1,
|
version: 1,
|
||||||
|
versionNonce: 0,
|
||||||
|
isDeleted: false,
|
||||||
};
|
};
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,11 @@ export class SceneHistory {
|
|||||||
private stateHistory: string[] = [];
|
private stateHistory: string[] = [];
|
||||||
private redoStack: string[] = [];
|
private redoStack: string[] = [];
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.stateHistory.length = 0;
|
||||||
|
this.redoStack.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
private generateEntry(
|
private generateEntry(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
@ -24,7 +24,7 @@ function colorForClientId(clientId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderScene(
|
export function renderScene(
|
||||||
elements: readonly ExcalidrawElement[],
|
allElements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
selectionElement: ExcalidrawElement | null,
|
selectionElement: ExcalidrawElement | null,
|
||||||
scale: number,
|
scale: number,
|
||||||
@ -49,6 +49,8 @@ export function renderScene(
|
|||||||
return { atLeastOneVisibleElement: false };
|
return { atLeastOneVisibleElement: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elements = allElements.filter(element => !element.isDeleted);
|
||||||
|
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
|
|
||||||
// When doing calculations based on canvas width we should used normalized one
|
// When doing calculations based on canvas width we should used normalized one
|
||||||
|
@ -25,6 +25,9 @@ export function getElementAtPosition(
|
|||||||
let hitElement = null;
|
let hitElement = null;
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||||
for (let i = elements.length - 1; i >= 0; --i) {
|
for (let i = elements.length - 1; i >= 0; --i) {
|
||||||
|
if (elements[i].isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (hitTest(elements[i], appState, x, y, zoom)) {
|
if (hitTest(elements[i], appState, x, y, zoom)) {
|
||||||
hitElement = elements[i];
|
hitElement = elements[i];
|
||||||
break;
|
break;
|
||||||
@ -42,6 +45,9 @@ export function getElementContainingPosition(
|
|||||||
let hitElement = null;
|
let hitElement = null;
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||||
for (let i = elements.length - 1; i >= 0; --i) {
|
for (let i = elements.length - 1; i >= 0; --i) {
|
||||||
|
if (elements[i].isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[i]);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[i]);
|
||||||
if (x1 < x && x < x2 && y1 < y && y < y2) {
|
if (x1 < x && x < x2 && y1 < y && y < y2) {
|
||||||
hitElement = elements[i];
|
hitElement = elements[i];
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { getElementAbsoluteCoords } from "../element";
|
import { getElementAbsoluteCoords } from "../element";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
|
||||||
export function getElementsWithinSelection(
|
export function getElementsWithinSelection(
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -34,24 +35,16 @@ export function deleteSelectedElements(
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) {
|
) {
|
||||||
const deletedIds: AppState["deletedIds"] = {};
|
|
||||||
return {
|
return {
|
||||||
elements: elements.filter(el => {
|
elements: elements.map(el => {
|
||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
deletedIds[el.id] = {
|
return newElementWith(el, { isDeleted: true });
|
||||||
version: el.version,
|
|
||||||
};
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return el;
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
deletedIds: {
|
|
||||||
...appState.deletedIds,
|
|
||||||
...deletedIds,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,6 @@ export type AppState = {
|
|||||||
openMenu: "canvas" | "shape" | null;
|
openMenu: "canvas" | "shape" | null;
|
||||||
lastPointerDownWith: PointerType;
|
lastPointerDownWith: PointerType;
|
||||||
selectedElementIds: { [id: string]: boolean };
|
selectedElementIds: { [id: string]: boolean };
|
||||||
deletedIds: { [id: string]: { version: ExcalidrawElement["version"] } };
|
|
||||||
collaborators: Map<string, { pointer?: { x: number; y: number } }>;
|
collaborators: Map<string, { pointer?: { x: number; y: number } }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user