Expose store, a bit

mrazator/delta-based-sync
Marcel Mraz 3 months ago
parent 52eaf64591
commit 245d681b7d
No known key found for this signature in database
GPG Key ID: 4EBD6E62DC830CD2

@ -107,6 +107,7 @@ import Trans from "../packages/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile"; import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
import type { StoreIncrementEvent } from "../packages/excalidraw/store";
import { import {
CommandPalette, CommandPalette,
DEFAULT_CATEGORIES, DEFAULT_CATEGORIES,
@ -663,6 +664,17 @@ const ExcalidrawWrapper = () => {
} }
}; };
const onIncrement = (increment: StoreIncrementEvent) => {
// ephemerals are not part of this (which is alright)
// - wysiwyg, dragging elements / points, mouse movements, etc.
const { elementsChange } = increment;
// some appState like selections should also be transfered (we could even persist it)
if (!elementsChange.isEmpty()) {
console.log(elementsChange)
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>( const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null, null,
); );
@ -795,6 +807,7 @@ const ExcalidrawWrapper = () => {
<Excalidraw <Excalidraw
excalidrawAPI={excalidrawRefCallback} excalidrawAPI={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
onIncrement={onIncrement}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate} onPointerUpdate={collabAPI?.onPointerUpdate}

@ -806,33 +806,37 @@ type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
*/ */
export class ElementsChange implements Change<SceneElementsMap> { export class ElementsChange implements Change<SceneElementsMap> {
private constructor( private constructor(
private readonly added: Map<string, Delta<ElementPartial>>, private readonly added: Record<string, Delta<ElementPartial>>,
private readonly removed: Map<string, Delta<ElementPartial>>, private readonly removed: Record<string, Delta<ElementPartial>>,
private readonly updated: Map<string, Delta<ElementPartial>>, private readonly updated: Record<string, Delta<ElementPartial>>,
) {} ) {}
public static create( public static create(
added: Map<string, Delta<ElementPartial>>, added: Record<string, Delta<ElementPartial>>,
removed: Map<string, Delta<ElementPartial>>, removed: Record<string, Delta<ElementPartial>>,
updated: Map<string, Delta<ElementPartial>>, updated: Record<string, Delta<ElementPartial>>,
options = { shouldRedistribute: false }, options = { shouldRedistribute: false },
) { ) {
let change: ElementsChange; let change: ElementsChange;
if (options.shouldRedistribute) { if (options.shouldRedistribute) {
const nextAdded = new Map<string, Delta<ElementPartial>>(); const nextAdded: Record<string, Delta<ElementPartial>> = {};
const nextRemoved = new Map<string, Delta<ElementPartial>>(); const nextRemoved: Record<string, Delta<ElementPartial>> = {};
const nextUpdated = new Map<string, Delta<ElementPartial>>(); const nextUpdated: Record<string, Delta<ElementPartial>> = {};
const deltas = [...added, ...removed, ...updated]; const deltas = [
...Object.entries(added),
...Object.entries(removed),
...Object.entries(updated),
];
for (const [id, delta] of deltas) { for (const [id, delta] of deltas) {
if (this.satisfiesAddition(delta)) { if (this.satisfiesAddition(delta)) {
nextAdded.set(id, delta); nextAdded[id] = delta;
} else if (this.satisfiesRemoval(delta)) { } else if (this.satisfiesRemoval(delta)) {
nextRemoved.set(id, delta); nextRemoved[id] = delta;
} else { } else {
nextUpdated.set(id, delta); nextUpdated[id] = delta;
} }
} }
@ -873,7 +877,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean, satifies: (delta: Delta<ElementPartial>) => boolean,
) { ) {
for (const [id, delta] of change[type].entries()) { for (const [id, delta] of Object.entries(change[type])) {
if (!satifies(delta)) { if (!satifies(delta)) {
console.error( console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`, `Broken invariant for "${type}" delta, element "${id}", delta:`,
@ -900,9 +904,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
return ElementsChange.empty(); return ElementsChange.empty();
} }
const added = new Map<string, Delta<ElementPartial>>(); const added: Record<string, Delta<ElementPartial>> = {};
const removed = new Map<string, Delta<ElementPartial>>(); const removed: Record<string, Delta<ElementPartial>> = {};
const updated = new Map<string, Delta<ElementPartial>>(); const updated: Record<string, Delta<ElementPartial>> = {};
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
for (const prevElement of prevElements.values()) { for (const prevElement of prevElements.values()) {
@ -918,7 +922,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
ElementsChange.stripIrrelevantProps, ElementsChange.stripIrrelevantProps,
); );
removed.set(prevElement.id, delta); removed[prevElement.id] = delta;
} }
} }
@ -938,7 +942,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
ElementsChange.stripIrrelevantProps, ElementsChange.stripIrrelevantProps,
); );
added.set(nextElement.id, delta); added[nextElement.id] = delta;
continue; continue;
} }
@ -959,9 +963,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
) { ) {
// notice that other props could have been updated as well // notice that other props could have been updated as well
if (prevElement.isDeleted && !nextElement.isDeleted) { if (prevElement.isDeleted && !nextElement.isDeleted) {
added.set(nextElement.id, delta); added[nextElement.id] = delta;
} else { } else {
removed.set(nextElement.id, delta); removed[nextElement.id] = delta;
} }
continue; continue;
@ -969,7 +973,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
// making sure there are at least some changes // making sure there are at least some changes
if (!Delta.isEmpty(delta)) { if (!Delta.isEmpty(delta)) {
updated.set(nextElement.id, delta); updated[nextElement.id] = delta;
} }
} }
} }
@ -978,15 +982,23 @@ export class ElementsChange implements Change<SceneElementsMap> {
} }
public static empty() { public static empty() {
return ElementsChange.create(new Map(), new Map(), new Map()); return ElementsChange.create({}, {}, {});
}
public static load(data: {
added: Record<string, Delta<ElementPartial>>;
removed: Record<string, Delta<ElementPartial>>;
updated: Record<string, Delta<ElementPartial>>;
}) {
return ElementsChange.create(data.added, data.removed, data.updated);
} }
public inverse(): ElementsChange { public inverse(): ElementsChange {
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => { const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas = new Map<string, Delta<ElementPartial>>(); const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of deltas.entries()) { for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
} }
return inversedDeltas; return inversedDeltas;
@ -1002,9 +1014,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
public isEmpty(): boolean { public isEmpty(): boolean {
return ( return (
this.added.size === 0 && Object.keys(this.added).length === 0 &&
this.removed.size === 0 && Object.keys(this.removed).length === 0 &&
this.updated.size === 0 Object.keys(this.updated).length === 0
); );
} }
@ -1036,11 +1048,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
}; };
const applyLatestChangesInternal = ( const applyLatestChangesInternal = (
deltas: Map<string, Delta<ElementPartial>>, deltas: Record<string, Delta<ElementPartial>>,
) => { ) => {
const modifiedDeltas = new Map<string, Delta<ElementPartial>>(); const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of deltas.entries()) { for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id); const existingElement = elements.get(id);
if (existingElement) { if (existingElement) {
@ -1051,9 +1063,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
"inserted", "inserted",
); );
modifiedDeltas.set(id, modifiedDelta); modifiedDeltas[id] = modifiedDelta;
} else { } else {
modifiedDeltas.set(id, delta); modifiedDeltas[id] = delta;
} }
} }
@ -1158,8 +1170,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
flags, flags,
); );
return (deltas: Map<string, Delta<ElementPartial>>) => return (deltas: Record<string, Delta<ElementPartial>>) =>
Array.from(deltas.entries()).reduce((acc, [id, delta]) => { Object.entries(deltas).reduce((acc, [id, delta]) => {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
@ -1331,20 +1343,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
}; };
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
for (const [id] of this.removed) { for (const id of Object.keys(this.removed)) {
ElementsChange.unbindAffected(prevElements, nextElements, id, updater); ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
} }
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
for (const [id] of this.added) { for (const id of Object.keys(this.added)) {
ElementsChange.rebindAffected(prevElements, nextElements, id, updater); ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
} }
// updated delta is affecting the binding only in case it contains changed binding or bindable property // updated delta is affecting the binding only in case it contains changed binding or bindable property
for (const [id] of Array.from(this.updated).filter(([_, delta]) => for (const [id] of Array.from(Object.entries(this.updated)).filter(
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => ([_, delta]) =>
bindingProperties.has(prop as BindingProp | BindableProp), Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
), bindingProperties.has(prop as BindingProp | BindableProp),
),
)) { )) {
const updatedElement = nextElements.get(id); const updatedElement = nextElements.get(id);
if (!updatedElement || updatedElement.isDeleted) { if (!updatedElement || updatedElement.isDeleted) {
@ -1367,16 +1380,16 @@ export class ElementsChange implements Change<SceneElementsMap> {
nextAffectedElements, nextAffectedElements,
); );
for (const [id, delta] of added) { for (const [id, delta] of Object.entries(added)) {
this.added.set(id, delta); this.added[id] = delta;
} }
for (const [id, delta] of removed) { for (const [id, delta] of Object.entries(removed)) {
this.removed.set(id, delta); this.removed[id] = delta;
} }
for (const [id, delta] of updated) { for (const [id, delta] of Object.entries(updated)) {
this.updated.set(id, delta); this.updated[id] = delta;
} }
return nextAffectedElements; return nextAffectedElements;

@ -739,6 +739,7 @@ class App extends React.Component<AppProps, AppState> {
updateFrameRendering: this.updateFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb), onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
@ -2462,6 +2463,7 @@ class App extends React.Component<AppProps, AppState> {
this.store.onStoreIncrementEmitter.on((increment) => { this.store.onStoreIncrementEmitter.on((increment) => {
this.history.record(increment.elementsChange, increment.appStateChange); this.history.record(increment.elementsChange, increment.appStateChange);
this.props.onIncrement?.(increment);
}); });
this.scene.onUpdate(this.triggerRender); this.scene.onUpdate(this.triggerRender);

@ -22,6 +22,7 @@ polyfill();
const ExcalidrawBase = (props: ExcalidrawProps) => { const ExcalidrawBase = (props: ExcalidrawProps) => {
const { const {
onChange, onChange,
onIncrement,
initialData, initialData,
excalidrawAPI, excalidrawAPI,
isCollaborating = false, isCollaborating = false,
@ -111,6 +112,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<InitializeApp langCode={langCode} theme={theme}> <InitializeApp langCode={langCode} theme={theme}>
<App <App
onChange={onChange} onChange={onChange}
onIncrement={onIncrement}
initialData={initialData} initialData={initialData}
excalidrawAPI={excalidrawAPI} excalidrawAPI={excalidrawAPI}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}

@ -75,7 +75,7 @@ export type StoreActionType = ValueOf<typeof StoreAction>;
/** /**
* Represent an increment to the Store. * Represent an increment to the Store.
*/ */
class StoreIncrementEvent { export class StoreIncrementEvent {
constructor( constructor(
public readonly elementsChange: ElementsChange, public readonly elementsChange: ElementsChange,
public readonly appStateChange: AppStateChange, public readonly appStateChange: AppStateChange,
@ -395,6 +395,7 @@ export class Snapshot {
!prev || !prev ||
!next || !next ||
prev.id !== next.id || prev.id !== next.id ||
prev.version !== next.version ||
prev.versionNonce !== next.versionNonce prev.versionNonce !== next.versionNonce
) { ) {
return true; return true;

@ -40,7 +40,7 @@ import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import type { ContextMenuItems } from "./components/ContextMenu"; import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping"; import type { SnapLine } from "./snapping";
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { StoreActionType } from "./store"; import type { StoreActionType, StoreIncrementEvent } from "./store";
export type SocketId = string & { _brand: "SocketId" }; export type SocketId = string & { _brand: "SocketId" };
@ -498,6 +498,7 @@ export interface ExcalidrawProps {
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => void; ) => void;
onIncrement?: (event: StoreIncrementEvent) => void;
initialData?: initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>) | (() => MaybePromise<ExcalidrawInitialDataState | null>)
| MaybePromise<ExcalidrawInitialDataState | null>; | MaybePromise<ExcalidrawInitialDataState | null>;
@ -782,6 +783,9 @@ export interface ExcalidrawImperativeAPI {
files: BinaryFiles, files: BinaryFiles,
) => void, ) => void,
) => UnsubscribeCallback; ) => UnsubscribeCallback;
onIncrement: (
callback: (event: StoreIncrementEvent) => void,
) => UnsubscribeCallback;
onPointerDown: ( onPointerDown: (
callback: ( callback: (
activeTool: AppState["activeTool"], activeTool: AppState["activeTool"],

Loading…
Cancel
Save