WIP sync client

This commit is contained in:
Marcel Mraz 2024-11-26 22:51:19 +01:00
parent 508cfbc843
commit f12ed8e0b2
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
12 changed files with 327 additions and 146 deletions

View file

@ -54,6 +54,7 @@ import Collab, {
collabAPIAtom, collabAPIAtom,
isCollaboratingAtom, isCollaboratingAtom,
isOfflineAtom, isOfflineAtom,
syncAPIAtom,
} from "./collab/Collab"; } from "./collab/Collab";
import { import {
exportToBackend, exportToBackend,
@ -363,11 +364,20 @@ const ExcalidrawWrapper = () => {
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [syncAPI] = useAtom(syncAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href); return isCollaborationLink(window.location.href);
}); });
const collabError = useAtomValue(collabErrorIndicatorAtom); const collabError = useAtomValue(collabErrorIndicatorAtom);
useEffect(() => {
syncAPI?.reconnect();
return () => {
syncAPI?.disconnect();
};
}, [syncAPI]);
useHandleLibrary({ useHandleLibrary({
excalidrawAPI, excalidrawAPI,
adapter: LibraryIndexedDBAdapter, adapter: LibraryIndexedDBAdapter,
@ -671,7 +681,7 @@ const ExcalidrawWrapper = () => {
// some appState like selections should also be transfered (we could even persist it) // some appState like selections should also be transfered (we could even persist it)
if (!elementsChange.isEmpty()) { if (!elementsChange.isEmpty()) {
console.log(elementsChange); syncAPI?.push("durable", [elementsChange]);
} }
}; };

View file

@ -88,7 +88,9 @@ import type {
ReconciledExcalidrawElement, ReconciledExcalidrawElement,
RemoteExcalidrawElement, RemoteExcalidrawElement,
} from "../../packages/excalidraw/data/reconcile"; } from "../../packages/excalidraw/data/reconcile";
import { ExcalidrawSyncClient } from "../../packages/excalidraw/sync/client";
export const syncAPIAtom = atom<ExcalidrawSyncClient | null>(null);
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false); export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false); export const isOfflineAtom = atom(false);
@ -234,6 +236,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
appJotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
appJotaiStore.set(
syncAPIAtom,
new ExcalidrawSyncClient(this.excalidrawAPI),
);
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
window.collab = window.collab || ({} as Window["collab"]); window.collab = window.collab || ({} as Window["collab"]);

View file

@ -1,2 +1,3 @@
node_modules node_modules
types types
.wrangler

View file

