mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
feat: support pen erasing (#7496)
This commit is contained in:
parent
d19b51d4f8
commit
e6c3c06c2e
5 changed files with 240 additions and 184 deletions
|
@ -245,6 +245,7 @@ import {
|
||||||
CollaboratorPointer,
|
CollaboratorPointer,
|
||||||
ToolType,
|
ToolType,
|
||||||
OnUserFollowedPayload,
|
OnUserFollowedPayload,
|
||||||
|
UnsubscribeCallback,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
|
@ -488,7 +489,7 @@ let IS_PLAIN_PASTE = false;
|
||||||
let IS_PLAIN_PASTE_TIMER = 0;
|
let IS_PLAIN_PASTE_TIMER = 0;
|
||||||
let PLAIN_PASTE_TOAST_SHOWN = false;
|
let PLAIN_PASTE_TOAST_SHOWN = false;
|
||||||
|
|
||||||
let lastPointerUp: ((event: any) => void) | null = null;
|
let lastPointerUp: (() => void) | null = null;
|
||||||
const gesture: Gesture = {
|
const gesture: Gesture = {
|
||||||
pointers: new Map(),
|
pointers: new Map(),
|
||||||
lastCenter: null,
|
lastCenter: null,
|
||||||
|
@ -528,6 +529,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||||
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
||||||
null;
|
null;
|
||||||
|
lastPointerMoveEvent: PointerEvent | null = null;
|
||||||
lastViewportPosition = { x: 0, y: 0 };
|
lastViewportPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
||||||
|
@ -560,6 +562,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
[scrollX: number, scrollY: number, zoom: AppState["zoom"]]
|
[scrollX: number, scrollY: number, zoom: AppState["zoom"]]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
missingPointerEventCleanupEmitter = new Emitter<
|
||||||
|
[event: PointerEvent | null]
|
||||||
|
>();
|
||||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||||
|
|
||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
|
@ -2372,7 +2377,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.scene.destroy();
|
this.scene.destroy();
|
||||||
this.library.destroy();
|
this.library.destroy();
|
||||||
this.laserPathManager.destroy();
|
this.laserPathManager.destroy();
|
||||||
this.onChangeEmitter.destroy();
|
this.onChangeEmitter.clear();
|
||||||
ShapeCache.destroy();
|
ShapeCache.destroy();
|
||||||
SnapCache.destroy();
|
SnapCache.destroy();
|
||||||
clearTimeout(touchTimeout);
|
clearTimeout(touchTimeout);
|
||||||
|
@ -2464,6 +2469,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.onGestureEnd as any,
|
this.onGestureEnd as any,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
addEventListener(window, EVENT.FOCUS, () => {
|
||||||
|
this.maybeCleanupAfterMissingPointerUp(null);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.state.viewModeEnabled) {
|
if (this.state.viewModeEnabled) {
|
||||||
|
@ -4616,6 +4624,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
) => {
|
) => {
|
||||||
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
|
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
|
||||||
|
this.lastPointerMoveEvent = event.nativeEvent;
|
||||||
|
|
||||||
if (gesture.pointers.has(event.pointerId)) {
|
if (gesture.pointers.has(event.pointerId)) {
|
||||||
gesture.pointers.set(event.pointerId, {
|
gesture.pointers.set(event.pointerId, {
|
||||||
|
@ -5203,6 +5212,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
private handleCanvasPointerDown = (
|
private handleCanvasPointerDown = (
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
) => {
|
) => {
|
||||||
|
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
||||||
this.maybeUnfollowRemoteUser();
|
this.maybeUnfollowRemoteUser();
|
||||||
|
|
||||||
// since contextMenu options are potentially evaluated on each render,
|
// since contextMenu options are potentially evaluated on each render,
|
||||||
|
@ -5265,7 +5275,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
}
|
}
|
||||||
this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
|
this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
|
||||||
this.maybeCleanupAfterMissingPointerUp(event);
|
|
||||||
|
|
||||||
//fires only once, if pen is detected, penMode is enabled
|
//fires only once, if pen is detected, penMode is enabled
|
||||||
//the user can disable this by toggling the penMode button
|
//the user can disable this by toggling the penMode button
|
||||||
|
@ -5304,10 +5313,60 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
});
|
});
|
||||||
this.savePointer(event.clientX, event.clientY, "down");
|
this.savePointer(event.clientX, event.clientY, "down");
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.button === POINTER_BUTTON.ERASER &&
|
||||||
|
this.state.activeTool.type !== TOOL_TYPE.eraser
|
||||||
|
) {
|
||||||
|
this.setState(
|
||||||
|
{
|
||||||
|
activeTool: updateActiveTool(this.state, {
|
||||||
|
type: TOOL_TYPE.eraser,
|
||||||
|
lastActiveToolBeforeEraser: this.state.activeTool,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.handleCanvasPointerDown(event);
|
||||||
|
const onPointerUp = () => {
|
||||||
|
unsubPointerUp();
|
||||||
|
unsubCleanup?.();
|
||||||
|
if (isEraserActive(this.state)) {
|
||||||
|
this.setState({
|
||||||
|
activeTool: updateActiveTool(this.state, {
|
||||||
|
...(this.state.activeTool.lastActiveTool || {
|
||||||
|
type: TOOL_TYPE.selection,
|
||||||
|
}),
|
||||||
|
lastActiveToolBeforeEraser: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubPointerUp = addEventListener(
|
||||||
|
window,
|
||||||
|
EVENT.POINTER_UP,
|
||||||
|
onPointerUp,
|
||||||
|
{
|
||||||
|
once: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let unsubCleanup: UnsubscribeCallback | undefined;
|
||||||
|
// subscribe inside rAF lest it'd be triggered on the same pointerdown
|
||||||
|
// if we start erasing while coming from blurred document since
|
||||||
|
// we cleanup pointer events on focus
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
unsubCleanup =
|
||||||
|
this.missingPointerEventCleanupEmitter.once(onPointerUp);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// only handle left mouse button or touch
|
// only handle left mouse button or touch
|
||||||
if (
|
if (
|
||||||
event.button !== POINTER_BUTTON.MAIN &&
|
event.button !== POINTER_BUTTON.MAIN &&
|
||||||
event.button !== POINTER_BUTTON.TOUCH
|
event.button !== POINTER_BUTTON.TOUCH &&
|
||||||
|
event.button !== POINTER_BUTTON.ERASER
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -5435,7 +5494,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
|
const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
|
||||||
const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
|
const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
|
||||||
|
|
||||||
lastPointerUp = onPointerUp;
|
this.missingPointerEventCleanupEmitter.once((_event) =>
|
||||||
|
onPointerUp(_event || event.nativeEvent),
|
||||||
|
);
|
||||||
|
|
||||||
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
|
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
|
||||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||||
|
@ -5546,16 +5607,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
invalidateContextMenu = false;
|
invalidateContextMenu = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeCleanupAfterMissingPointerUp(
|
/**
|
||||||
event: React.PointerEvent<HTMLElement>,
|
* pointerup may not fire in certian cases (user tabs away...), so in order
|
||||||
): void {
|
* to properly cleanup pointerdown state, we need to fire any hanging
|
||||||
if (lastPointerUp !== null) {
|
* pointerup handlers manually
|
||||||
// Unfortunately, sometimes we don't get a pointerup after a pointerdown,
|
*/
|
||||||
// this can happen when a contextual menu or alert is triggered. In order to avoid
|
private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => {
|
||||||
// being in a weird state, we clean up on the next pointerdown
|
lastPointerUp?.();
|
||||||
lastPointerUp(event);
|
this.missingPointerEventCleanupEmitter.trigger(event).clear();
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Returns whether the event is a panning
|
// Returns whether the event is a panning
|
||||||
private handleCanvasPanUsingWheelOrSpaceDrag = (
|
private handleCanvasPanUsingWheelOrSpaceDrag = (
|
||||||
|
@ -5758,11 +5818,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
this.handlePointerMoveOverScrollbars(event, pointerDownState);
|
this.handlePointerMoveOverScrollbars(event, pointerDownState);
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPointerUp = withBatchedUpdates(() => {
|
const onPointerUp = withBatchedUpdates(() => {
|
||||||
|
lastPointerUp = null;
|
||||||
isDraggingScrollBar = false;
|
isDraggingScrollBar = false;
|
||||||
setCursorForShape(this.interactiveCanvas, this.state);
|
setCursorForShape(this.interactiveCanvas, this.state);
|
||||||
lastPointerUp = null;
|
|
||||||
this.setState({
|
this.setState({
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
});
|
});
|
||||||
|
@ -7208,6 +7267,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
): (event: PointerEvent) => void {
|
): (event: PointerEvent) => void {
|
||||||
return withBatchedUpdates((childEvent: PointerEvent) => {
|
return withBatchedUpdates((childEvent: PointerEvent) => {
|
||||||
|
this.removePointer(childEvent);
|
||||||
if (pointerDownState.eventListeners.onMove) {
|
if (pointerDownState.eventListeners.onMove) {
|
||||||
pointerDownState.eventListeners.onMove.flush();
|
pointerDownState.eventListeners.onMove.flush();
|
||||||
}
|
}
|
||||||
|
@ -7310,7 +7370,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPointerUp = null;
|
this.missingPointerEventCleanupEmitter.clear();
|
||||||
|
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
EVENT.POINTER_MOVE,
|
EVENT.POINTER_MOVE,
|
||||||
|
@ -7693,19 +7753,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isEraserActive(this.state)) {
|
|
||||||
|
const pointerStart = this.lastPointerDownEvent;
|
||||||
|
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
|
||||||
|
|
||||||
|
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
|
||||||
const draggedDistance = distance2d(
|
const draggedDistance = distance2d(
|
||||||
this.lastPointerDownEvent!.clientX,
|
pointerStart.clientX,
|
||||||
this.lastPointerDownEvent!.clientY,
|
pointerStart.clientY,
|
||||||
this.lastPointerUpEvent!.clientX,
|
pointerEnd.clientX,
|
||||||
this.lastPointerUpEvent!.clientY,
|
pointerEnd.clientY,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (draggedDistance === 0) {
|
if (draggedDistance === 0) {
|
||||||
const scenePointer = viewportCoordsToSceneCoords(
|
const scenePointer = viewportCoordsToSceneCoords(
|
||||||
{
|
{
|
||||||
clientX: this.lastPointerUpEvent!.clientX,
|
clientX: pointerEnd.clientX,
|
||||||
clientY: this.lastPointerUpEvent!.clientY,
|
clientY: pointerEnd.clientY,
|
||||||
},
|
},
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
|
@ -43,6 +43,7 @@ export const POINTER_BUTTON = {
|
||||||
WHEEL: 1,
|
WHEEL: 1,
|
||||||
SECONDARY: 2,
|
SECONDARY: 2,
|
||||||
TOUCH: -1,
|
TOUCH: -1,
|
||||||
|
ERASER: 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const POINTER_EVENTS = {
|
export const POINTER_EVENTS = {
|
||||||
|
|
|
@ -4,13 +4,6 @@ type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||||
|
|
||||||
export class Emitter<T extends any[] = []> {
|
export class Emitter<T extends any[] = []> {
|
||||||
public subscribers: Subscriber<T>[] = [];
|
public subscribers: Subscriber<T>[] = [];
|
||||||
public value: T | undefined;
|
|
||||||
private updateOnChangeOnly: boolean;
|
|
||||||
|
|
||||||
constructor(opts?: { initialState?: T; updateOnChangeOnly?: boolean }) {
|
|
||||||
this.updateOnChangeOnly = opts?.updateOnChangeOnly ?? false;
|
|
||||||
this.value = opts?.initialState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches subscriber
|
* Attaches subscriber
|
||||||
|
@ -45,16 +38,14 @@ export class Emitter<T extends any[] = []> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
trigger(...payload: T): any[] {
|
trigger(...payload: T) {
|
||||||
if (this.updateOnChangeOnly && this.value === payload) {
|
for (const handler of this.subscribers) {
|
||||||
return [];
|
handler(...payload);
|
||||||
}
|
}
|
||||||
this.value = payload;
|
return this;
|
||||||
return this.subscribers.map((handler) => handler(...payload));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
clear() {
|
||||||
this.subscribers = [];
|
this.subscribers = [];
|
||||||
this.value = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1908,7 +1908,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -1951,7 +1951,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1150084233,
|
"versionNonce": 1116226695,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 25,
|
"x": 25,
|
||||||
"y": 25,
|
"y": 25,
|
||||||
|
@ -16160,7 +16160,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 493213705,
|
"seed": 915032327,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
|
@ -16246,7 +16246,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 493213705,
|
"seed": 915032327,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
|
@ -16330,7 +16330,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -16373,7 +16373,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -16395,14 +16395,14 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 2019559783,
|
"seed": 1150084233,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 1014066025,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 110,
|
"x": 110,
|
||||||
"y": 110,
|
"y": 110,
|
||||||
|
@ -16445,7 +16445,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 401146281,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -16467,14 +16467,14 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 2019559783,
|
"seed": 1150084233,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 1014066025,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 110,
|
"x": 110,
|
||||||
"y": 110,
|
"y": 110,
|
||||||
|
@ -16496,14 +16496,14 @@ exports[`regression tests > switches from group of selected elements to another
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 238820263,
|
"seed": 400692809,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 1505387817,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 310,
|
"x": 310,
|
||||||
"y": 310,
|
"y": 310,
|
||||||
|
|
Loading…
Add table
Reference in a new issue