store.ts 28 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  1. import {
  2. assertNever,
  3. COLOR_PALETTE,
  4. isDevEnv,
  5. isTestEnv,
  6. randomId,
  7. Emitter,
  8. toIterable,
  9. } from "@excalidraw/common";
  10. import type App from "@excalidraw/excalidraw/components/App";
  11. import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
  12. import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types";
  13. import { deepCopyElement } from "./duplicate";
  14. import { newElementWith } from "./mutateElement";
  15. import { ElementsDelta, AppStateDelta, Delta } from "./delta";
  16. import {
  17. syncInvalidIndicesImmutable,
  18. hashElementsVersion,
  19. hashString,
  20. isInitializedImageElement,
  21. isImageElement,
  22. } from "./index";
  23. import type { ApplyToOptions } from "./delta";
  24. import type {
  25. ExcalidrawElement,
  26. OrderedExcalidrawElement,
  27. SceneElementsMap,
  28. } from "./types";
  29. export const CaptureUpdateAction = {
  30. /**
  31. * Immediately undoable.
  32. *
  33. * Use for updates which should be captured.
  34. * Should be used for most of the local updates, except ephemerals such as dragging or resizing.
  35. *
  36. * These updates will _immediately_ make it to the local undo / redo stacks.
  37. */
  38. IMMEDIATELY: "IMMEDIATELY",
  39. /**
  40. * Never undoable.
  41. *
  42. * Use for updates which should never be recorded, such as remote updates
  43. * or scene initialization.
  44. *
  45. * These updates will _never_ make it to the local undo / redo stacks.
  46. */
  47. NEVER: "NEVER",
  48. /**
  49. * Eventually undoable.
  50. *
  51. * Use for updates which should not be captured immediately - likely
  52. * exceptions which are part of some async multi-step process. Otherwise, all
  53. * such updates would end up being captured with the next
  54. * `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
  55. * or internally by the editor.
  56. *
  57. * These updates will _eventually_ make it to the local undo / redo stacks.
  58. */
  59. EVENTUALLY: "EVENTUALLY",
  60. } as const;
  61. export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
  62. type MicroActionsQueue = (() => void)[];
  63. /**
  64. * Store which captures the observed changes and emits them as `StoreIncrement` events.
  65. */
  66. export class Store {
  67. // internally used by history
  68. public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
  69. public readonly onStoreIncrementEmitter = new Emitter<
  70. [DurableIncrement | EphemeralIncrement]
  71. >();
  72. private scheduledMacroActions: Set<CaptureUpdateActionType> = new Set();
  73. private scheduledMicroActions: MicroActionsQueue = [];
  74. private _snapshot = StoreSnapshot.empty();
  75. public get snapshot() {
  76. return this._snapshot;
  77. }
  78. public set snapshot(snapshot: StoreSnapshot) {
  79. this._snapshot = snapshot;
  80. }
  81. constructor(private readonly app: App) {}
  82. public scheduleAction(action: CaptureUpdateActionType) {
  83. this.scheduledMacroActions.add(action);
  84. this.satisfiesScheduledActionsInvariant();
  85. }
  86. /**
  87. * Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack.
  88. */
  89. // TODO: Suspicious that this is called so many places. Seems error-prone.
  90. public scheduleCapture() {
  91. this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
  92. }
  93. /**
  94. * Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
  95. */
  96. public scheduleMicroAction(
  97. params:
  98. | {
  99. action: CaptureUpdateActionType;
  100. elements: readonly ExcalidrawElement[] | undefined;
  101. appState: AppState | ObservedAppState | undefined;
  102. }
  103. | {
  104. action: typeof CaptureUpdateAction.IMMEDIATELY;
  105. change: StoreChange;
  106. delta: StoreDelta;
  107. }
  108. | {
  109. action:
  110. | typeof CaptureUpdateAction.NEVER
  111. | typeof CaptureUpdateAction.EVENTUALLY;
  112. change: StoreChange;
  113. },
  114. ) {
  115. const { action } = params;
  116. let change: StoreChange;
  117. if ("change" in params) {
  118. change = params.change;
  119. } else {
  120. // immediately create an immutable change of the scheduled updates,
  121. // compared to the current state, so that they won't mutate later on during batching
  122. // also, we have to compare against the current state,
  123. // as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
  124. const currentSnapshot = StoreSnapshot.create(
  125. this.app.scene.getElementsMapIncludingDeleted(),
  126. this.app.state,
  127. );
  128. const scheduledSnapshot = currentSnapshot.maybeClone(
  129. action,
  130. // let's sync invalid indices first, so that we could detect this change
  131. // also have the synced elements immutable, so that we don't mutate elements,
  132. // that are already in the scene, otherwise we wouldn't see any change
  133. params.elements
  134. ? syncInvalidIndicesImmutable(params.elements)
  135. : undefined,
  136. params.appState,
  137. );
  138. change = StoreChange.create(currentSnapshot, scheduledSnapshot);
  139. }
  140. const delta = "delta" in params ? params.delta : undefined;
  141. this.scheduledMicroActions.push(() =>
  142. this.processAction({
  143. action,
  144. change,
  145. delta,
  146. }),
  147. );
  148. }
  149. /**
  150. * Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
  151. * Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
  152. *
  153. * @emits StoreIncrement
  154. */
  155. public commit(
  156. elements: SceneElementsMap | undefined,
  157. appState: AppState | ObservedAppState | undefined,
  158. ): void {
  159. // execute all scheduled micro actions first
  160. // similar to microTasks, there can be many
  161. this.flushMicroActions();
  162. try {
  163. // execute a single scheduled "macro" function
  164. // similar to macro tasks, there can be only one within a single commit (loop)
  165. const action = this.getScheduledMacroAction();
  166. this.processAction({ action, elements, appState });
  167. } finally {
  168. this.satisfiesScheduledActionsInvariant();
  169. // defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
  170. this.scheduledMacroActions = new Set();
  171. }
  172. }
  173. /**
  174. * Clears the store instance.
  175. */
  176. public clear(): void {
  177. this.snapshot = StoreSnapshot.empty();
  178. this.scheduledMacroActions = new Set();
  179. }
  180. /**
  181. * Performs delta & change calculation and emits a durable increment.
  182. *
  183. * @emits StoreIncrement.
  184. */
  185. private emitDurableIncrement(
  186. snapshot: StoreSnapshot,
  187. change: StoreChange | undefined = undefined,
  188. delta: StoreDelta | undefined = undefined,
  189. ) {
  190. const prevSnapshot = this.snapshot;
  191. let storeChange: StoreChange;
  192. let storeDelta: StoreDelta;
  193. if (change) {
  194. storeChange = change;
  195. } else {
  196. storeChange = StoreChange.create(prevSnapshot, snapshot);
  197. }
  198. if (delta) {
  199. // we might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again
  200. // using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
  201. storeDelta = delta;
  202. } else {
  203. storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
  204. }
  205. if (!storeDelta.isEmpty()) {
  206. const increment = new DurableIncrement(storeChange, storeDelta);
  207. // Notify listeners with the increment
  208. this.onDurableIncrementEmitter.trigger(increment);
  209. this.onStoreIncrementEmitter.trigger(increment);
  210. }
  211. }
  212. /**
  213. * Performs change calculation and emits an ephemeral increment.
  214. *
  215. * @emits EphemeralStoreIncrement
  216. */
  217. private emitEphemeralIncrement(
  218. snapshot: StoreSnapshot,
  219. change: StoreChange | undefined = undefined,
  220. ) {
  221. let storeChange: StoreChange;
  222. if (change) {
  223. storeChange = change;
  224. } else {
  225. const prevSnapshot = this.snapshot;
  226. storeChange = StoreChange.create(prevSnapshot, snapshot);
  227. }
  228. const increment = new EphemeralIncrement(storeChange);
  229. // Notify listeners with the increment
  230. this.onStoreIncrementEmitter.trigger(increment);
  231. }
  232. private applyChangeToSnapshot(change: StoreChange) {
  233. const prevSnapshot = this.snapshot;
  234. const nextSnapshot = this.snapshot.applyChange(change);
  235. if (prevSnapshot === nextSnapshot) {
  236. return null;
  237. }
  238. return nextSnapshot;
  239. }
  240. /**
  241. * Clones the snapshot if there are changes detected.
  242. */
  243. private maybeCloneSnapshot(
  244. action: CaptureUpdateActionType,
  245. elements: SceneElementsMap | undefined,
  246. appState: AppState | ObservedAppState | undefined,
  247. ) {
  248. if (!elements && !appState) {
  249. return null;
  250. }
  251. const prevSnapshot = this.snapshot;
  252. const nextSnapshot = this.snapshot.maybeClone(action, elements, appState);
  253. if (prevSnapshot === nextSnapshot) {
  254. return null;
  255. }
  256. return nextSnapshot;
  257. }
  258. private flushMicroActions() {
  259. for (const microAction of this.scheduledMicroActions) {
  260. try {
  261. microAction();
  262. } catch (error) {
  263. console.error(`Failed to execute scheduled micro action`, error);
  264. }
  265. }
  266. this.scheduledMicroActions = [];
  267. }
  268. private processAction(
  269. params:
  270. | {
  271. action: CaptureUpdateActionType;
  272. elements: SceneElementsMap | undefined;
  273. appState: AppState | ObservedAppState | undefined;
  274. }
  275. | {
  276. action: CaptureUpdateActionType;
  277. change: StoreChange;
  278. delta: StoreDelta | undefined;
  279. },
  280. ) {
  281. const { action } = params;
  282. // perf. optimisation, since "EVENTUALLY" does not update the snapshot,
  283. // so if nobody is listening for increments, we don't need to even clone the snapshot
  284. // as it's only needed for `StoreChange` computation inside `EphemeralIncrement`
  285. if (
  286. action === CaptureUpdateAction.EVENTUALLY &&
  287. !this.onStoreIncrementEmitter.subscribers.length
  288. ) {
  289. return;
  290. }
  291. let nextSnapshot: StoreSnapshot | null;
  292. if ("change" in params) {
  293. nextSnapshot = this.applyChangeToSnapshot(params.change);
  294. } else {
  295. nextSnapshot = this.maybeCloneSnapshot(
  296. action,
  297. params.elements,
  298. params.appState,
  299. );
  300. }
  301. if (!nextSnapshot) {
  302. // don't continue if there is not change detected
  303. return;
  304. }
  305. const change = "change" in params ? params.change : undefined;
  306. const delta = "delta" in params ? params.delta : undefined;
  307. try {
  308. switch (action) {
  309. // only immediately emits a durable increment
  310. case CaptureUpdateAction.IMMEDIATELY:
  311. this.emitDurableIncrement(nextSnapshot, change, delta);
  312. break;
  313. // both never and eventually emit an ephemeral increment
  314. case CaptureUpdateAction.NEVER:
  315. case CaptureUpdateAction.EVENTUALLY:
  316. this.emitEphemeralIncrement(nextSnapshot, change);
  317. break;
  318. default:
  319. assertNever(action, `Unknown store action`);
  320. }
  321. } finally {
  322. // update the snapshot no-matter what, as it would mess up with the next action
  323. switch (action) {
  324. // both immediately and never update the snapshot, unlike eventually
  325. case CaptureUpdateAction.IMMEDIATELY:
  326. case CaptureUpdateAction.NEVER:
  327. this.snapshot = nextSnapshot;
  328. break;
  329. }
  330. }
  331. }
  332. /**
  333. * Returns the scheduled macro action.
  334. */
  335. private getScheduledMacroAction() {
  336. let scheduledAction: CaptureUpdateActionType;
  337. if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) {
  338. // Capture has a precedence over update, since it also performs snapshot update
  339. scheduledAction = CaptureUpdateAction.IMMEDIATELY;
  340. } else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) {
  341. // Update has a precedence over none, since it also emits an (ephemeral) increment
  342. scheduledAction = CaptureUpdateAction.NEVER;
  343. } else {
  344. // Default is to emit ephemeral increment and don't update the snapshot
  345. scheduledAction = CaptureUpdateAction.EVENTUALLY;
  346. }
  347. return scheduledAction;
  348. }
  349. /**
  350. * Ensures that the scheduled actions invariant is satisfied.
  351. */
  352. private satisfiesScheduledActionsInvariant() {
  353. if (
  354. !(
  355. this.scheduledMacroActions.size >= 0 &&
  356. this.scheduledMacroActions.size <=
  357. Object.keys(CaptureUpdateAction).length
  358. )
  359. ) {
  360. const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`;
  361. console.error(message, this.scheduledMacroActions.values());
  362. if (isTestEnv() || isDevEnv()) {
  363. throw new Error(message);
  364. }
  365. }
  366. }
  367. }
  368. /**
  369. * Repsents a change to the store containing changed elements and appState.
  370. */
  371. export class StoreChange {
  372. // so figuring out what has changed should ideally be just quick reference checks
  373. // TODO: we might need to have binary files here as well, in order to be drop-in replacement for `onChange`
  374. private constructor(
  375. public readonly elements: Record<string, OrderedExcalidrawElement>,
  376. public readonly appState: Partial<ObservedAppState>,
  377. ) {}
  378. public static create(
  379. prevSnapshot: StoreSnapshot,
  380. nextSnapshot: StoreSnapshot,
  381. ) {
  382. const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
  383. const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot);
  384. return new StoreChange(changedElements, changedAppState);
  385. }
  386. }
  387. /**
  388. * Encpasulates any change to the store (durable or ephemeral).
  389. */
  390. export abstract class StoreIncrement {
  391. protected constructor(
  392. public readonly type: "durable" | "ephemeral",
  393. public readonly change: StoreChange,
  394. ) {}
  395. public static isDurable(
  396. increment: StoreIncrement,
  397. ): increment is DurableIncrement {
  398. return increment.type === "durable";
  399. }
  400. public static isEphemeral(
  401. increment: StoreIncrement,
  402. ): increment is EphemeralIncrement {
  403. return increment.type === "ephemeral";
  404. }
  405. }
  406. /**
  407. * Represents a durable change to the store.
  408. */
  409. export class DurableIncrement extends StoreIncrement {
  410. constructor(
  411. public readonly change: StoreChange,
  412. public readonly delta: StoreDelta,
  413. ) {
  414. super("durable", change);
  415. }
  416. }
  417. /**
  418. * Represents an ephemeral change to the store.
  419. */
  420. export class EphemeralIncrement extends StoreIncrement {
  421. constructor(public readonly change: StoreChange) {
  422. super("ephemeral", change);
  423. }
  424. }
  425. /**
  426. * Represents a captured delta by the Store.
  427. */
  428. export class StoreDelta {
  429. protected constructor(
  430. public readonly id: string,
  431. public readonly elements: ElementsDelta,
  432. public readonly appState: AppStateDelta,
  433. ) {}
  434. /**
  435. * Create a new instance of `StoreDelta`.
  436. */
  437. public static create(
  438. elements: ElementsDelta,
  439. appState: AppStateDelta,
  440. opts: {
  441. id: string;
  442. } = {
  443. id: randomId(),
  444. },
  445. ) {
  446. return new this(opts.id, elements, appState);
  447. }
  448. /**
  449. * Calculate the delta between the previous and next snapshot.
  450. */
  451. public static calculate(
  452. prevSnapshot: StoreSnapshot,
  453. nextSnapshot: StoreSnapshot,
  454. ) {
  455. const elementsDelta = nextSnapshot.metadata.didElementsChange
  456. ? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
  457. : ElementsDelta.empty();
  458. const appStateDelta = nextSnapshot.metadata.didAppStateChange
  459. ? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
  460. : AppStateDelta.empty();
  461. return this.create(elementsDelta, appStateDelta);
  462. }
  463. /**
  464. * Restore a store delta instance from a DTO.
  465. */
  466. public static restore(storeDeltaDTO: DTO<StoreDelta>) {
  467. const { id, elements, appState } = storeDeltaDTO;
  468. return new this(
  469. id,
  470. ElementsDelta.restore(elements),
  471. AppStateDelta.restore(appState),
  472. );
  473. }
  474. /**
  475. * Parse and load the delta from the remote payload.
  476. */
  477. public static load({
  478. id,
  479. elements: { added, removed, updated },
  480. }: DTO<StoreDelta>) {
  481. const elements = ElementsDelta.create(added, removed, updated);
  482. return new this(id, elements, AppStateDelta.empty());
  483. }
  484. /**
  485. * Inverse store delta, creates new instance of `StoreDelta`.
  486. */
  487. public static inverse(delta: StoreDelta) {
  488. return this.create(delta.elements.inverse(), delta.appState.inverse());
  489. }
  490. /**
  491. * Apply the delta to the passed elements and appState, does not modify the snapshot.
  492. */
  493. public static applyTo(
  494. delta: StoreDelta,
  495. elements: SceneElementsMap,
  496. appState: AppState,
  497. options: ApplyToOptions = {
  498. excludedProperties: new Set(),
  499. },
  500. ): [SceneElementsMap, AppState, boolean] {
  501. const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
  502. elements,
  503. StoreSnapshot.empty().elements,
  504. options,
  505. );
  506. const [nextAppState, appStateContainsVisibleChange] =
  507. delta.appState.applyTo(appState, nextElements);
  508. const appliedVisibleChanges =
  509. elementsContainVisibleChange || appStateContainsVisibleChange;
  510. return [nextElements, nextAppState, appliedVisibleChanges];
  511. }
  512. /**
  513. * Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
  514. */
  515. public static applyLatestChanges(
  516. delta: StoreDelta,
  517. prevElements: SceneElementsMap,
  518. nextElements: SceneElementsMap,
  519. modifierOptions?: "deleted" | "inserted",
  520. ): StoreDelta {
  521. return this.create(
  522. delta.elements.applyLatestChanges(
  523. prevElements,
  524. nextElements,
  525. modifierOptions,
  526. ),
  527. delta.appState,
  528. {
  529. id: delta.id,
  530. },
  531. );
  532. }
  533. public isEmpty() {
  534. return this.elements.isEmpty() && this.appState.isEmpty();
  535. }
  536. }
  537. /**
  538. * Represents a snapshot of the captured or updated changes in the store,
  539. * used for producing deltas and emitting `DurableStoreIncrement`s.
  540. */
  541. export class StoreSnapshot {
  542. private _lastChangedElementsHash: number = 0;
  543. private _lastChangedAppStateHash: number = 0;
  544. private constructor(
  545. public readonly elements: SceneElementsMap,
  546. public readonly appState: ObservedAppState,
  547. public readonly metadata: {
  548. didElementsChange: boolean;
  549. didAppStateChange: boolean;
  550. isEmpty?: boolean;
  551. } = {
  552. didElementsChange: false,
  553. didAppStateChange: false,
  554. isEmpty: false,
  555. },
  556. ) {}
  557. public static create(
  558. elements: SceneElementsMap,
  559. appState: AppState | ObservedAppState,
  560. metadata: {
  561. didElementsChange: boolean;
  562. didAppStateChange: boolean;
  563. } = {
  564. didElementsChange: false,
  565. didAppStateChange: false,
  566. },
  567. ) {
  568. return new StoreSnapshot(
  569. elements,
  570. isObservedAppState(appState) ? appState : getObservedAppState(appState),
  571. metadata,
  572. );
  573. }
  574. public static empty() {
  575. return new StoreSnapshot(
  576. new Map() as SceneElementsMap,
  577. getDefaultObservedAppState(),
  578. {
  579. didElementsChange: false,
  580. didAppStateChange: false,
  581. isEmpty: true,
  582. },
  583. );
  584. }
  585. public getChangedElements(prevSnapshot: StoreSnapshot) {
  586. const changedElements: Record<string, OrderedExcalidrawElement> = {};
  587. for (const prevElement of toIterable(prevSnapshot.elements)) {
  588. const nextElement = this.elements.get(prevElement.id);
  589. if (!nextElement) {
  590. changedElements[prevElement.id] = newElementWith(prevElement, {
  591. isDeleted: true,
  592. });
  593. }
  594. }
  595. for (const nextElement of toIterable(this.elements)) {
  596. // Due to the structural clone inside `maybeClone`, we can perform just these reference checks
  597. if (prevSnapshot.elements.get(nextElement.id) !== nextElement) {
  598. changedElements[nextElement.id] = nextElement;
  599. }
  600. }
  601. return changedElements;
  602. }
  603. public getChangedAppState(
  604. prevSnapshot: StoreSnapshot,
  605. ): Partial<ObservedAppState> {
  606. return Delta.getRightDifferences(
  607. prevSnapshot.appState,
  608. this.appState,
  609. ).reduce(
  610. (acc, key) =>
  611. Object.assign(acc, {
  612. [key]: this.appState[key as keyof ObservedAppState],
  613. }),
  614. {} as Partial<ObservedAppState>,
  615. );
  616. }
  617. public isEmpty() {
  618. return this.metadata.isEmpty;
  619. }
  620. /**
  621. * Apply the change and return a new snapshot instance.
  622. */
  623. public applyChange(change: StoreChange): StoreSnapshot {
  624. const nextElements = new Map(this.elements) as SceneElementsMap;
  625. for (const [id, changedElement] of Object.entries(change.elements)) {
  626. nextElements.set(id, changedElement);
  627. }
  628. const nextAppState = getObservedAppState({
  629. ...this.appState,
  630. ...change.appState,
  631. });
  632. return StoreSnapshot.create(nextElements, nextAppState, {
  633. // by default we assume that change is different from what we have in the snapshot
  634. // so that we trigger the delta calculation and if it isn't different, delta will be empty
  635. didElementsChange: Object.keys(change.elements).length > 0,
  636. didAppStateChange: Object.keys(change.appState).length > 0,
  637. });
  638. }
  639. /**
  640. * Efficiently clone the existing snapshot, only if we detected changes.
  641. *
  642. * @returns same instance if there are no changes detected, new instance otherwise.
  643. */
  644. public maybeClone(
  645. action: CaptureUpdateActionType,
  646. elements: SceneElementsMap | undefined,
  647. appState: AppState | ObservedAppState | undefined,
  648. ) {
  649. const options = {
  650. shouldCompareHashes: false,
  651. };
  652. if (action === CaptureUpdateAction.EVENTUALLY) {
  653. // actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash
  654. // as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update
  655. // instead of just the first time the elements or appState actually changed
  656. options.shouldCompareHashes = true;
  657. }
  658. const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
  659. elements,
  660. options,
  661. );
  662. const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(
  663. appState,
  664. options,
  665. );
  666. let didElementsChange = false;
  667. let didAppStateChange = false;
  668. if (this.elements !== nextElementsSnapshot) {
  669. didElementsChange = true;
  670. }
  671. if (this.appState !== nextAppStateSnapshot) {
  672. didAppStateChange = true;
  673. }
  674. if (!didElementsChange && !didAppStateChange) {
  675. return this;
  676. }
  677. const snapshot = new StoreSnapshot(
  678. nextElementsSnapshot,
  679. nextAppStateSnapshot,
  680. {
  681. didElementsChange,
  682. didAppStateChange,
  683. },
  684. );
  685. return snapshot;
  686. }
  687. private maybeCreateAppStateSnapshot(
  688. appState: AppState | ObservedAppState | undefined,
  689. options: {
  690. shouldCompareHashes: boolean;
  691. } = {
  692. shouldCompareHashes: false,
  693. },
  694. ): ObservedAppState {
  695. if (!appState) {
  696. return this.appState;
  697. }
  698. // Not watching over everything from the app state, just the relevant props
  699. const nextAppStateSnapshot = !isObservedAppState(appState)
  700. ? getObservedAppState(appState)
  701. : appState;
  702. const didAppStateChange = this.detectChangedAppState(
  703. nextAppStateSnapshot,
  704. options,
  705. );
  706. if (!didAppStateChange) {
  707. return this.appState;
  708. }
  709. return nextAppStateSnapshot;
  710. }
  711. private maybeCreateElementsSnapshot(
  712. elements: SceneElementsMap | undefined,
  713. options: {
  714. shouldCompareHashes: boolean;
  715. } = {
  716. shouldCompareHashes: false,
  717. },
  718. ): SceneElementsMap {
  719. if (!elements) {
  720. return this.elements;
  721. }
  722. const changedElements = this.detectChangedElements(elements, options);
  723. if (!changedElements?.size) {
  724. return this.elements;
  725. }
  726. const elementsSnapshot = this.createElementsSnapshot(changedElements);
  727. return elementsSnapshot;
  728. }
  729. private detectChangedAppState(
  730. nextObservedAppState: ObservedAppState,
  731. options: {
  732. shouldCompareHashes: boolean;
  733. } = {
  734. shouldCompareHashes: false,
  735. },
  736. ): boolean | undefined {
  737. if (this.appState === nextObservedAppState) {
  738. return;
  739. }
  740. const didAppStateChange = Delta.isRightDifferent(
  741. this.appState,
  742. nextObservedAppState,
  743. );
  744. if (!didAppStateChange) {
  745. return;
  746. }
  747. const changedAppStateHash = hashString(
  748. JSON.stringify(nextObservedAppState),
  749. );
  750. if (
  751. options.shouldCompareHashes &&
  752. this._lastChangedAppStateHash === changedAppStateHash
  753. ) {
  754. return;
  755. }
  756. this._lastChangedAppStateHash = changedAppStateHash;
  757. return didAppStateChange;
  758. }
  759. /**
  760. * Detect if there are any changed elements.
  761. */
  762. private detectChangedElements(
  763. nextElements: SceneElementsMap,
  764. options: {
  765. shouldCompareHashes: boolean;
  766. } = {
  767. shouldCompareHashes: false,
  768. },
  769. ): SceneElementsMap | undefined {
  770. if (this.elements === nextElements) {
  771. return;
  772. }
  773. const changedElements: SceneElementsMap = new Map() as SceneElementsMap;
  774. for (const prevElement of toIterable(this.elements)) {
  775. const nextElement = nextElements.get(prevElement.id);
  776. if (!nextElement) {
  777. // element was deleted
  778. changedElements.set(
  779. prevElement.id,
  780. newElementWith(prevElement, { isDeleted: true }),
  781. );
  782. }
  783. }
  784. for (const nextElement of toIterable(nextElements)) {
  785. const prevElement = this.elements.get(nextElement.id);
  786. if (
  787. !prevElement || // element was added
  788. prevElement.version < nextElement.version // element was updated
  789. ) {
  790. if (
  791. isImageElement(nextElement) &&
  792. !isInitializedImageElement(nextElement)
  793. ) {
  794. // ignore any updates on uninitialized image elements
  795. continue;
  796. }
  797. changedElements.set(nextElement.id, nextElement);
  798. }
  799. }
  800. if (!changedElements.size) {
  801. return;
  802. }
  803. const changedElementsHash = hashElementsVersion(changedElements);
  804. if (
  805. options.shouldCompareHashes &&
  806. this._lastChangedElementsHash === changedElementsHash
  807. ) {
  808. return;
  809. }
  810. this._lastChangedElementsHash = changedElementsHash;
  811. return changedElements;
  812. }
  813. /**
  814. * Perform structural clone, deep cloning only elements that changed.
  815. */
  816. private createElementsSnapshot(changedElements: SceneElementsMap) {
  817. const clonedElements = new Map() as SceneElementsMap;
  818. for (const prevElement of toIterable(this.elements)) {
  819. // Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
  820. // i.e. during collab, persist or whenenever isDeleted elements get cleared
  821. clonedElements.set(prevElement.id, prevElement);
  822. }
  823. for (const changedElement of toIterable(changedElements)) {
  824. // TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable
  825. // TODO: consider creating a lazy deep clone, having a one-time-usage proxy over the snapshotted element and deep cloning only if it gets mutated
  826. clonedElements.set(changedElement.id, deepCopyElement(changedElement));
  827. }
  828. return clonedElements;
  829. }
  830. }
  831. // hidden non-enumerable property for runtime checks
  832. const hiddenObservedAppStateProp = "__observedAppState";
  833. const getDefaultObservedAppState = (): ObservedAppState => {
  834. return {
  835. name: null,
  836. editingGroupId: null,
  837. viewBackgroundColor: COLOR_PALETTE.white,
  838. selectedElementIds: {},
  839. selectedGroupIds: {},
  840. selectedLinearElementId: null,
  841. selectedLinearElementIsEditing: null,
  842. croppingElementId: null,
  843. activeLockedId: null,
  844. lockedMultiSelections: {},
  845. };
  846. };
  847. export const getObservedAppState = (
  848. appState: AppState | ObservedAppState,
  849. ): ObservedAppState => {
  850. const observedAppState = {
  851. name: appState.name,
  852. editingGroupId: appState.editingGroupId,
  853. viewBackgroundColor: appState.viewBackgroundColor,
  854. selectedElementIds: appState.selectedElementIds,
  855. selectedGroupIds: appState.selectedGroupIds,
  856. croppingElementId: appState.croppingElementId,
  857. activeLockedId: appState.activeLockedId,
  858. lockedMultiSelections: appState.lockedMultiSelections,
  859. selectedLinearElementId:
  860. (appState as AppState).selectedLinearElement?.elementId ??
  861. (appState as ObservedAppState).selectedLinearElementId ??
  862. null,
  863. selectedLinearElementIsEditing:
  864. (appState as AppState).selectedLinearElement?.isEditing ??
  865. (appState as ObservedAppState).selectedLinearElementIsEditing ??
  866. null,
  867. };
  868. Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
  869. value: true,
  870. enumerable: false,
  871. });
  872. return observedAppState;
  873. };
  874. const isObservedAppState = (
  875. appState: AppState | ObservedAppState,
  876. ): appState is ObservedAppState =>
  877. !!Reflect.get(appState, hiddenObservedAppStateProp);