From c0c69b97ec69ecc18953ac1e2448d70dd1d78481 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 15:12:37 -0300 Subject: [PATCH 01/10] make Renderer.pick() generic --- src/Renderer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Renderer.ts b/src/Renderer.ts index 076e11b..e73e895 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -740,10 +740,10 @@ export default class Renderer { } // Pick the topmost sprite at the given point (if one exists). - public pick( - sprites: (Sprite | Stage)[], + public pick( + sprites: T[], point: { x: number; y: number } - ): Sprite | Stage | null { + ): T | null { this._setFramebuffer(this._collisionBuffer); const gl = this.gl; gl.clearColor(0, 0, 0, 0); From ae7de3498e975ff8f958b90dc16f5bb32f40e060 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 15:13:18 -0300 Subject: [PATCH 02/10] track where mouse was pressed down --- src/Input.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Input.ts b/src/Input.ts index 12ac997..3d52bdc 100644 --- a/src/Input.ts +++ b/src/Input.ts @@ -1,6 +1,12 @@ import type { Stage } from "./Sprite"; -type Mouse = { x: number; y: number; down: boolean }; +type Mouse = { + x: number; + y: number; + + down: boolean; + downAt: { x: number; y: number } | null; +}; export default class Input { private _stage; @@ -23,7 +29,7 @@ export default class Input { this._canvas.tabIndex = 0; } - this.mouse = { x: 0, y: 0, down: false }; + this.mouse = { x: 0, y: 0, down: false, downAt: null }; this._canvas.addEventListener("mousemove", this._mouseMove.bind(this)); this._canvas.addEventListener("mousedown", this._mouseDown.bind(this)); this._canvas.addEventListener("mouseup", this._mouseUp.bind(this)); @@ -55,6 +61,10 @@ export default class Input { this.mouse = { ...this.mouse, down: true, + downAt: { + x: this.mouse.x, + y: this.mouse.y, + }, }; } @@ -62,6 +72,7 @@ export default class Input { this.mouse = { ...this.mouse, down: false, + downAt: null, }; } From 3698a8d03bd46a3e3953f2b3b9b839045aabb316 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 15:13:57 -0300 Subject: [PATCH 03/10] add Sprite.draggable flag --- src/Sprite.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Sprite.ts b/src/Sprite.ts index 9ae6392..09efff7 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -508,6 +508,7 @@ type SpriteInitialConditions = { costumeNumber: number; size: number; visible: boolean; + draggable?: boolean; penDown?: boolean; penSize?: number; penColor?: Color; @@ -520,6 +521,7 @@ export class Sprite extends SpriteBase { public rotationStyle: RotationStyle; public size: number; public visible: boolean; + public draggable: boolean; private parent: this | null; public clones: this[]; @@ -540,6 +542,7 @@ export class Sprite extends SpriteBase { costumeNumber, size, visible, + draggable, penDown, penSize, penColor, @@ -552,6 +555,7 @@ export class Sprite extends SpriteBase { this._costumeNumber = costumeNumber; this.size = size; this.visible = visible; + this.draggable = draggable || false; this.parent = null; this.clones = []; From a6aca93d39cfd33a29df033bfa9788b0804651e6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 15:16:42 -0300 Subject: [PATCH 04/10] implement dragging logic --- src/Project.ts | 129 +++++++++++++++++++++++++++++++++++++++++++----- src/Renderer.ts | 6 +++ src/Sprite.ts | 7 ++- 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 0d1e3e7..03d9c76 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -24,6 +24,13 @@ export default class Project { public answer: string | null; private timerStart!: Date; + public draggingSprite: Sprite | null; + public dragThreshold: number; + private _consideringDraggingSprite: Sprite | null; + private _dragOffsetX: number; + private _dragOffsetY: number; + private _idleDragTimeout: number | null; + /** * Used to keep track of what edge-activated trigger predicates evaluted to * on the previous step. @@ -56,6 +63,14 @@ export default class Project { this.restartTimer(); this.answer = null; + this.draggingSprite = null; + this._consideringDraggingSprite = null; + this._dragOffsetX = 0; + this._dragOffsetY = 0; + this._idleDragTimeout = null; + + // TODO: Enable customizing, like frameRate + this.dragThreshold = 3; // Run project code at specified framerate setInterval(() => { @@ -68,6 +83,7 @@ export default class Project { public attach(renderTarget: string | HTMLElement): void { this.renderer.setRenderTarget(renderTarget); + this.renderer.stage.addEventListener("click", () => { // Chrome requires a user gesture on the page before we can start the // audio context. @@ -76,24 +92,56 @@ export default class Project { if (Sound.audioContext.state === "suspended") { void Sound.audioContext.resume(); } + }); - let clickedSprite = this.renderer.pick(this.spritesAndClones, { - x: this.input.mouse.x, - y: this.input.mouse.y, - }); - if (!clickedSprite) { - clickedSprite = this.stage; + this.renderer.stage.addEventListener("mousedown", () => { + const spriteUnderMouse = this._spriteUnderMouse; + const targetUnderMouse = this._targetUnderMouse; + if (spriteUnderMouse && spriteUnderMouse.draggable) { + this._consideringDraggingSprite = spriteUnderMouse; + this._startIdleDragTimeout(); + } else { + this._startClickTriggersFor(targetUnderMouse); } + }); + + this.renderer.stage.addEventListener("mousemove", () => { + // TODO: Effects - goto() and moveAhead() - are applied immediately. + // Do we want to buffer them to apply at the start of the next tick? + if (this.input.mouse.down) { + if (this._consideringDraggingSprite && this.input.mouse.downAt) { + const distanceX = this.input.mouse.x - this.input.mouse.downAt.x; + const distanceY = this.input.mouse.y - this.input.mouse.downAt.y; + const distanceFromMouseDown = Math.sqrt( + distanceX ** 2 + distanceY ** 2 + ); + if (distanceFromMouseDown > this.dragThreshold) { + this._startDragging(); + } + } - const matchingTriggers: TriggerWithTarget[] = []; - for (const trigger of clickedSprite.triggers) { - if (trigger.matches(Trigger.CLICKED, {}, clickedSprite)) { - matchingTriggers.push({ trigger, target: clickedSprite }); + if (this.draggingSprite) { + const gotoX = this.input.mouse.x + this._dragOffsetX; + const gotoY = this.input.mouse.y + this._dragOffsetY; + this.draggingSprite.goto(gotoX, gotoY, true); } } + }); - void this._startTriggers(matchingTriggers); + this.renderer.stage.addEventListener("mouseup", () => { + if (!this._clearDragging()) { + const spriteUnderMouse = this._spriteUnderMouse; + if (spriteUnderMouse && spriteUnderMouse.draggable) { + this._startClickTriggersFor(spriteUnderMouse); + } + } }); + + if (this.renderer.stage.ownerDocument) { + this.renderer.stage.ownerDocument.addEventListener("mouseup", () => { + void this._clearDragging(); + }); + } } public greenFlag(): void { @@ -155,6 +203,65 @@ export default class Project { void this._startTriggers(triggersToStart); } + private _startClickTriggersFor(target: Sprite | Stage): void { + const matchingTriggers: TriggerWithTarget[] = []; + for (const trigger of target.triggers) { + if (trigger.matches(Trigger.CLICKED, {}, target)) { + matchingTriggers.push({ trigger, target }); + } + } + + void this._startTriggers(matchingTriggers); + } + + private get _spriteUnderMouse(): Sprite | null { + return this.renderer.pick(this.spritesAndClones, { + x: this.input.mouse.x, + y: this.input.mouse.y, + }); + } + + private get _targetUnderMouse(): Sprite | Stage { + return this._spriteUnderMouse || this.stage; + } + + private _startDragging(): void { + if (this._consideringDraggingSprite) { + this.draggingSprite = this._consideringDraggingSprite; + this._consideringDraggingSprite = null; + this._clearIdleDragTimeout(); + + this._dragOffsetX = this.draggingSprite.x - this.input.mouse.x; + this._dragOffsetY = this.draggingSprite.y - this.input.mouse.y; + + this.draggingSprite.moveAhead(); + } + } + + private _clearDragging(): boolean { + const wasDragging = !!this.draggingSprite; + this.draggingSprite = null; + this._consideringDraggingSprite = null; + this._dragOffsetX = 0; + this._dragOffsetY = 0; + this._clearIdleDragTimeout(); + return wasDragging; + } + + private _startIdleDragTimeout(): void { + this._idleDragTimeout = window.setTimeout( + this._startDragging.bind(this), + 400 + ); + } + + private _clearIdleDragTimeout(): void { + if (typeof this._idleDragTimeout === "number") { + clearTimeout(this._idleDragTimeout); + this._idleDragTimeout = null; + } + } + private step(): void { this._cachedLoudness = null; this._stepEdgeActivatedTriggers(); diff --git a/src/Renderer.ts b/src/Renderer.ts index e73e895..1ae4689 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -611,6 +611,12 @@ export default class Renderer { } } + // Filter out the sprite that is being dragged, if any. + // A sprite that is being dragged can detect other sprites, but other sprites can't detect it. + if (this.project.draggingSprite) { + targets.delete(this.project.draggingSprite); + } + const sprBox = Rectangle.copy( this.getBoundingBox(spr), __collisionBox diff --git a/src/Sprite.ts b/src/Sprite.ts index 09efff7..79bc8dd 100644 --- a/src/Sprite.ts +++ b/src/Sprite.ts @@ -634,6 +634,10 @@ export class Sprite extends SpriteBase { this._project.runningTriggers = this._project.runningTriggers.filter( ({ target }) => target !== this ); + + if (this._project.draggingSprite === this) { + this._project.draggingSprite = null; + } } public andClones(): this[] { @@ -648,7 +652,8 @@ export class Sprite extends SpriteBase { this._direction = this.normalizeDeg(dir); } - public goto(x: number, y: number): void { + public goto(x: number, y: number, fromDrag?: boolean): void { + if (this._project.draggingSprite === this && !fromDrag) return; if (x === this.x && y === this.y) return; if (this.penDown) { From d32440d5bffa720487fa0bf815cd1225da66a2f8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 15:30:43 -0300 Subject: [PATCH 05/10] never start dragging a sprite that doesn't exist anymore --- src/Project.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Project.ts b/src/Project.ts index 03d9c76..4626404 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -226,6 +226,10 @@ export default class Project { } private _startDragging(): void { + if (!this.spritesAndClones.includes(this._consideringDraggingSprite)) { + this._consideringDraggingSprite = null; + } + if (this._consideringDraggingSprite) { this.draggingSprite = this._consideringDraggingSprite; this._consideringDraggingSprite = null; From 701bec0e0d32ef9d9bcfff69ad38c371de7910c6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 21:02:57 -0300 Subject: [PATCH 06/10] grab sprite under mouse at time of drag start --- src/Project.ts | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 4626404..61dd5d7 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -26,7 +26,6 @@ export default class Project { public draggingSprite: Sprite | null; public dragThreshold: number; - private _consideringDraggingSprite: Sprite | null; private _dragOffsetX: number; private _dragOffsetY: number; private _idleDragTimeout: number | null; @@ -64,7 +63,6 @@ export default class Project { this.answer = null; this.draggingSprite = null; - this._consideringDraggingSprite = null; this._dragOffsetX = 0; this._dragOffsetY = 0; this._idleDragTimeout = null; @@ -98,7 +96,6 @@ export default class Project { const spriteUnderMouse = this._spriteUnderMouse; const targetUnderMouse = this._targetUnderMouse; if (spriteUnderMouse && spriteUnderMouse.draggable) { - this._consideringDraggingSprite = spriteUnderMouse; this._startIdleDragTimeout(); } else { this._startClickTriggersFor(targetUnderMouse); @@ -109,9 +106,9 @@ export default class Project { // TODO: Effects - goto() and moveAhead() - are applied immediately. // Do we want to buffer them to apply at the start of the next tick? if (this.input.mouse.down) { - if (this._consideringDraggingSprite && this.input.mouse.downAt) { - const distanceX = this.input.mouse.x - this.input.mouse.downAt.x; - const distanceY = this.input.mouse.y - this.input.mouse.downAt.y; + if (!this.draggingSprite) { + const distanceX = this.input.mouse.x - this.input.mouse.downAt!.x; + const distanceY = this.input.mouse.y - this.input.mouse.downAt!.y; const distanceFromMouseDown = Math.sqrt( distanceX ** 2 + distanceY ** 2 ); @@ -226,26 +223,21 @@ export default class Project { } private _startDragging(): void { - if (!this.spritesAndClones.includes(this._consideringDraggingSprite)) { - this._consideringDraggingSprite = null; - } + const spriteUnderMouse = this._spriteUnderMouse; + if (!spriteUnderMouse || !spriteUnderMouse.draggable) return; - if (this._consideringDraggingSprite) { - this.draggingSprite = this._consideringDraggingSprite; - this._consideringDraggingSprite = null; - this._clearIdleDragTimeout(); + this.draggingSprite = spriteUnderMouse; + this._clearIdleDragTimeout(); - this._dragOffsetX = this.draggingSprite.x - this.input.mouse.x; - this._dragOffsetY = this.draggingSprite.y - this.input.mouse.y; + this._dragOffsetX = this.draggingSprite.x - this.input.mouse.x; + this._dragOffsetY = this.draggingSprite.y - this.input.mouse.y; - this.draggingSprite.moveAhead(); - } + this.draggingSprite.moveAhead(); } private _clearDragging(): boolean { const wasDragging = !!this.draggingSprite; this.draggingSprite = null; - this._consideringDraggingSprite = null; this._dragOffsetX = 0; this._dragOffsetY = 0; this._clearIdleDragTimeout(); From f07ad8051ef549922b0a6a525f373641932c7ab5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 22:05:42 -0300 Subject: [PATCH 07/10] grab sprite under mouse at time of mouse down + behavior details --- src/Project.ts | 96 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 61dd5d7..de97145 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -92,50 +92,77 @@ export default class Project { } }); - this.renderer.stage.addEventListener("mousedown", () => { - const spriteUnderMouse = this._spriteUnderMouse; - const targetUnderMouse = this._targetUnderMouse; - if (spriteUnderMouse && spriteUnderMouse.draggable) { - this._startIdleDragTimeout(); + this.renderer.stage.addEventListener("mousedown", event => { + this._startIdleDragTimeout(); + + const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { + x: this.input.mouse.x, + y: this.input.mouse.y, + }); + + if (spriteUnderMouse) { + // Draggable sprites' click triggers are started when the mouse is released + // (provided no drag has started by that point). However, they still occlude + // a click on the stage. + if (!spriteUnderMouse.draggable) { + this._startClickTriggersFor(spriteUnderMouse); + } } else { - this._startClickTriggersFor(targetUnderMouse); + // If there's no sprite under the mouse at all, the stage was clicked. + this._startClickTriggersFor(this.stage); } }); this.renderer.stage.addEventListener("mousemove", () => { - // TODO: Effects - goto() and moveAhead() - are applied immediately. - // Do we want to buffer them to apply at the start of the next tick? if (this.input.mouse.down) { if (!this.draggingSprite) { + // Consider dragging based on if the mouse has traveled far from where it was pressed down. const distanceX = this.input.mouse.x - this.input.mouse.downAt!.x; const distanceY = this.input.mouse.y - this.input.mouse.downAt!.y; const distanceFromMouseDown = Math.sqrt( distanceX ** 2 + distanceY ** 2 ); if (distanceFromMouseDown > this.dragThreshold) { - this._startDragging(); + // Try starting dragging from where the mouse was pressed down. Yes, this means we're + // checking for the presence of a draggable sprite *where the mouse was pressed down, + // no matter where it is now.* This makes for subtly predictable and hilarious hijinks: + // https://github.com/scratchfoundation/scratch-gui/pull/1434#issuecomment-2207679144 + this._tryStartingDraggingFrom(this.input.mouse.downAt!.x, this.input.mouse.downAt!.y); } } if (this.draggingSprite) { const gotoX = this.input.mouse.x + this._dragOffsetX; const gotoY = this.input.mouse.y + this._dragOffsetY; + + // TODO: This is applied immediately. Do we want to buffer it til the start of the next tick? this.draggingSprite.goto(gotoX, gotoY, true); } } }); this.renderer.stage.addEventListener("mouseup", () => { - if (!this._clearDragging()) { - const spriteUnderMouse = this._spriteUnderMouse; - if (spriteUnderMouse && spriteUnderMouse.draggable) { - this._startClickTriggersFor(spriteUnderMouse); - } + // Releasing the mouse terminates a drag, and if this is the case, don't start click triggers. + if (this._clearDragging()) { + return; + } + + const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { + x: this.input.mouse.x, + y: this.input.mouse.y, + }); + + // Only draggable sprites start click triggers when the mouse is released. + // Non-draggable sprites' click triggers are started when the mouse is pressed. + if (spriteUnderMouse && spriteUnderMouse.draggable) { + this._startClickTriggersFor(spriteUnderMouse); } }); if (this.renderer.stage.ownerDocument) { this.renderer.stage.ownerDocument.addEventListener("mouseup", () => { + // Releasing the mouse outside of the stage canvas should never start click triggers, + // so we don't care if a drag was actually cleared or not. void this._clearDragging(); }); } @@ -211,28 +238,21 @@ export default class Project { void this._startTriggers(matchingTriggers); } - private get _spriteUnderMouse(): Sprite | null { - return this.renderer.pick(this.spritesAndClones, { - x: this.input.mouse.x, - y: this.input.mouse.y, - }); - } + private _tryStartingDraggingFrom(x: number, y: number): void { + const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { x, y }); + if (spriteUnderMouse && spriteUnderMouse.draggable) { + this.draggingSprite = spriteUnderMouse; + this._clearIdleDragTimeout(); - private get _targetUnderMouse(): Sprite | Stage { - return this._spriteUnderMouse || this.stage; - } + // Note the drag offset is in terms of where the drag is starting from, not where the mouse is now. + // This has the apparent effect of teleporting the sprite a significant distance, if you moved your + // mouse far away from where you pressed it down. + this._dragOffsetX = this.draggingSprite.x - x; + this._dragOffsetY = this.draggingSprite.y - y; - private _startDragging(): void { - const spriteUnderMouse = this._spriteUnderMouse; - if (!spriteUnderMouse || !spriteUnderMouse.draggable) return; - - this.draggingSprite = spriteUnderMouse; - this._clearIdleDragTimeout(); - - this._dragOffsetX = this.draggingSprite.x - this.input.mouse.x; - this._dragOffsetY = this.draggingSprite.y - this.input.mouse.y; - - this.draggingSprite.moveAhead(); + // TODO: This is applied immediately. Do we want to buffer it til the start of the next tick? + this.draggingSprite.moveAhead(); + } } private _clearDragging(): boolean { @@ -245,8 +265,14 @@ export default class Project { } private _startIdleDragTimeout(): void { + // We call this the "idle drag timeout" because it's only relevant if you haven't moved the mouse + // past the drag threshold, so that you'd just call _tryStartDraggingFrom normally. (Or you *have* + // moved it past the threshold, but are not currently moving it on the frame when this timeout + // activates.) Note that the bind is to the position of the mouse when the mouse is pressed down, + // i.e. it will start dragging regardless where the mouse actually is when this timeout activates - + // although usually, it's in the same place, because you just pressed it down and held it still. this._idleDragTimeout = window.setTimeout( - this._startDragging.bind(this), + this._tryStartingDraggingFrom.bind(this, this.input.mouse.x, this.input.mouse.y), 400 ); } From a6229520c802f2f854b6a1eb12ce50b4d4c4a20e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 22:15:20 -0300 Subject: [PATCH 08/10] move event listener behavior into functions --- src/Project.ts | 177 +++++++++++++++++++++++++++---------------------- 1 file changed, 96 insertions(+), 81 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index de97145..487a9a8 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -37,6 +37,8 @@ export default class Project { private _prevStepTriggerPredicates: WeakMap; public constructor(stage: Stage, sprites = {}, { frameRate = 30 } = {}) { + this._bindListenerFunctions(); + this.stage = stage; this.sprites = sprites; @@ -79,92 +81,24 @@ export default class Project { this._renderLoop(); } + private _bindListenerFunctions() { + this._onStageClick = this._onStageClick.bind(this); + this._onStageMouseDown = this._onStageMouseDown.bind(this); + this._onStageMouseMove = this._onStageMouseMove.bind(this); + this._onStageMouseUp = this._onStageMouseUp.bind(this); + this._onPageMouseUp = this._onPageMouseUp.bind(this); + } + public attach(renderTarget: string | HTMLElement): void { this.renderer.setRenderTarget(renderTarget); - this.renderer.stage.addEventListener("click", () => { - // Chrome requires a user gesture on the page before we can start the - // audio context. - // When we click the stage, that counts as a user gesture, so try - // resuming the audio context. - if (Sound.audioContext.state === "suspended") { - void Sound.audioContext.resume(); - } - }); - - this.renderer.stage.addEventListener("mousedown", event => { - this._startIdleDragTimeout(); - - const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { - x: this.input.mouse.x, - y: this.input.mouse.y, - }); - - if (spriteUnderMouse) { - // Draggable sprites' click triggers are started when the mouse is released - // (provided no drag has started by that point). However, they still occlude - // a click on the stage. - if (!spriteUnderMouse.draggable) { - this._startClickTriggersFor(spriteUnderMouse); - } - } else { - // If there's no sprite under the mouse at all, the stage was clicked. - this._startClickTriggersFor(this.stage); - } - }); - - this.renderer.stage.addEventListener("mousemove", () => { - if (this.input.mouse.down) { - if (!this.draggingSprite) { - // Consider dragging based on if the mouse has traveled far from where it was pressed down. - const distanceX = this.input.mouse.x - this.input.mouse.downAt!.x; - const distanceY = this.input.mouse.y - this.input.mouse.downAt!.y; - const distanceFromMouseDown = Math.sqrt( - distanceX ** 2 + distanceY ** 2 - ); - if (distanceFromMouseDown > this.dragThreshold) { - // Try starting dragging from where the mouse was pressed down. Yes, this means we're - // checking for the presence of a draggable sprite *where the mouse was pressed down, - // no matter where it is now.* This makes for subtly predictable and hilarious hijinks: - // https://github.com/scratchfoundation/scratch-gui/pull/1434#issuecomment-2207679144 - this._tryStartingDraggingFrom(this.input.mouse.downAt!.x, this.input.mouse.downAt!.y); - } - } - - if (this.draggingSprite) { - const gotoX = this.input.mouse.x + this._dragOffsetX; - const gotoY = this.input.mouse.y + this._dragOffsetY; - - // TODO: This is applied immediately. Do we want to buffer it til the start of the next tick? - this.draggingSprite.goto(gotoX, gotoY, true); - } - } - }); - - this.renderer.stage.addEventListener("mouseup", () => { - // Releasing the mouse terminates a drag, and if this is the case, don't start click triggers. - if (this._clearDragging()) { - return; - } - - const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { - x: this.input.mouse.x, - y: this.input.mouse.y, - }); - - // Only draggable sprites start click triggers when the mouse is released. - // Non-draggable sprites' click triggers are started when the mouse is pressed. - if (spriteUnderMouse && spriteUnderMouse.draggable) { - this._startClickTriggersFor(spriteUnderMouse); - } - }); + this.renderer.stage.addEventListener("click", this._onStageClick); + this.renderer.stage.addEventListener("mousedown", this._onStageMouseDown); + this.renderer.stage.addEventListener("mousemove", this._onStageMouseMove); + this.renderer.stage.addEventListener("mouseup", this._onStageMouseUp); if (this.renderer.stage.ownerDocument) { - this.renderer.stage.ownerDocument.addEventListener("mouseup", () => { - // Releasing the mouse outside of the stage canvas should never start click triggers, - // so we don't care if a drag was actually cleared or not. - void this._clearDragging(); - }); + this.renderer.stage.ownerDocument.addEventListener("mouseup", this._onPageMouseUp); } } @@ -238,6 +172,87 @@ export default class Project { void this._startTriggers(matchingTriggers); } + private _onStageClick(): void { + // Chrome requires a user gesture on the page before we can start the audio context. + // When we click the stage, that counts as a user gesture, so try resuming the audio context. + if (Sound.audioContext.state === "suspended") { + void Sound.audioContext.resume(); + } + } + + private _onStageMouseDown(): void { + this._startIdleDragTimeout(); + + const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { + x: this.input.mouse.x, + y: this.input.mouse.y, + }); + + if (spriteUnderMouse) { + // Draggable sprites' click triggers are started when the mouse is released + // (provided no drag has started by that point). However, they still occlude + // a click on the stage. + if (!spriteUnderMouse.draggable) { + this._startClickTriggersFor(spriteUnderMouse); + } + } else { + // If there's no sprite under the mouse at all, the stage was clicked. + this._startClickTriggersFor(this.stage); + } + } + + private _onStageMouseMove(): void { + if (this.input.mouse.down) { + if (!this.draggingSprite) { + // Consider dragging based on if the mouse has traveled far from where it was pressed down. + const distanceX = this.input.mouse.x - this.input.mouse.downAt!.x; + const distanceY = this.input.mouse.y - this.input.mouse.downAt!.y; + const distanceFromMouseDown = Math.sqrt( + distanceX ** 2 + distanceY ** 2 + ); + if (distanceFromMouseDown > this.dragThreshold) { + // Try starting dragging from where the mouse was pressed down. Yes, this means we're + // checking for the presence of a draggable sprite *where the mouse was pressed down, + // no matter where it is now.* This makes for subtly predictable and hilarious hijinks: + // https://github.com/scratchfoundation/scratch-gui/pull/1434#issuecomment-2207679144 + this._tryStartingDraggingFrom(this.input.mouse.downAt!.x, this.input.mouse.downAt!.y); + } + } + + if (this.draggingSprite) { + const gotoX = this.input.mouse.x + this._dragOffsetX; + const gotoY = this.input.mouse.y + this._dragOffsetY; + + // TODO: This is applied immediately. Do we want to buffer it til the start of the next tick? + this.draggingSprite.goto(gotoX, gotoY, true); + } + } + } + + private _onStageMouseUp(): void { + // Releasing the mouse terminates a drag, and if this is the case, don't start click triggers. + if (this._clearDragging()) { + return; + } + + const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { + x: this.input.mouse.x, + y: this.input.mouse.y, + }); + + // Only draggable sprites start click triggers when the mouse is released. + // Non-draggable sprites' click triggers are started when the mouse is pressed. + if (spriteUnderMouse && spriteUnderMouse.draggable) { + this._startClickTriggersFor(spriteUnderMouse); + } + } + + private _onPageMouseUp(): void { + // Releasing the mouse outside of the stage canvas should never start click triggers, + // so we don't care if a drag was actually cleared or not. + void this._clearDragging(); + } + private _tryStartingDraggingFrom(x: number, y: number): void { const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { x, y }); if (spriteUnderMouse && spriteUnderMouse.draggable) { From d7dba4d8339a68a3e9599c1ff4ecabda524cecb6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 22:28:49 -0300 Subject: [PATCH 09/10] add touch listeners + misc lint fixes --- src/Project.ts | 57 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 487a9a8..963151c 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -81,24 +81,32 @@ export default class Project { this._renderLoop(); } - private _bindListenerFunctions() { + private _bindListenerFunctions(): void { this._onStageClick = this._onStageClick.bind(this); - this._onStageMouseDown = this._onStageMouseDown.bind(this); - this._onStageMouseMove = this._onStageMouseMove.bind(this); - this._onStageMouseUp = this._onStageMouseUp.bind(this); - this._onPageMouseUp = this._onPageMouseUp.bind(this); + this._onStagePointerPress = this._onStagePointerPress.bind(this); + this._onStagePointerMove = this._onStagePointerMove.bind(this); + this._onStagePointerRelease = this._onStagePointerRelease.bind(this); + this._onPagePointerRelease = this._onPagePointerRelease.bind(this); } public attach(renderTarget: string | HTMLElement): void { - this.renderer.setRenderTarget(renderTarget); + /* eslint-disable @typescript-eslint/unbound-method */ - this.renderer.stage.addEventListener("click", this._onStageClick); - this.renderer.stage.addEventListener("mousedown", this._onStageMouseDown); - this.renderer.stage.addEventListener("mousemove", this._onStageMouseMove); - this.renderer.stage.addEventListener("mouseup", this._onStageMouseUp); + this.renderer.setRenderTarget(renderTarget); - if (this.renderer.stage.ownerDocument) { - this.renderer.stage.ownerDocument.addEventListener("mouseup", this._onPageMouseUp); + const { stage } = this.renderer; + stage.addEventListener("click", this._onStageClick); + stage.addEventListener("mousedown", this._onStagePointerPress); + stage.addEventListener("mousemove", this._onStagePointerMove); + stage.addEventListener("mouseup", this._onStagePointerRelease); + stage.addEventListener("touchstart", this._onStagePointerMove); + stage.addEventListener("touchmove", this._onStagePointerMove); + stage.addEventListener("touchend", this._onStagePointerRelease); + + const { ownerDocument: stageDocument } = stage; + if (stageDocument) { + stageDocument.addEventListener("mouseup", this._onPagePointerRelease); + stageDocument.addEventListener("touchend", this._onPagePointerRelease); } } @@ -180,7 +188,7 @@ export default class Project { } } - private _onStageMouseDown(): void { + private _onStagePointerPress(): void { this._startIdleDragTimeout(); const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { @@ -201,7 +209,7 @@ export default class Project { } } - private _onStageMouseMove(): void { + private _onStagePointerMove(): void { if (this.input.mouse.down) { if (!this.draggingSprite) { // Consider dragging based on if the mouse has traveled far from where it was pressed down. @@ -215,7 +223,10 @@ export default class Project { // checking for the presence of a draggable sprite *where the mouse was pressed down, // no matter where it is now.* This makes for subtly predictable and hilarious hijinks: // https://github.com/scratchfoundation/scratch-gui/pull/1434#issuecomment-2207679144 - this._tryStartingDraggingFrom(this.input.mouse.downAt!.x, this.input.mouse.downAt!.y); + this._tryStartingDraggingFrom( + this.input.mouse.downAt!.x, + this.input.mouse.downAt!.y + ); } } @@ -229,7 +240,7 @@ export default class Project { } } - private _onStageMouseUp(): void { + private _onStagePointerRelease(): void { // Releasing the mouse terminates a drag, and if this is the case, don't start click triggers. if (this._clearDragging()) { return; @@ -247,14 +258,18 @@ export default class Project { } } - private _onPageMouseUp(): void { + private _onPagePointerRelease(): void { // Releasing the mouse outside of the stage canvas should never start click triggers, // so we don't care if a drag was actually cleared or not. void this._clearDragging(); } private _tryStartingDraggingFrom(x: number, y: number): void { - const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { x, y }); + const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { + x, + y, + }); + if (spriteUnderMouse && spriteUnderMouse.draggable) { this.draggingSprite = spriteUnderMouse; this._clearIdleDragTimeout(); @@ -287,7 +302,11 @@ export default class Project { // i.e. it will start dragging regardless where the mouse actually is when this timeout activates - // although usually, it's in the same place, because you just pressed it down and held it still. this._idleDragTimeout = window.setTimeout( - this._tryStartingDraggingFrom.bind(this, this.input.mouse.x, this.input.mouse.y), + this._tryStartingDraggingFrom.bind( + this, + this.input.mouse.x, + this.input.mouse.y + ), 400 ); } From ef35f2352990cd0f669f28bcd52ad3dff02b3e53 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 3 Jul 2024 22:40:41 -0300 Subject: [PATCH 10/10] only qualify primary mouse button & touches for drag --- src/Project.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Project.ts b/src/Project.ts index 963151c..d240162 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -28,6 +28,7 @@ export default class Project { public dragThreshold: number; private _dragOffsetX: number; private _dragOffsetY: number; + private _dragQualified: boolean; private _idleDragTimeout: number | null; /** @@ -67,6 +68,7 @@ export default class Project { this.draggingSprite = null; this._dragOffsetX = 0; this._dragOffsetY = 0; + this._dragQualified = false; this._idleDragTimeout = null; // TODO: Enable customizing, like frameRate @@ -188,8 +190,17 @@ export default class Project { } } - private _onStagePointerPress(): void { - this._startIdleDragTimeout(); + private _onStagePointerPress(event: PointerEvent | TouchEvent): void { + if ( + (event instanceof PointerEvent && event.button === 0) || + (window.TouchEvent && event instanceof TouchEvent) + ) { + // Qualifying a drag doesn't mean we actually are dragging anything, it just indicates that + // the current pointer movement - starting from this mousedown / touchstart event - is suitable + // for beginning a drag, provided the conditions line up right. + this._dragQualified = true; + this._startIdleDragTimeout(); + } const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, { x: this.input.mouse.x, @@ -210,7 +221,7 @@ export default class Project { } private _onStagePointerMove(): void { - if (this.input.mouse.down) { + if (this.input.mouse.down && this._dragQualified) { if (!this.draggingSprite) { // Consider dragging based on if the mouse has traveled far from where it was pressed down. const distanceX = this.input.mouse.x - this.input.mouse.downAt!.x; @@ -241,7 +252,7 @@ export default class Project { } private _onStagePointerRelease(): void { - // Releasing the mouse terminates a drag, and if this is the case, don't start click triggers. + // Releasing the mouse terminates a drag. If one was terminated, don't start click triggers. if (this._clearDragging()) { return; } @@ -260,7 +271,7 @@ export default class Project { private _onPagePointerRelease(): void { // Releasing the mouse outside of the stage canvas should never start click triggers, - // so we don't care if a drag was actually cleared or not. + // so we don't care if a drag was actually terminated or not. void this._clearDragging(); } @@ -291,6 +302,7 @@ export default class Project { this._dragOffsetX = 0; this._dragOffsetY = 0; this._clearIdleDragTimeout(); + this._dragQualified = false; return wasDragging; }