feat: add initial undo/redo system
This commit is contained in:
parent
305341fdf0
commit
c4c203968d
@ -20,6 +20,7 @@
|
||||
"@threlte/extras": "^8.7.5",
|
||||
"@threlte/flex": "^1.0.1",
|
||||
"@types/three": "^0.159.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"three": "^0.159.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Node } from "$lib/types";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import NodeHeader from "./NodeHeader.svelte";
|
||||
import NodeParameter from "./NodeParameter.svelte";
|
||||
import { activeNodeId, selectedNodes } from "./graph/stores";
|
||||
@ -7,9 +8,20 @@
|
||||
export let node: Node;
|
||||
export let inView = true;
|
||||
|
||||
const updateNodePosition =
|
||||
getContext<(n: Node) => void>("updateNodePosition");
|
||||
|
||||
const type = node?.tmp?.type;
|
||||
|
||||
const parameters = Object.entries(type?.inputs || {});
|
||||
|
||||
let ref: HTMLDivElement;
|
||||
|
||||
$: if (node && ref) {
|
||||
node.tmp = node.tmp || {};
|
||||
node.tmp.ref = ref;
|
||||
updateNodePosition(node);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@ -18,7 +30,7 @@
|
||||
class:selected={!!$selectedNodes?.has(node.id)}
|
||||
class:in-view={inView}
|
||||
data-node-id={node.id}
|
||||
bind:this={node.tmp.ref}
|
||||
bind:this={ref}
|
||||
>
|
||||
<NodeHeader {node} />
|
||||
|
||||
|
@ -56,10 +56,20 @@
|
||||
function updateNodePosition(node: NodeType) {
|
||||
node.tmp = node.tmp || {};
|
||||
if (node?.tmp?.ref) {
|
||||
node.tmp.ref.style.setProperty("--nx", `${node.position.x * 10}px`);
|
||||
node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`);
|
||||
if (node.tmp["x"] !== undefined && node.tmp["y"] !== undefined) {
|
||||
node.tmp.ref.style.setProperty("--nx", `${node.tmp.x * 10}px`);
|
||||
node.tmp.ref.style.setProperty("--ny", `${node.tmp.y * 10}px`);
|
||||
if (node.tmp.x === node.position.x && node.tmp.y === node.position.y) {
|
||||
delete node.tmp.x;
|
||||
delete node.tmp.y;
|
||||
}
|
||||
} else {
|
||||
node.tmp.ref.style.setProperty("--nx", `${node.position.x * 10}px`);
|
||||
node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
setContext("updateNodePosition", updateNodePosition);
|
||||
|
||||
const nodeHeightCache: Record<string, number> = {};
|
||||
function getNodeHeight(nodeTypeId: string) {
|
||||
@ -100,7 +110,7 @@
|
||||
if (edge[3] === index) {
|
||||
node = edge[0];
|
||||
index = edge[1];
|
||||
position = getSocketPosition({ node, index });
|
||||
position = getSocketPosition(node, index);
|
||||
graph.removeEdge(edge);
|
||||
break;
|
||||
}
|
||||
@ -120,7 +130,7 @@
|
||||
return {
|
||||
node,
|
||||
index,
|
||||
position: getSocketPosition({ node, index }),
|
||||
position: getSocketPosition(node, index),
|
||||
};
|
||||
});
|
||||
$possibleSocketIds = new Set(
|
||||
@ -142,23 +152,23 @@
|
||||
}
|
||||
|
||||
function getSocketPosition(
|
||||
socket: Omit<Socket, "position">,
|
||||
node: NodeType,
|
||||
index: string | number,
|
||||
): [number, number] {
|
||||
if (typeof socket.index === "number") {
|
||||
if (typeof index === "number") {
|
||||
return [
|
||||
socket.node.position.x + 5,
|
||||
socket.node.position.y + 0.625 + 2.5 * socket.index,
|
||||
(node?.tmp?.x ?? node.position.x) + 5,
|
||||
(node?.tmp?.y ?? node.position.y) + 0.625 + 2.5 * index,
|
||||
];
|
||||
} else {
|
||||
const _index = Object.keys(socket.node.tmp?.type?.inputs || {}).indexOf(
|
||||
socket.index,
|
||||
);
|
||||
const _index = Object.keys(node.tmp?.type?.inputs || {}).indexOf(index);
|
||||
return [
|
||||
socket.node.position.x,
|
||||
socket.node.position.y + 2.5 + 2.5 * _index,
|
||||
node?.tmp?.x ?? node.position.x,
|
||||
(node?.tmp?.y ?? node.position.y) + 2.5 + 2.5 * _index,
|
||||
];
|
||||
}
|
||||
}
|
||||
setContext("getSocketPosition", getSocketPosition);
|
||||
|
||||
function setMouseFromEvent(event: MouseEvent) {
|
||||
const x = event.clientX;
|
||||
@ -230,16 +240,15 @@
|
||||
if ($selectedNodes?.size) {
|
||||
for (const nodeId of $selectedNodes) {
|
||||
const n = graph.getNode(nodeId);
|
||||
if (!n) continue;
|
||||
n.position.x = (n?.tmp?.downX || 0) - vecX;
|
||||
n.position.y = (n?.tmp?.downY || 0) - vecY;
|
||||
if (!n?.tmp) continue;
|
||||
n.tmp.x = (n?.tmp?.downX || 0) - vecX;
|
||||
n.tmp.y = (n?.tmp?.downY || 0) - vecY;
|
||||
updateNodePosition(n);
|
||||
}
|
||||
}
|
||||
|
||||
node.position.x = newX;
|
||||
node.position.y = newY;
|
||||
node.position = node.position;
|
||||
node.tmp.x = newX;
|
||||
node.tmp.y = newY;
|
||||
|
||||
updateNodePosition(node);
|
||||
|
||||
@ -306,7 +315,41 @@
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Delete") {
|
||||
if (event.key === "Escape") {
|
||||
$activeNodeId = -1;
|
||||
$selectedNodes?.clear();
|
||||
$selectedNodes = $selectedNodes;
|
||||
}
|
||||
|
||||
if (event.key === "a" && event.ctrlKey) {
|
||||
$selectedNodes = new Set($nodes.keys());
|
||||
}
|
||||
|
||||
if (event.key === "c" && event.ctrlKey) {
|
||||
}
|
||||
|
||||
if (event.key === "v" && event.ctrlKey) {
|
||||
}
|
||||
|
||||
if (event.key === "z" && event.ctrlKey) {
|
||||
graph.history.undo();
|
||||
for (const node of $nodes.values()) {
|
||||
updateNodePosition(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "y" && event.ctrlKey) {
|
||||
graph.history.redo();
|
||||
for (const node of $nodes.values()) {
|
||||
updateNodePosition(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Delete" ||
|
||||
event.key === "Backspace" ||
|
||||
event.key === "x"
|
||||
) {
|
||||
if ($activeNodeId !== -1) {
|
||||
const node = graph.getNode($activeNodeId);
|
||||
if (node) {
|
||||
@ -349,38 +392,52 @@
|
||||
activeNode.tmp = activeNode.tmp || {};
|
||||
activeNode.tmp.isMoving = false;
|
||||
const snapLevel = getSnapLevel();
|
||||
const fx = snapToGrid(activeNode.position.x, 5 / snapLevel);
|
||||
const fy = snapToGrid(activeNode.position.y, 5 / snapLevel);
|
||||
if ($selectedNodes) {
|
||||
for (const nodeId of $selectedNodes) {
|
||||
const node = graph.getNode(nodeId);
|
||||
if (!node) continue;
|
||||
node.tmp = node.tmp || {};
|
||||
node.tmp.snapX = node.position.x - (activeNode.position.x - fx);
|
||||
node.tmp.snapY = node.position.y - (activeNode.position.y - fy);
|
||||
activeNode.position.x = snapToGrid(
|
||||
activeNode?.tmp?.x ?? activeNode.position.x,
|
||||
5 / snapLevel,
|
||||
);
|
||||
activeNode.position.y = snapToGrid(
|
||||
activeNode?.tmp?.y ?? activeNode.position.y,
|
||||
5 / snapLevel,
|
||||
);
|
||||
const nodes = [
|
||||
...[...($selectedNodes?.values() || [])].map((id) => graph.getNode(id)),
|
||||
] as NodeType[];
|
||||
|
||||
const vec = [
|
||||
activeNode.position.x - (activeNode?.tmp.x || 0),
|
||||
activeNode.position.y - (activeNode?.tmp.y || 0),
|
||||
];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node) continue;
|
||||
node.tmp = node.tmp || {};
|
||||
const { x, y } = node.tmp;
|
||||
if (x !== undefined && y !== undefined) {
|
||||
node.position.x = x + vec[0];
|
||||
node.position.y = y + vec[1];
|
||||
}
|
||||
}
|
||||
nodes.push(activeNode);
|
||||
animate(500, (a: number) => {
|
||||
activeNode.position.x = lerp(activeNode.position.x, fx, a);
|
||||
activeNode.position.y = lerp(activeNode.position.y, fy, a);
|
||||
updateNodePosition(activeNode);
|
||||
|
||||
if ($selectedNodes) {
|
||||
for (const nodeId of $selectedNodes) {
|
||||
const node = graph.getNode(nodeId);
|
||||
if (!node) continue;
|
||||
node.position.x = lerp(node.position.x, node?.tmp?.snapX || 0, a);
|
||||
node.position.y = lerp(node.position.y, node?.tmp?.snapY || 0, a);
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node?.tmp &&
|
||||
node.tmp["x"] !== undefined &&
|
||||
node.tmp["y"] !== undefined
|
||||
) {
|
||||
node.tmp.x = lerp(node.tmp.x, node.position.x, a);
|
||||
node.tmp.y = lerp(node.tmp.y, node.position.y, a);
|
||||
updateNodePosition(node);
|
||||
if (node?.tmp?.isMoving) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeNode?.tmp?.isMoving) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$edges = $edges;
|
||||
});
|
||||
graph.history.save();
|
||||
} else if ($hoveredSocket && $activeSocket) {
|
||||
if (
|
||||
typeof $hoveredSocket.index === "number" &&
|
||||
@ -403,6 +460,7 @@
|
||||
$hoveredSocket.index,
|
||||
);
|
||||
}
|
||||
graph.history.save();
|
||||
}
|
||||
|
||||
mouseDown = null;
|
||||
|
@ -14,14 +14,16 @@
|
||||
|
||||
const isNodeInView = getContext<(n: NodeType) => boolean>("isNodeInView");
|
||||
|
||||
const getSocketPosition =
|
||||
getContext<(node: NodeType, index: string | number) => [number, number]>(
|
||||
"getSocketPosition",
|
||||
);
|
||||
|
||||
function getEdgePosition(edge: EdgeType) {
|
||||
const index = Object.keys(edge[2].tmp?.type?.inputs || {}).indexOf(edge[3]);
|
||||
return [
|
||||
edge[0].position.x + 5,
|
||||
edge[0].position.y + 0.625 + edge[1] * 2.5,
|
||||
edge[2].position.x,
|
||||
edge[2].position.y + 2.5 + index * 2.5,
|
||||
];
|
||||
const pos1 = getSocketPosition(edge[0], edge[1]);
|
||||
const pos2 = getSocketPosition(edge[2], edge[3]);
|
||||
|
||||
return [pos1[0], pos1[1], pos2[0], pos2[1]];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import type { Graph, NodeRegistry as INodeRegistry, NodeType, Node, Edge, Socket } from "./types";
|
||||
import { HistoryManager } from "./history-manager";
|
||||
|
||||
const nodeTypes: NodeType[] = [
|
||||
{
|
||||
@ -43,6 +44,8 @@ export class GraphManager {
|
||||
private _edges: Edge[] = [];
|
||||
edges: Writable<Edge[]> = writable([]);
|
||||
|
||||
history: HistoryManager = new HistoryManager(this);
|
||||
|
||||
private constructor(private graph: Graph, private nodeRegistry: NodeRegistry = new NodeRegistry()) {
|
||||
this.nodes.subscribe((nodes) => {
|
||||
this._nodes = nodes;
|
||||
@ -50,38 +53,30 @@ export class GraphManager {
|
||||
this.edges.subscribe((edges) => {
|
||||
this._edges = edges;
|
||||
});
|
||||
|
||||
globalThis["serialize"] = () => this.serialize();
|
||||
}
|
||||
|
||||
serialize() {
|
||||
serialize(): Graph {
|
||||
const nodes = Array.from(this._nodes.values()).map(node => ({
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
type: node.type,
|
||||
props: node.props,
|
||||
}));
|
||||
const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]);
|
||||
const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]) as Graph["edges"];
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
||||
const nodes = new Map(this.graph.nodes.map(node => [node.id, node]));
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
private _init(graph: Graph) {
|
||||
const nodes = new Map(graph.nodes.map(node => {
|
||||
const nodeType = this.nodeRegistry.getNode(node.type);
|
||||
if (!nodeType) {
|
||||
console.error(`Node type not found: ${node.type}`);
|
||||
this.status.set("error");
|
||||
return;
|
||||
if (nodeType) {
|
||||
node.tmp = node.tmp || {};
|
||||
node.tmp.type = nodeType;
|
||||
}
|
||||
node.tmp = node.tmp || {};
|
||||
node.tmp.type = nodeType;
|
||||
}
|
||||
return [node.id, node]
|
||||
}));
|
||||
|
||||
|
||||
this.edges.set(this.graph.edges.map((edge) => {
|
||||
this.edges.set(graph.edges.map((edge) => {
|
||||
const from = nodes.get(edge[0]);
|
||||
const to = nodes.get(edge[2]);
|
||||
if (!from || !to) {
|
||||
@ -101,7 +96,25 @@ export class GraphManager {
|
||||
|
||||
this.nodes.set(nodes);
|
||||
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
||||
for (const node of this.graph.nodes) {
|
||||
const nodeType = this.nodeRegistry.getNode(node.type);
|
||||
if (!nodeType) {
|
||||
console.error(`Node type not found: ${node.type}`);
|
||||
this.status.set("error");
|
||||
return;
|
||||
}
|
||||
node.tmp = node.tmp || {};
|
||||
node.tmp.type = nodeType;
|
||||
}
|
||||
|
||||
this._init(this.graph);
|
||||
|
||||
this.status.set("idle");
|
||||
this.history.save();
|
||||
}
|
||||
|
||||
|
||||
@ -155,6 +168,7 @@ export class GraphManager {
|
||||
nodes.delete(node.id);
|
||||
return nodes;
|
||||
});
|
||||
this.history.save();
|
||||
}
|
||||
|
||||
createEdge(from: Node, fromSocket: number, to: Node, toSocket: string) {
|
||||
@ -181,6 +195,8 @@ export class GraphManager {
|
||||
this.edges.update((edges) => {
|
||||
return [...edges.filter(e => e[2].id !== to.id || e[3] !== toSocket), [from, fromSocket, to, toSocket]];
|
||||
});
|
||||
|
||||
this.history.save();
|
||||
}
|
||||
|
||||
getParentsOfNode(node: Node) {
|
||||
@ -257,6 +273,7 @@ export class GraphManager {
|
||||
this.edges.update((edges) => {
|
||||
return edges.filter((e) => e[0].id !== id0 || e[1] !== sid0 || e[2].id !== id2 || e[3] !== sid2);
|
||||
});
|
||||
this.history.save();
|
||||
}
|
||||
|
||||
getEdgesToNode(node: Node) {
|
||||
@ -328,7 +345,5 @@ export class GraphManager {
|
||||
return new GraphManager(graph);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
95
frontend/src/lib/history-manager.ts
Normal file
95
frontend/src/lib/history-manager.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { GraphManager } from "./graph-manager";
|
||||
import { create, type Delta } from "jsondiffpatch";
|
||||
import type { Graph } from "./types";
|
||||
|
||||
|
||||
const diff = create({
|
||||
objectHash: function (obj, index) {
|
||||
if (obj === null) return obj;
|
||||
if ("id" in obj) return obj.id;
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.join("-")
|
||||
}
|
||||
return obj?.id || obj._id || '$$index:' + index;
|
||||
}
|
||||
})
|
||||
|
||||
export class HistoryManager {
|
||||
|
||||
index: number = -1;
|
||||
history: Delta[] = [];
|
||||
private initialState: Graph | undefined;
|
||||
private prevState: Graph | undefined;
|
||||
private timeout: number | undefined;
|
||||
|
||||
private opts = {
|
||||
debounce: 400,
|
||||
maxHistory: 100,
|
||||
}
|
||||
|
||||
|
||||
constructor(private manager: GraphManager, { maxHistory = 100, debounce = 100 } = {}) {
|
||||
this.history = [];
|
||||
this.index = -1;
|
||||
this.opts.debounce = debounce;
|
||||
this.opts.maxHistory = maxHistory;
|
||||
}
|
||||
|
||||
save() {
|
||||
if (!this.prevState) {
|
||||
this.prevState = this.manager.serialize();
|
||||
this.initialState = globalThis.structuredClone(this.prevState);
|
||||
} else {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
const newState = this.manager.serialize();
|
||||
const delta = diff.diff(this.prevState, newState);
|
||||
if (delta) {
|
||||
// Add the delta to history
|
||||
if (this.index < this.history.length - 1) {
|
||||
// Clear the history after the current index if new changes are made
|
||||
this.history.splice(this.index + 1);
|
||||
}
|
||||
|
||||
this.history.push(delta);
|
||||
this.index++;
|
||||
|
||||
// Limit the size of the history
|
||||
if (this.history.length > this.opts.maxHistory) {
|
||||
this.history.shift();
|
||||
}
|
||||
}
|
||||
this.prevState = newState;
|
||||
}, this.opts.debounce) as unknown as number;
|
||||
}
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.index > 0) {
|
||||
const delta = this.history[this.index];
|
||||
const prevState = diff.unpatch(this.prevState, delta) as Graph;
|
||||
this.manager._init(prevState);
|
||||
this.index--;
|
||||
this.prevState = prevState;
|
||||
} else if (this.index === 0 && this.initialState) {
|
||||
this.manager._init(globalThis.structuredClone(this.initialState));
|
||||
console.log("Reached start", this.index, this.history.length)
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (this.index < this.history.length - 1) {
|
||||
const nextIndex = this.index + 1;
|
||||
const delta = this.history[nextIndex];
|
||||
const nextState = diff.patch(this.prevState, delta) as Graph;
|
||||
this.manager._init(nextState);
|
||||
this.index = nextIndex;
|
||||
this.prevState = nextState;
|
||||
} else {
|
||||
console.log("Reached end")
|
||||
}
|
||||
}
|
||||
}
|
@ -11,8 +11,8 @@ export type Node = {
|
||||
type?: NodeType;
|
||||
downX?: number;
|
||||
downY?: number;
|
||||
snapX?: number;
|
||||
snapY?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
ref?: HTMLElement;
|
||||
visible?: boolean;
|
||||
isMoving?: boolean;
|
||||
|
@ -35,6 +35,9 @@ importers:
|
||||
'@types/three':
|
||||
specifier: ^0.159.0
|
||||
version: 0.159.0
|
||||
jsondiffpatch:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
three:
|
||||
specifier: ^0.159.0
|
||||
version: 0.159.0
|
||||
@ -1146,6 +1149,10 @@ packages:
|
||||
/@types/cookie@0.6.0:
|
||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||
|
||||
/@types/diff-match-patch@1.0.36:
|
||||
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
|
||||
dev: false
|
||||
|
||||
/@types/estree@1.0.5:
|
||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||
|
||||
@ -1803,6 +1810,10 @@ packages:
|
||||
resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==}
|
||||
dev: true
|
||||
|
||||
/diff-match-patch@1.0.5:
|
||||
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
|
||||
dev: false
|
||||
|
||||
/dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -2638,6 +2649,16 @@ packages:
|
||||
/jsonc-parser@3.2.1:
|
||||
resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==}
|
||||
|
||||
/jsondiffpatch@0.6.0:
|
||||
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@types/diff-match-patch': 1.0.36
|
||||
chalk: 5.3.0
|
||||
diff-match-patch: 1.0.5
|
||||
dev: false
|
||||
|
||||
/jsonfile@6.1.0:
|
||||
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
||||
dependencies:
|
||||
|
Loading…
Reference in New Issue
Block a user