@ -32,6 +32,7 @@ import type {
} from "./element/types"; } from "./element/types";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { getNonDeletedGroupIds } from "./groups"; import { getNonDeletedGroupIds } from "./groups";
import { randomId } from "./random";
import { getObservedAppState } from "./store"; import { getObservedAppState } from "./store";
import type { import type {
AppState, AppState,
@ -795,27 +796,33 @@ export class AppStateChange implements Change<AppState> {
} }
} }
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
ElementUpdate<Ordered<T>>, ElementUpdate<Ordered<T>>;
"seed"
>;
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
*/ */
export class ElementsChange implements Change<SceneElementsMap> { export class ElementsChange implements Change<SceneElementsMap> {
public readonly id: string;
private constructor( private constructor(
private readonly added: Record<string, Delta<ElementPartial>>, private readonly added: Record<string, Delta<ElementPartial>>,
private readonly removed: Record<string, Delta<ElementPartial>>, private readonly removed: Record<string, Delta<ElementPartial>>,
private readonly updated: Record<string, Delta<ElementPartial>>, private readonly updated: Record<string, Delta<ElementPartial>>,
) {} options: { changeId: string },
) {
this.id = options.changeId;
}
public static create( public static create(
added: Record<string, Delta<ElementPartial>>, added: Record<string, Delta<ElementPartial>>,
removed: Record<string, Delta<ElementPartial>>, removed: Record<string, Delta<ElementPartial>>,
updated: Record<string, Delta<ElementPartial>>, updated: Record<string, Delta<ElementPartial>>,
options = { shouldRedistribute: false }, options: { changeId: string; shouldRedistribute: boolean } = {
changeId: randomId(),
shouldRedistribute: false,
},
) { ) {
let change: ElementsChange; let change: ElementsChange;
@ -840,9 +847,13 @@ export class ElementsChange implements Change<SceneElementsMap> {
} }
} }
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); change = new ElementsChange(nextAdded, nextRemoved, nextUpdated, {
changeId: options.changeId,
});
} else { } else {
change = new ElementsChange(added, removed, updated); change = new ElementsChange(added, removed, updated, {
changeId: options.changeId,
});
} }
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
@ -985,12 +996,13 @@ export class ElementsChange implements Change<SceneElementsMap> {
return ElementsChange.create({}, {}, {}); return ElementsChange.create({}, {}, {});
} }
public static load(data: { public static load(payload: string) {
added: Record<string, Delta<ElementPartial>>; const { id, added, removed, updated } = JSON.parse(payload);
removed: Record<string, Delta<ElementPartial>>;
updated: Record<string, Delta<ElementPartial>>; return ElementsChange.create(added, removed, updated, {
}) { changeId: id,
return ElementsChange.create(data.added, data.removed, data.updated); shouldRedistribute: false,
});
} }
public inverse(): ElementsChange { public inverse(): ElementsChange {
@ -1077,6 +1089,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
const updated = applyLatestChangesInternal(this.updated); const updated = applyLatestChangesInternal(this.updated);
return ElementsChange.create(added, removed, updated, { return ElementsChange.create(added, removed, updated, {
changeId: this.id,
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
}); });
} }
@ -1101,9 +1114,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
flags, flags,
); );
const addedElements = applyDeltas(this.added); const addedElements = applyDeltas("added", this.added);
const removedElements = applyDeltas(this.removed); const removedElements = applyDeltas("removed", this.removed);
const updatedElements = applyDeltas(this.updated); const updatedElements = applyDeltas("updated", this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements); const affectedElements = this.resolveConflicts(elements, nextElements);
@ -1156,22 +1169,27 @@ export class ElementsChange implements Change<SceneElementsMap> {
} }
} }
private static createApplier = ( private static createApplier =
(
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: Map<string, OrderedExcalidrawElement>,
flags: { flags: {
containsVisibleDifference: boolean; containsVisibleDifference: boolean;
containsZindexDifference: boolean; containsZindexDifference: boolean;
}, },
) =>
(
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => { ) => {
const getElement = ElementsChange.createGetter( const getElement = ElementsChange.createGetter(
type,
nextElements, nextElements,
snapshot, snapshot,
flags, flags,
); );
return (deltas: Record<string, Delta<ElementPartial>>) => return Object.entries(deltas).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) {
@ -1186,6 +1204,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
private static createGetter = private static createGetter =
( (
type: "added" | "removed" | "updated",
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: Map<string, OrderedExcalidrawElement>,
flags: { flags: {
@ -1211,6 +1230,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
) { ) {
flags.containsVisibleDifference = true; flags.containsVisibleDifference = true;
} }
} else if (type === "added") {
// for additions the element does not have to exist (i.e. remote update)
// TODO: the version itself might be different!
element = newElementWith(
{ id, version: 1 } as OrderedExcalidrawElement,
{
...partial,
},
);
} }
} }
@ -1574,8 +1602,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
private static stripIrrelevantProps( private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>, partial: Partial<OrderedExcalidrawElement>,
): ElementPartial { ): ElementPartial {
const { id, updated, version, versionNonce, seed, ...strippedPartial } = const { id, updated, version, versionNonce, ...strippedPartial } = partial;
partial;
return strippedPartial; return strippedPartial;
} }

View file

