diff --git a/src/ui/display.ts b/src/ui/display.ts index d6ca89f..1f62b67 100644 --- a/src/ui/display.ts +++ b/src/ui/display.ts @@ -52,9 +52,14 @@ export class FormulaDisplay { el.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); + // the handle may sit beside the edge it belongs to (kept inside the window): the drag moves the edge by what + // the finger moved, not to where the finger is + const ax = Number(el.dataset.ax ?? NaN); + const start = this.toContent(ev.clientX, ev.clientY - 18); + const off = Number.isFinite(ax) ? start.x - ax : 0; const move = (e2: PointerEvent) => { const q = this.toContent(e2.clientX, e2.clientY - 18); - this.onHandle?.(end, q.x, q.y); + this.onHandle?.(end, q.x - off, q.y); }; const up = () => { document.removeEventListener('pointermove', move); @@ -220,6 +225,17 @@ export class FormulaDisplay { el.style.height = '36px'; el.style.overflow = 'visible'; el.style.display = ''; + el.dataset.ax = String(x); + // a handle at the edge of the formula (the start of a right-to-left line is its right edge) would be cut off by + // the window: it is moved inward, whole, where a finger can take it + const vr = this.view.getBoundingClientRect(); + const hr = el.getBoundingClientRect(); + if (vr.width > 0 && hr.width > 0) { + let dx = 0; + if (hr.left < vr.left + 2) dx = vr.left + 2 - hr.left; + else if (hr.right > vr.right - 2) dx = vr.right - 2 - hr.right; + if (dx) el.style.left = Math.round(x - 12 + dx) + 'px'; + } } } diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 31b8d5b..1325ada 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -420,10 +420,41 @@ export class MathEditor implements ToolbarHost { document.addEventListener('mouseup', this.onMouseUp); disp.view.addEventListener('dblclick', (e) => this.onDblClick(e as MouseEvent)); disp.view.addEventListener('contextmenu', (e) => e.preventDefault()); - // a long press on a touch screen does nothing of its own (no selection, no menu): the browser's native long-press - // behaviour would take the focus - and the keyboard - away from the formula + // a long press on a touch screen selects the line under the finger (the native selection of the browser would take the + // focus, and with it the keyboard, away from the formula); the handles then move either end – in either direction + let press: ReturnType | null = null; + let pressAt = { x: 0, y: 0 }; + const cancelPress = () => { + if (press) clearTimeout(press); + press = null; + }; disp.view.addEventListener('pointerdown', (e) => { - if ((e as PointerEvent).pointerType === 'touch') this.lastTouchAt = Date.now(); + const pe = e as PointerEvent; + if (pe.pointerType !== 'touch' || !pe.isPrimary) return; + this.lastTouchAt = Date.now(); + cancelPress(); + pressAt = { x: pe.clientX, y: pe.clientY }; + press = setTimeout(() => { + press = null; + const p = this.display.toContent(pressAt.x, pressAt.y); + const hit = this.display.hitTest(p.x, p.y); + if (!hit) return; + this.closePopup(); + this.core.setCaret(hit); + this.core.selectRun(hit); + // the browser may still send the mouse events of a tap when the finger lifts: they must not undo the selection + this.longPressedAt = Date.now(); + this.wantKeyboard(); + this.focusEditor(); + }, 450); + }); + disp.view.addEventListener('pointermove', (e) => { + if (press && Math.hypot((e as PointerEvent).clientX - pressAt.x, (e as PointerEvent).clientY - pressAt.y) > 10) cancelPress(); + }); + for (const t of ['pointerup', 'pointercancel', 'pointerleave']) disp.view.addEventListener(t, cancelPress); + // the touch that ended a long press must not start the browser's own selection / callout + disp.view.addEventListener('touchend', (e) => { + if (Date.now() - this.longPressedAt < 800 && e.cancelable) e.preventDefault(); }); // a long press can put the focus on the display itself (it is focusable) – and with it the keyboard goes away. The // formula field takes the focus back at once, in the same task, so that the keyboard never has to leave diff --git a/tools/probe_caret_long.cjs b/tools/probe_caret_long.cjs new file mode 100644 index 0000000..01211a6 --- /dev/null +++ b/tools/probe_caret_long.cjs @@ -0,0 +1,51 @@ +// tap to place the caret in a formula wider than the phone (it scrolls), LTR and RTL, Latin and Arabic letters +const { chromium } = require('playwright'); +(async () => { + const b = await chromium.launch({ channel: 'chrome' }); + for (const [rtl, ar] of [[false, false], [true, false], [true, true]]) { + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 }); + const p = await ctx.newPage(); + await p.goto(process.env.URL || 'http://localhost:8099/?output=0'); + await p.waitForFunction(() => window.editor && document.querySelector('#editorContainer .wrs_toolbar button')); + await p.waitForTimeout(700); + const NS = 'xmlns="http://www.w3.org/1998/Math/MathML"'; + const letters = ar ? 'سصضطظعغفقكلمنهوي' : 'abcdefghijklmnopqrstuvwxyz'; + const toks = [...letters, ...letters].map((c, i) => `${c}${i % 3 === 2 ? '+' : ''}`).join(''); + await p.evaluate(({ rtl, NS, toks }) => editor.setMathML(`${toks}`), { rtl, NS, toks }); + await p.waitForTimeout(600); + await p.touchscreen.tap(200, 600); await p.waitForTimeout(1200); // brings the keyboard up + // scroll the formula: caret at the far end shows the end + await p.evaluate(() => { editor.core.setCaret && 0; }); + const info = async () => p.evaluate(() => { + const v = document.querySelector('.wrs_formulaDisplay'), vr = v.getBoundingClientRect(); + const g = [...v.querySelectorAll('svg text')].map((t) => { const r = t.getBoundingClientRect(); return { t: t.textContent, l: r.left, r: r.right, y: r.top + r.height / 2 }; }); + return { view: [Math.round(vr.left), Math.round(vr.right)], scroll: [v.scrollLeft, (v.parentElement || v).scrollLeft], g }; + }); + const n = (await info()).g.length; + console.log(`\n${rtl ? 'RTL' : 'LTR'} ${ar ? 'Arabic' : 'Latin'}: ${n} glyphs`); + // caret to the far end, then to the start, to scroll the display both ways + for (const key of [rtl ? 'End' : 'End', 'Home']) { + await p.keyboard.press(key); + await p.waitForTimeout(500); + const { view, g, scroll } = await info(); + const vis = g.map((x, i) => ({ ...x, i })).filter((x) => x.l > view[0] + 24 && x.r < view[1] - 24 && x.t.trim()); + let bad = 0, tested = 0; + for (const x of vis.filter((_, k) => k % 3 === 0)) { + const mid = (x.l + x.r) / 2, q = (x.r - x.l) / 4; + const startSide = rtl ? mid + q : mid - q; + await p.touchscreen.tap(startSide, x.y); + await p.waitForTimeout(800); + const got = await p.evaluate(() => editor.core.caret.index); + // element indexes: every third letter is followed by '+': glyph i is element i + floor of '+' before it + const glyphs = g.length; + tested++; + const plus = g.filter((y, j) => j < x.i && y.t === '+').length; + const elIndex = x.i; // glyph order == element order (+ included) + if (got !== elIndex) { bad++; if (bad <= 4) console.log(` ${key}: tap ${x.t} (glyph ${x.i}) start half -> caret ${got}, expected ${elIndex}`); } + } + console.log(` after ${key}: view ${view}, scrollLeft ${scroll}, tapped ${tested}, wrong ${bad}`); + } + await ctx.close(); + } + await b.close(); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/tools/probe_caret_tap.cjs b/tools/probe_caret_tap.cjs new file mode 100644 index 0000000..d656d32 --- /dev/null +++ b/tools/probe_caret_tap.cjs @@ -0,0 +1,47 @@ +// tap to place the caret on a phone, LTR and RTL: where does the caret go for taps on each half of each glyph? +const { chromium } = require('playwright'); +(async () => { + const b = await chromium.launch({ channel: 'chrome' }); + for (const rtl of [false, true]) { + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 }); + const p = await ctx.newPage(); + await p.goto(process.env.URL || 'http://localhost:8099/?output=0'); + await p.waitForFunction(() => window.editor && document.querySelector('#editorContainer .wrs_toolbar button')); + await p.waitForTimeout(700); + const NS = 'xmlns="http://www.w3.org/1998/Math/MathML"'; + await p.evaluate(({ rtl, NS }) => editor.setMathML(`a+12+c+d`), { rtl, NS }); + await p.waitForTimeout(500); + const g = await p.evaluate(() => [...document.querySelectorAll('.wrs_formulaDisplay svg text')].map((t) => { const r = t.getBoundingClientRect(); return { t: t.textContent, l: r.left, r: r.right, y: r.top + r.height / 2 }; })); + console.log(rtl ? '\nRTL' : '\nLTR', g.map((x) => `${x.t}[${Math.round(x.l)}-${Math.round(x.r)}]`).join(' ')); + const caret = () => p.evaluate(() => { const c = editor.core.caret; return c.index; }); + // element index of each glyph: a=0 +=1 12=2 +=3 c=4 +=5 d=6 + const startIdx = [0, 1, 2, 4, 5, 6, 7], len = [1, 1, 2, 1, 1, 1, 1]; // '12' is two elements + await p.touchscreen.tap(g[0].l + 2, g[0].y + 90); // the first tap only brings the keyboard up + await p.waitForTimeout(1500); + await p.touchscreen.tap(g[0].l + 2, g[0].y + 90); + await p.waitForTimeout(1500); + let bad = 0; + for (let i = 0; i < g.length; i++) { + const x = g[i]; + const mid = (x.l + x.r) / 2, q = x.t === '12' ? (x.r - x.l) / 2 - 2 : (x.r - x.l) / 4; + // the start edge of the glyph in the reading direction: left in LTR, right in RTL + const startSide = rtl ? mid + q : mid - q, endSide = rtl ? mid - q : mid + q; + for (const [label, px, want] of [['start half', startSide, startIdx[i]], ['end half', endSide, startIdx[i] + len[i]]]) { + await p.touchscreen.tap(px, x.y); + await p.waitForTimeout(900); // apart enough not to be a double tap + const got = await caret(); + if (got !== want) { bad++; console.log(` tap ${x.t} ${label} (x=${Math.round(px)}): caret ${got}, expected ${want}`); } + } + } + // empty area to the end side of the formula: caret at the end + await p.touchscreen.tap(rtl ? 40 : 350, g[0].y); + await p.waitForTimeout(250); + console.log(' tap in the empty area on the far side -> caret', await caret(), '(formula length 8)'); + await p.touchscreen.tap(rtl ? 380 : 4, g[0].y + 60); + await p.waitForTimeout(250); + console.log(' tap in the empty area below, start side -> caret', await caret()); + console.log(` ${bad} mismatches`); + await ctx.close(); + } + await b.close(); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/tools/probe_longpress_select.cjs b/tools/probe_longpress_select.cjs new file mode 100644 index 0000000..217aec6 --- /dev/null +++ b/tools/probe_longpress_select.cjs @@ -0,0 +1,41 @@ +// long press to select on a phone, LTR and RTL: selection, keyboard, handles fully visible, dragging the handles +const { chromium } = require('playwright'); +(async () => { + const b = await chromium.launch({ channel: 'chrome' }); + for (const rtl of [false, true]) { + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 }); + const p = await ctx.newPage(); + await p.goto(process.env.URL || 'http://localhost:8099/?output=0'); + await p.waitForFunction(() => window.editor && document.querySelector('#editorContainer .wrs_toolbar button')); + await p.waitForTimeout(700); + const NS = 'xmlns="http://www.w3.org/1998/Math/MathML"'; + await p.evaluate(({ rtl, NS }) => editor.setMathML(`a+12+c+d`), { rtl, NS }); + await p.waitForTimeout(500); + const cdp = await ctx.newCDPSession(p); + const g = await p.evaluate(() => [...document.querySelectorAll('.wrs_formulaDisplay svg text')].map((t) => { const r = t.getBoundingClientRect(); return [t.textContent, r.left + r.width / 2, r.top + r.height / 2]; })); + const st = () => p.evaluate(() => { const f = document.querySelector('.wrs_focusElement'); return { sel: editor.core.hasSelection() ? editor.core.selectionMathML().replace(/ xmlns="[^"]*"/, '') : '(none)', focus: document.activeElement === f, inputMode: f.inputMode }; }); + const hs = () => p.evaluate(() => { const v = document.querySelector('.wrs_formulaDisplay').getBoundingClientRect(); return { view: [Math.round(v.left), Math.round(v.right)], handles: [...document.querySelectorAll('.wrs_forSelection')].filter((e) => getComputedStyle(e).display !== 'none').map((e) => { const r = e.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right), Math.round(r.top + r.height / 2)]; }) }; }); + await p.touchscreen.tap(g[0][1], g[0][2] + 80); await p.waitForTimeout(1200); // keyboard up + console.log(rtl ? '\nRTL' : '\nLTR', 'before:', JSON.stringify(await st())); + const t = g[2]; // the 12 + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: t[1], y: t[2] }] }); + await p.waitForTimeout(700); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await p.waitForTimeout(500); + console.log('long press on 12:', JSON.stringify(await st())); + const h = await hs(); + console.log('handles:', JSON.stringify(h), 'fully inside the view:', h.handles.every((x) => x[0] >= h.view[0] && x[1] <= h.view[1])); + // drag the end handle (rightmost in LTR, leftmost in RTL) in by two glyphs + const sorted = [...h.handles].sort((a, b) => a[0] - b[0]); + const endH = rtl ? sorted[0] : sorted[sorted.length - 1]; + const from = [(endH[0] + endH[1]) / 2, endH[2]]; + const to = [g[4][1] + (rtl ? -6 : 6), from[1]]; // after c + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from[0], y: from[1] }] }); + for (let i = 1; i <= 10; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 10, y: from[1] - 10 }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await p.waitForTimeout(400); + console.log('after dragging the end handle to after c:', JSON.stringify(await st())); + await ctx.close(); + } + await b.close(); +})().catch((e) => { console.error(e); process.exit(1); });