diff --git a/src/editor/interaction.js b/src/editor/interaction.js index f3fe1bd..3bf80cc 100644 --- a/src/editor/interaction.js +++ b/src/editor/interaction.js @@ -27,9 +27,12 @@ export class InteractionManager { } _bindEvents() { - this.svg.addEventListener('mousedown', (e) => this._onMouseDown(e)); - this.svg.addEventListener('mousemove', (e) => this._onMouseMove(e)); - this.svg.addEventListener('mouseup', (e) => this._onMouseUp(e)); + // Pointer events cover mouse, touch and pen with one code path, so dragging + // works on phones/tablets as well as desktop. + this.svg.addEventListener('pointerdown', (e) => this._onPointerDown(e)); + this.svg.addEventListener('pointermove', (e) => this._onPointerMove(e)); + this.svg.addEventListener('pointerup', (e) => this._onPointerUp(e)); + this.svg.addEventListener('pointercancel', (e) => this._onPointerUp(e)); this.svg.addEventListener('click', (e) => this._onClick(e)); this.svg.addEventListener('dblclick', (e) => this._onDoubleClick(e)); this.svg.addEventListener('contextmenu', (e) => this._onContextMenu(e)); @@ -42,7 +45,21 @@ export class InteractionManager { document.addEventListener('keydown', (e) => this._onKeyDown(e)); } + /** + * Convert a pointer/mouse event to viewBox coordinates. Uses the SVG's screen + * CTM so it is correct at any rendered size — the SVG is scaled to 100% width, + * so on small screens client pixels do NOT equal viewBox units. + */ _svgPoint(e) { + const ctm = this.svg.getScreenCTM && this.svg.getScreenCTM(); + if (ctm) { + const pt = this.svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const local = pt.matrixTransform(ctm.inverse()); + return { x: local.x, y: local.y }; + } + // Fallback: assume 1:1 mapping (only correct when rendered at viewBox size). const rect = this.svg.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } @@ -103,7 +120,7 @@ export class InteractionManager { return best; } - _onMouseDown(e) { + _onPointerDown(e) { if (e.button !== 0) return; if (this.state.activeTool !== 'select') return; @@ -122,6 +139,9 @@ export class InteractionManager { offsetY: svgPt.y - eSvg.y, hasMoved: false, }; + // Capture so the drag keeps tracking even if the finger/cursor leaves + // the entity (or the SVG re-renders under it). + try { this.svg.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ } this.state.saveUndo(); this.state.select(hitKey); e.preventDefault(); @@ -129,7 +149,7 @@ export class InteractionManager { } } - _onMouseMove(e) { + _onPointerMove(e) { if (!this.dragging) return; const svgPt = this._svgPoint(e); const t = this.getTransform(); @@ -153,18 +173,29 @@ export class InteractionManager { this._hideSnapIndicator(); } this.dragging.hasMoved = true; + e.preventDefault(); } - _onMouseUp(e) { + _onPointerUp(e) { if (this.dragging) { + this._dragMoved = this.dragging.hasMoved; this._hideSnapIndicator(); this.dragging = null; + try { this.svg.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ } } } _onClick(e) { const tool = this.state.activeTool; + // A click is synthesized after a drag (pointerup). If the drag actually + // moved the entity, swallow this click so it does not re-run hit testing + // (which could deselect after the token has moved away from the tap point). + if (this._dragMoved) { + this._dragMoved = false; + return; + } + // Line drawing mode if (tool.startsWith('line_')) { const courtPt = this._courtPoint(e); diff --git a/tests/regressions.spec.js b/tests/regressions.spec.js index 4c0e72a..8b1f04f 100644 --- a/tests/regressions.spec.js +++ b/tests/regressions.spec.js @@ -23,17 +23,20 @@ function paletteBtn(page, label) { return page.locator('.palette-item .pal-label', { hasText: label }).locator('..'); } -/** Convert a court coordinate to a client (screen) point over the SVG. - * Mirrors InteractionManager._svgPoint: the handler reads `clientX - rect.left` - * and feeds it straight into the viewBox-based transform, so a viewBox unit maps - * to (box.x + svgX) regardless of any CSS scaling. */ +/** Convert a court coordinate to a client (viewport) point over the SVG, using + * the SVG screen CTM — correct at any rendered size (the court scales to 100% + * width, so client pixels do not equal viewBox units). */ async function courtToClient(page, courtX, courtY) { - const svg = await page.evaluate(([cx, cy]) => { + return await page.evaluate(([cx, cy]) => { + const svg = document.getElementById('court-svg'); const t = window.OCFEditor.getCurrentTransform(); - return t.toSvg(cx, cy); + const v = t.toSvg(cx, cy); // viewBox coords + const pt = svg.createSVGPoint(); + pt.x = v.x; + pt.y = v.y; + const s = pt.matrixTransform(svg.getScreenCTM()); // -> client coords + return { x: s.x, y: s.y }; }, [courtX, courtY]); - const box = await page.locator('#court-svg').boundingBox(); - return { x: box.x + svg.x, y: box.y + svg.y }; } // ─── #1 Ball possession ─────────────────────────────────────────────────────── @@ -143,33 +146,39 @@ test.describe('Regression #3: snap indicator survives the per-move re-render', ( await openEditor(page); await paletteBtn(page, 'Offense').click(); // fiba default: offense_1 at (0, 7) - // Drive the real InteractionManager handlers with synthetic mouse events so - // the sequence is deterministic. The handler reads `clientX - rect.left` and - // feeds it straight into the viewBox transform, so a viewBox unit maps to - // (rect.left + svgX). We snap onto two named positions in a row. + // Drive the real InteractionManager handlers with synthetic pointer events + // so the sequence is deterministic. We snap onto two named positions in a row. const result = await page.evaluate(() => { const svg = document.getElementById('court-svg'); const t = window.OCFEditor.getCurrentTransform(); - const rect = svg.getBoundingClientRect(); - const fire = (type, c, buttons) => svg.dispatchEvent(new MouseEvent(type, { + const ctm = svg.getScreenCTM(); + const toClient = (cx, cy) => { + const v = t.toSvg(cx, cy); + const p = svg.createSVGPoint(); + p.x = v.x; p.y = v.y; + const s = p.matrixTransform(ctm); + return { x: s.x, y: s.y }; + }; + const fire = (type, c, buttons) => svg.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, view: window, - clientX: rect.left + c.x, clientY: rect.top + c.y, button: 0, buttons, + pointerId: 1, pointerType: 'mouse', isPrimary: true, + clientX: c.x, clientY: c.y, button: 0, buttons, })); const isConnected = () => { const c = svg.querySelector('circle[stroke="#00cc66"]'); return !!c && c.isConnected; }; - const start = t.toSvg(0, 7); // the player - const snap1 = t.toSvg(0, 8.2); // free_throw_line (fiba) - const snap2 = t.toSvg(0, 5.68); // top_of_the_key (fiba) + const start = toClient(0, 7); // the player + const snap1 = toClient(0, 8.2); // free_throw_line (fiba) + const snap2 = toClient(0, 5.68); // top_of_the_key (fiba) - fire('mousedown', start, 1); - fire('mousemove', snap1, 1); // first snap → indicator created + appended + fire('pointerdown', start, 1); + fire('pointermove', snap1, 1); // first snap → indicator created + appended const afterFirst = isConnected(); - fire('mousemove', snap2, 1); // second snap → re-render wipes innerHTML + fire('pointermove', snap2, 1); // second snap → re-render wipes innerHTML const afterSecond = isConnected(); - fire('mouseup', snap2, 0); + fire('pointerup', snap2, 0); const player = window.OCFEditor.editorState.doc.entities.find(e => e.type === 'offense'); return { afterFirst, afterSecond, movedTo: player.y }; @@ -229,3 +238,57 @@ test.describe('Regression: offense/defense numbering capped at 9', () => { expect(result.cone).toBe(1); }); }); + +// ─── Mobile / touch ───────────────────────────────────────────────────────────── + +test.describe('Regression: touch dragging on a small screen', () => { + // Narrow viewport → the court SVG is scaled well below its 700-unit viewBox, + // and touch input is enabled. This guards both mobile fixes at once: + // - pointer events (mouse-only handlers never fired for touch) + // - CTM-based coordinate mapping (client px ≠ viewBox units when scaled) + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); + + test('an entity can be dragged with touch and lands at the right court spot', async ({ page }) => { + await openEditor(page); + + // Add a player directly (avoids depending on the responsive sidebar layout). + await page.evaluate(() => { + window.OCFEditor.editorState.addEntity({ type: 'offense', nr: 1, x: 0, y: 7 }); + }); + + const renderedWidth = await page.evaluate( + () => document.getElementById('court-svg').getBoundingClientRect().width, + ); + expect(renderedWidth).toBeLessThan(700); // confirms the SVG is scaled down + + const moved = await page.evaluate(() => { + const svg = document.getElementById('court-svg'); + const t = window.OCFEditor.getCurrentTransform(); + const ctm = svg.getScreenCTM(); + const toClient = (cx, cy) => { + const v = t.toSvg(cx, cy); + const p = svg.createSVGPoint(); + p.x = v.x; p.y = v.y; + const s = p.matrixTransform(ctm); + return { x: s.x, y: s.y }; + }; + const fire = (type, c, buttons) => svg.dispatchEvent(new PointerEvent(type, { + bubbles: true, cancelable: true, view: window, + pointerId: 1, pointerType: 'touch', isPrimary: true, + clientX: c.x, clientY: c.y, button: 0, buttons, + })); + + const from = toClient(0, 7); // on the player + const target = toClient(-3, 3); // empty spot, not a snap position + fire('pointerdown', from, 1); + fire('pointermove', target, 1); + fire('pointerup', target, 0); + + const p = window.OCFEditor.editorState.doc.entities.find(e => e.type === 'offense'); + return { x: p.x, y: p.y }; + }); + + expect(moved.x).toBeCloseTo(-3, 1); + expect(moved.y).toBeCloseTo(3, 1); + }); +});