@ -8,7 +8,7 @@ import type {
export class DurableChangesRepository implements ChangesRepository { export class DurableChangesRepository implements ChangesRepository {
constructor(private storage: DurableObjectStorage) { constructor(private storage: DurableObjectStorage) {
// #region DEV ONLY // #region DEV ONLY
this.storage.sql.exec(`DROP TABLE IF EXISTS changes;`); // this.storage.sql.exec(`DROP TABLE IF EXISTS changes;`);
// #endregion // #endregion
this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS changes( this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS changes(

View file

@ -719,6 +719,7 @@ class App extends React.Component<AppProps, AppState> {
addFiles: this.addFiles, addFiles: this.addFiles,
resetScene: this.resetScene, resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
store: this.store,
history: { history: {
clear: this.resetHistory, clear: this.resetHistory,
}, },

View file

@ -140,8 +140,8 @@
"start": "node ../../scripts/buildExample.mjs && vite", "start": "node ../../scripts/buildExample.mjs && vite",
"build:example": "node ../../scripts/buildExample.mjs", "build:example": "node ../../scripts/buildExample.mjs",
"size": "yarn build:umd && size-limit", "size": "yarn build:umd && size-limit",
"cf:deploy": "wrangler deploy", "sync:deploy": "wrangler deploy",
"cf:dev": "wrangler dev", "sync:dev": "wrangler dev",
"cf:typegen": "wrangler types" "sync:typegen": "wrangler types"
} }
} }

View file

@ -1,44 +1,147 @@
/* eslint-disable no-console */
import { Utils } from "./utils"; import { Utils } from "./utils";
import type { CLIENT_CHANGE, SERVER_CHANGE } from "./protocol"; import { ElementsChange } from "../change";
import type { ExcalidrawImperativeAPI } from "../types";
import type { SceneElementsMap } from "../element/types";
import type { CLIENT_CHANGE, PUSH_PAYLOAD, SERVER_CHANGE } from "./protocol";
import throttle from "lodash.throttle";
class ExcalidrawSyncClient { export class ExcalidrawSyncClient {
// TODO: add prod url // TODO: add prod url
private static readonly HOST_URL = "ws://localhost:8787"; private static readonly HOST_URL = "ws://localhost:8787";
private static readonly RECONNECT_INTERVAL = 10_000;
private roomId: string; private lastAcknowledgedVersion = 0;
private lastAcknowledgedVersion: number;
private readonly api: ExcalidrawImperativeAPI;
private readonly roomId: string;
private readonly queuedChanges: Map<string, CLIENT_CHANGE> = new Map();
private get localChanges() {
return Array.from(this.queuedChanges.values());
}
private server: WebSocket | null = null; private server: WebSocket | null = null;
private get isConnected() {
return this.server?.readyState === WebSocket.OPEN;
}
constructor(roomId: string = "test_room_1") { private isConnecting: { done: (error?: Error) => void } | null = null;
constructor(api: ExcalidrawImperativeAPI, roomId: string = "test_room_1") {
this.api = api;
this.roomId = roomId; this.roomId = roomId;
// TODO: persist in idb // TODO: persist in idb
this.lastAcknowledgedVersion = 0; this.lastAcknowledgedVersion = 0;
} }
public connect() { public reconnect = throttle(
async () => {
try {
if (this.isConnected) {
console.debug("Already connected to the sync server.");
return;
}
if (this.isConnecting !== null) {
console.debug("Already reconnecting to the sync server...");
return;
}
console.trace("Reconnecting to the sync server...");
const isConnecting = {
done: () => {},
};
// ensure there won't be multiple reconnection attempts
this.isConnecting = isConnecting;
return await new Promise<void>((resolve, reject) => {
this.server = new WebSocket( this.server = new WebSocket(
`${ExcalidrawSyncClient.HOST_URL}/connect?roomId=${this.roomId}`, `${ExcalidrawSyncClient.HOST_URL}/connect?roomId=${this.roomId}`,
); );
this.server.addEventListener("open", this.onOpen); // wait for 10 seconds before timing out
const timeoutId = setTimeout(() => {
reject("Connecting the sync server timed out");
}, 10_000);
// resolved when opened, rejected on error
isConnecting.done = (error?: Error) => {
clearTimeout(timeoutId);
if (error) {
reject(error);
} else {
resolve();
}
};
this.server.addEventListener("message", this.onMessage); this.server.addEventListener("message", this.onMessage);
this.server.addEventListener("close", this.onClose); this.server.addEventListener("close", this.onClose);
this.server.addEventListener("error", this.onError); this.server.addEventListener("error", this.onError);
this.server.addEventListener("open", this.onOpen);
});
} catch (e) {
console.error("Failed to connect to sync server:", e);
this.disconnect(e as Error);
}
},
ExcalidrawSyncClient.RECONNECT_INTERVAL,
{ leading: true },
);
public disconnect = throttle(
(error?: Error) => {
try {
this.server?.removeEventListener("message", this.onMessage);
this.server?.removeEventListener("close", this.onClose);
this.server?.removeEventListener("error", this.onError);
this.server?.removeEventListener("open", this.onOpen);
if (error) {
this.isConnecting?.done(error);
}
} finally {
this.isConnecting = null;
this.server = null;
this.reconnect();
}
},
ExcalidrawSyncClient.RECONNECT_INTERVAL,
{ leading: true },
);
private onOpen = async () => {
if (!this.isConnected) {
throw new Error(
"Received open event, but the connection is still not ready.",
);
} }
public disconnect() { if (!this.isConnecting) {
if (this.server) { throw new Error(
this.server.removeEventListener("open", this.onOpen); "Can't resolve connection without `isConnecting` callback.",
this.server.removeEventListener("message", this.onMessage); );
this.server.removeEventListener("close", this.onClose);
this.server.removeEventListener("error", this.onError);
this.server.close();
}
} }
private onOpen = () => this.sync(); // resolve the current connection
this.isConnecting.done();
// initiate pull
this.pull();
};
private onClose = () =>
this.disconnect(
new Error(`Received "closed" event on the sync connection`),
);
private onError = (event: Event) =>
this.disconnect(
new Error(`Received "${event.type}" on the sync connection`),
);
// TODO: could be an array buffer // TODO: could be an array buffer
private onMessage = (event: MessageEvent) => { private onMessage = (event: MessageEvent) => {
@ -62,82 +165,126 @@ class ExcalidrawSyncClient {
} }
}; };
private onClose = () => this.disconnect(); private pull = (): void => {
private onError = (error: Event) => console.error("WebSocket error:", error); this.send({
public sync() {
const remoteChanges = this.send({
type: "pull", type: "pull",
payload: { lastAcknowledgedVersion: this.lastAcknowledgedVersion }, payload: {
lastAcknowledgedVersion: this.lastAcknowledgedVersion,
},
}); });
// TODO: apply remote changes };
// const localChanges: Array<CLIENT_CHANGE> = [];
// // TODO: apply local changes (unacknowledged) public push = (
// this.push(localChanges, 'durable'); type: "durable" | "ephemeral" = "durable",
changes: Array<CLIENT_CHANGE> = [],
): void => {
const payload: PUSH_PAYLOAD = { type, changes: [] };
if (type === "durable") {
// TODO: persist in idb (with insertion order)
for (const change of changes) {
this.queuedChanges.set(change.id, change);
} }
public pull() { // batch all queued changes
return this.send({ payload.changes = this.localChanges;
type: "pull", } else {
payload: { lastAcknowledgedVersion: this.lastAcknowledgedVersion }, payload.changes = changes;
});
} }
public push(changes: Array<CLIENT_CHANGE>, type: "durable" | "ephemeral") { if (payload.changes.length > 0) {
return this.send({ this.send({
type: "push", type: "push",
payload: { type, changes }, payload,
}); });
} }
};
public relay(buffer: ArrayBuffer) { public relay(buffer: ArrayBuffer): void {
return this.send({ this.send({
type: "relay", type: "relay",
payload: { buffer }, payload: { buffer },
}); });
} }
private handleMessage(message: string) { // TODO: refactor by applying all operations to store, not to the elements
const [result, error] = Utils.try(() => JSON.parse(message));
if (error) {
console.error("Failed to parse message:", message);
return;
}
const { type, payload } = result;
switch (type) {
case "relayed":
return this.handleRelayed(payload);
case "acknowledged":
return this.handleAcknowledged(payload);
case "rejected":
return this.handleRejected(payload);
default:
console.error("Unknown message type:", type);
}
}
private handleRelayed(payload: { changes: Array<CLIENT_CHANGE> }) {
console.log("Relayed message received:", payload);
// Process relayed changes
}
private handleAcknowledged(payload: { changes: Array<SERVER_CHANGE> }) { private handleAcknowledged(payload: { changes: Array<SERVER_CHANGE> }) {
console.log("Acknowledged message received:", payload); const { changes: remoteChanges } = payload;
// Handle acknowledged changes
const oldAcknowledgedVersion = this.lastAcknowledgedVersion;
let elements = new Map(
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
) as SceneElementsMap;
console.log("remote changes", remoteChanges);
console.log("local changes", this.localChanges);
try {
// apply remote changes
for (const remoteChange of remoteChanges) {
if (this.queuedChanges.has(remoteChange.id)) {
// local change acknowledge by the server, safe to remove
this.queuedChanges.delete(remoteChange.id);
} else {
[elements] = ElementsChange.load(remoteChange.payload).applyTo(
elements,
this.api.store.snapshot.elements,
);
// TODO: we might not need to be that strict here
if (this.lastAcknowledgedVersion + 1 !== remoteChange.version) {
throw new Error(
`Received out of order change, expected "${
this.lastAcknowledgedVersion + 1
}", but received "${remoteChange.version}"`,
);
}
}
this.lastAcknowledgedVersion = remoteChange.version;
}
// apply local changes
// TODO: only necessary when remote changes modified same element properties!
for (const localChange of this.localChanges) {
[elements] = localChange.applyTo(
elements,
this.api.store.snapshot.elements,
);
}
this.api.updateScene({
elements: Array.from(elements.values()),
storeAction: "update",
});
// push all queued changes
this.push();
} catch (e) {
console.error("Failed to apply acknowledged changes:", e);
// rollback the last acknowledged version
this.lastAcknowledgedVersion = oldAcknowledgedVersion;
// pull again to get the latest changes
this.pull();
}
} }
private handleRejected(payload: { ids: Array<string>; message: string }) { private handleRejected(payload: { ids: Array<string>; message: string }) {
// handle rejected changes
console.error("Rejected message received:", payload); console.error("Rejected message received:", payload);
// Handle rejected changes
} }
private send(message: { type: string; payload: any }) { private handleRelayed(payload: { changes: Array<CLIENT_CHANGE> }) {
if (this.server && this.server.readyState === WebSocket.OPEN) { // apply relayed changes / buffer
this.server.send(JSON.stringify(message)); console.log("Relayed message received:", payload);
} else {
console.error("WebSocket is not open. Unable to send message.");
} }
private send(message: { type: string; payload: any }): void {
if (!this.isConnected) {
console.error("Can't send a message without an active connection!");
return;
}
this.server?.send(JSON.stringify(message));
} }
} }

View file

@ -1,3 +1,5 @@
import type { ElementsChange } from "../change";
export type RELAY_PAYLOAD = { buffer: ArrayBuffer }; export type RELAY_PAYLOAD = { buffer: ArrayBuffer };
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number }; export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
export type PUSH_PAYLOAD = { export type PUSH_PAYLOAD = {
@ -5,11 +7,7 @@ export type PUSH_PAYLOAD = {
changes: Array<CLIENT_CHANGE>; changes: Array<CLIENT_CHANGE>;
}; };
export type CLIENT_CHANGE = { export type CLIENT_CHANGE = ElementsChange;
id: string;
appStateChange: any;
elementsChange: any;
};
export type CLIENT_MESSAGE = export type CLIENT_MESSAGE =
| { type: "relay"; payload: RELAY_PAYLOAD } | { type: "relay"; payload: RELAY_PAYLOAD }
@ -23,7 +21,10 @@ export type SERVER_MESSAGE =
payload: { changes: Array<CLIENT_CHANGE> } | RELAY_PAYLOAD; payload: { changes: Array<CLIENT_CHANGE> } | RELAY_PAYLOAD;
} }
| { type: "acknowledged"; payload: { changes: Array<SERVER_CHANGE> } } | { type: "acknowledged"; payload: { changes: Array<SERVER_CHANGE> } }
| { type: "rejected"; payload: { ids: Array<string>; message: string } }; | {
type: "rejected";
payload: { changes: Array<CLIENT_CHANGE>; message: string };
};
export interface ChangesRepository { export interface ChangesRepository {
saveAll(changes: Array<CLIENT_CHANGE>): Array<SERVER_CHANGE>; saveAll(changes: Array<CLIENT_CHANGE>): Array<SERVER_CHANGE>;

View file

@ -78,6 +78,7 @@ export class ExcalidrawSyncServer {
} }
if (versionΔ > 0) { if (versionΔ > 0) {
// TODO: for versioning we need deletions, but not for the "snapshot" update
const changes = this.changesRepository.getSinceVersion( const changes = this.changesRepository.getSinceVersion(
lastAcknowledgedClientVersion, lastAcknowledgedClientVersion,
); );
@ -106,8 +107,8 @@ export class ExcalidrawSyncServer {
return this.send(client, { return this.send(client, {
type: "rejected", type: "rejected",
payload: { payload: {
ids: changes.map((i) => i.id),
message: error.message, message: error.message,
changes,
}, },
}); });
} }

View file

@ -756,6 +756,7 @@ export interface ExcalidrawImperativeAPI {
history: { history: {
clear: InstanceType<typeof App>["resetHistory"]; clear: InstanceType<typeof App>["resetHistory"];
}; };
store: InstanceType<typeof App>["store"];
getSceneElements: InstanceType<typeof App>["getSceneElements"]; getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"]; getAppState: () => InstanceType<typeof App>["state"];
getFiles: () => InstanceType<typeof App>["files"]; getFiles: () => InstanceType<typeof App>["files"];

View file

@ -10145,27 +10145,13 @@ stringify-object@^3.3.0:
is-obj "^1.0.1" is-obj "^1.0.1"
is-regexp "^1.0.0" is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies: dependencies:
ansi-regex "^5.0.1" ansi-regex "^5.0.1"
strip-ansi@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==
dependencies:
ansi-regex "^2.0.0"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
dependencies:
ansi-regex "^6.0.1"
strip-bom@^3.0.0: strip-bom@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"