Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/ui/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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';
}
}
}

Expand Down
37 changes: 34 additions & 3 deletions src/ui/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | 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
Expand Down
51 changes: 51 additions & 0 deletions tools/probe_caret_long.cjs
Original file line number Diff line number Diff line change
@@ -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) => `<mi>${c}</mi>${i % 3 === 2 ? '<mo>+</mo>' : ''}`).join('');
await p.evaluate(({ rtl, NS, toks }) => editor.setMathML(`<math ${rtl ? 'dir="rtl" ' : ''}${NS}>${toks}</math>`), { 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); });
47 changes: 47 additions & 0 deletions tools/probe_caret_tap.cjs
Original file line number Diff line number Diff line change
@@ -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(`<math ${rtl ? 'dir="rtl" ' : ''}${NS}><mi>a</mi><mo>+</mo><mn>12</mn><mo>+</mo><mi>c</mi><mo>+</mo><mi>d</mi></math>`), { 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); });
41 changes: 41 additions & 0 deletions tools/probe_longpress_select.cjs
Original file line number Diff line number Diff line change
@@ -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(`<math ${rtl ? 'dir="rtl" ' : ''}${NS}><mi>a</mi><mo>+</mo><mn>12</mn><mo>+</mo><mi>c</mi><mo>+</mo><mi>d</mi></math>`), { 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); });
Loading