feat: support pen erasing (#7496)

This commit is contained in:
David Luzar 2024-01-01 13:27:03 +01:00 committed by GitHub
parent d19b51d4f8
commit e6c3c06c2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 240 additions and 184 deletions

View file

@ -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,
); );

View file

@ -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 = {

View file

@ -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;
} }
} }

View file

@ -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,