From 85e4c4f229e271bb28e4a358d0b7452a4cd4dbd7 Mon Sep 17 00:00:00 2001 From: Mohammad-Erhim Date: Wed, 14 Oct 2026 11:54:30 +0300 Subject: [PATCH 1/3] fix(ui): long press no longer closes and reopens the keyboard; the edit menu acts on pointerup - no blur/focus toggling after a long press (it made the keyboard leave and come back) - the focusable display hands the focus straight back to the formula field - one menu per press (the contextmenu event of the same press no longer rebuilds it) - menu actions run on pointerup and click; only Escape (not the keys the phone's keyboard sends) closes it Co-Authored-By: Claude Sonnet 5.5 --- src/ui/editor.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/ui/editor.ts b/src/ui/editor.ts index aa22114..a2d6bef 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -467,18 +467,11 @@ export class MathEditor implements ToolbarHost { disp.view.addEventListener('touchend', (e) => { if (Date.now() - this.longPressedAt < 800 && e.cancelable) e.preventDefault(); }); - // a long press can make the system hide the soft keyboard (the browser's own selection takes the text input away - // from the field) without the focus going anywhere: put the keyboard back when the finger lifts and while the - // selection of the page changes right after the press - const reopenKeyboard = () => { - if (!this.kbOn || !this.coarse() || Date.now() - this.longPressedAt > 1500) return; - const input = this.display.input; - input.inputMode = 'text'; - // the soft keyboard follows a field that is focused anew (the user's touch is the gesture that allows it) - input.blur(); - input.focus({ preventScroll: true }); - }; - for (const t of ['pointerup', 'touchend']) disp.view.addEventListener(t, () => setTimeout(reopenKeyboard, 80)); + // 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 + disp.view.addEventListener('focus', () => { + if (this.coarse() && this.kbOn) input.focus({ preventScroll: true }); + }); document.addEventListener('selectionchange', this.keepKeyboard); disp.view.addEventListener('selectstart', (e) => e.preventDefault()); // keyboard @@ -1601,7 +1594,11 @@ export class MathEditor implements ToolbarHost { } /** a small menu at the touch / click point: Cut, Copy, Paste, Select all */ + private ctxOpenedAt = 0; private openClipboardMenu(clientX: number, clientY: number): void { + // the long press itself and the contextmenu event that follows it are one gesture: one menu + if (this.ctxMenu && Date.now() - this.ctxOpenedAt < 700) return; + this.ctxOpenedAt = Date.now(); this.closeClipboardMenu(); const sel = this.core.hasSelection(); const act = (fn: () => void) => () => { @@ -1626,17 +1623,26 @@ export class MathEditor implements ToolbarHost { b.disabled = !enabled; // the formula keeps the focus (and the keyboard state) while the menu is used b.addEventListener('mousedown', (e) => e.preventDefault()); - b.addEventListener('click', (e) => { + // the action runs when the finger lifts on the button (pointerup) – a tap that the page would otherwise have to + // wait for the browser to turn into a click – and on click (keyboard, assistive technology), once + let done = false; + const run = (e: Event) => { e.preventDefault(); e.stopPropagation(); + if (done) return; + done = true; fn(); - }); + }; + b.addEventListener('pointerup', run); + b.addEventListener('click', run); menu.append(b); } this.mountFloating(menu, clientX, clientY); this.ctxMenu = menu; const away = (e: Event) => { if (menu.contains(e.target as Node)) return; + // the keyboard of a phone sends keys of its own (composition) when it comes up: only Escape closes + if (e.type === 'keydown' && (e as KeyboardEvent).key !== 'Escape') return; this.closeClipboardMenu(); document.removeEventListener('pointerdown', away, true); document.removeEventListener('keydown', away, true); From 441843597c47bc8908b204201055d36b49099528 Mon Sep 17 00:00:00 2001 From: Mohammad-Erhim Date: Wed, 14 Oct 2026 12:00:30 +0300 Subject: [PATCH 2/3] fix(ui): no long-press selection and no clipboard menu / paste box on any device; the toolbar Cut / Copy / Paste stay Co-Authored-By: Claude Sonnet 5.5 --- src/ui/editor.ts | 190 ++++------------------------------------------- src/ui/i18n.ts | 4 - src/ui/styles.ts | 6 -- 3 files changed, 13 insertions(+), 187 deletions(-) diff --git a/src/ui/editor.ts b/src/ui/editor.ts index a2d6bef..cdebce9 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -419,53 +419,11 @@ export class MathEditor implements ToolbarHost { document.addEventListener('mousemove', this.onMouseMove); document.addEventListener('mouseup', this.onMouseUp); disp.view.addEventListener('dblclick', (e) => this.onDblClick(e as MouseEvent)); - // the menu of the formula (Cut / Copy / Paste / Select all): right click, and the end of a long press on a touch screen - disp.view.addEventListener('contextmenu', (e) => { - e.preventDefault(); - const me = e as MouseEvent; - this.openClipboardMenu(me.clientX, me.clientY); - }); - // a long press on a touch screen selects the word / run under the finger (the native selection of the browser would - // take the focus, and with it the keyboard, away from the formula) - let press: ReturnType | null = null; - let pressAt = { x: 0, y: 0 }; - const cancelPress = () => { - if (press) clearTimeout(press); - press = null; - }; + 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 disp.view.addEventListener('pointerdown', (e) => { - const pe = e as PointerEvent; - if (pe.pointerType !== 'touch' || !pe.isPrimary) return; - 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) { - this.longPressedAt = Date.now(); - this.wantKeyboard(); - this.focusEditor(); - this.openClipboardMenu(pressAt.x, pressAt.y); - 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(); - this.openClipboardMenu(pressAt.x, pressAt.y); - }, 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(); + if ((e as PointerEvent).pointerType === 'touch') this.lastTouchAt = Date.now(); }); // 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 @@ -1356,10 +1314,11 @@ export class MathEditor implements ToolbarHost { } private longPressedAt = 0; + private lastTouchAt = 0; /** the page's selection changed right after a long press: the field keeps the focus and the input mode (keyboard) */ private keepKeyboard = (): void => { - if (!this.kbOn || !this.coarse() || Date.now() - this.longPressedAt > 3000) return; + if (!this.kbOn || !this.coarse() || Date.now() - this.lastTouchAt > 3000) return; const input = this.display.input; if (input.inputMode !== 'text') input.inputMode = 'text'; if (document.activeElement !== input) input.focus({ preventScroll: true }); @@ -1477,9 +1436,8 @@ export class MathEditor implements ToolbarHost { setTimeout(() => { const a = this.root?.ownerDocument.activeElement as HTMLElement | null; if (!a || a === this.root.ownerDocument.body) { - // a long press (selection, the menu) can make the browser drop the focus: the formula takes it back and the - // keyboard stays - if (Date.now() - this.longPressedAt < 2500 && this.display.input.isConnected) { + // a long press can make the browser drop the focus: the formula takes it back and the keyboard stays + if (Date.now() - this.lastTouchAt < 2500 && this.display.input.isConnected) { this.display.input.focus({ preventScroll: true }); return; } @@ -1562,19 +1520,12 @@ export class MathEditor implements ToolbarHost { } private pasteButton(): void { - this.pasteFromClipboard(null); + this.pasteFromClipboard(); } - /** - * Paste the clipboard. The browser asks for permission (a "Paste" bubble on phones) and only answers to a tap; when it - * does not allow reading, a field to paste into is offered – the native long-press menu of the field pastes. - */ - private pasteFromClipboard(at: { x: number; y: number } | null): void { - const fallback = () => this.openPasteField(at); - if (!navigator.clipboard?.readText) { - fallback(); - return; - } + /** Paste the clipboard (the browser asks for permission – a "Paste" bubble on phones – and only answers to a tap) */ + private pasteFromClipboard(): void { + if (!navigator.clipboard?.readText) return; navigator.clipboard .readText() .then((t) => { @@ -1582,123 +1533,8 @@ export class MathEditor implements ToolbarHost { this.focusEditor(); return this.pasteText(t); } - return fallback(); }) - .catch(fallback); - } - - private ctxMenu: HTMLElement | null = null; - private closeClipboardMenu(): void { - this.ctxMenu?.remove(); - this.ctxMenu = null; - } - - /** a small menu at the touch / click point: Cut, Copy, Paste, Select all */ - private ctxOpenedAt = 0; - private openClipboardMenu(clientX: number, clientY: number): void { - // the long press itself and the contextmenu event that follows it are one gesture: one menu - if (this.ctxMenu && Date.now() - this.ctxOpenedAt < 700) return; - this.ctxOpenedAt = Date.now(); - this.closeClipboardMenu(); - const sel = this.core.hasSelection(); - const act = (fn: () => void) => () => { - this.closeClipboardMenu(); - fn(); - this.wantKeyboard(); - this.focusEditor(); - }; - const items: [string, boolean, () => void][] = [ - ['Cut', sel, act(() => this.copySelection(true))], - ['Copy', sel, act(() => this.copySelection(false))], - ['Paste', true, () => { - this.closeClipboardMenu(); - this.wantKeyboard(); - this.pasteFromClipboard({ x: clientX, y: clientY }); - }], - ['Select all', true, act(() => this.core.selectAll())], - ]; - const menu = h('div', { class: 'wrs_ctxMenu', role: 'menu', 'aria-label': 'Edit' }); - for (const [label, enabled, fn] of items) { - const b = h('button', { type: 'button', role: 'menuitem', class: 'wrs_ctxItem', tabindex: '-1' }, label) as HTMLButtonElement; - b.disabled = !enabled; - // the formula keeps the focus (and the keyboard state) while the menu is used - b.addEventListener('mousedown', (e) => e.preventDefault()); - // the action runs when the finger lifts on the button (pointerup) – a tap that the page would otherwise have to - // wait for the browser to turn into a click – and on click (keyboard, assistive technology), once - let done = false; - const run = (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - if (done) return; - done = true; - fn(); - }; - b.addEventListener('pointerup', run); - b.addEventListener('click', run); - menu.append(b); - } - this.mountFloating(menu, clientX, clientY); - this.ctxMenu = menu; - const away = (e: Event) => { - if (menu.contains(e.target as Node)) return; - // the keyboard of a phone sends keys of its own (composition) when it comes up: only Escape closes - if (e.type === 'keydown' && (e as KeyboardEvent).key !== 'Escape') return; - this.closeClipboardMenu(); - document.removeEventListener('pointerdown', away, true); - document.removeEventListener('keydown', away, true); - }; - // not on the pointerdown that is still going on (the long press itself) - setTimeout(() => { - document.addEventListener('pointerdown', away, true); - document.addEventListener('keydown', away, true); - }, 0); - } - - /** a field to paste into, when the page may not read the clipboard (phones: its long-press menu has Paste) */ - private openPasteField(at: { x: number; y: number } | null): void { - this.closeClipboardMenu(); - const ta = h('textarea', { class: 'wrs_pasteField', rows: '2', placeholder: 'Paste here', 'aria-label': 'Paste here', dir: 'auto' }) as HTMLTextAreaElement; - const box = h('div', { class: 'wrs_ctxMenu wrs_pasteBox', role: 'dialog' }, ta); - const finish = () => { - const v = ta.value; - this.closeClipboardMenu(); - if (v.trim()) { - this.focusEditor(); - void this.pasteText(v); - } else this.focusEditor(); - }; - ta.addEventListener('paste', () => setTimeout(finish, 0)); - ta.addEventListener('input', () => { - if (ta.value.length > 0) setTimeout(finish, 0); - }); - ta.addEventListener('keydown', (e) => { - if ((e as KeyboardEvent).key === 'Escape') { - this.closeClipboardMenu(); - this.focusEditor(); - } - }); - const r = this.display.view.getBoundingClientRect(); - this.mountFloating(box, at ? at.x : r.left + 20, at ? at.y : r.top + 20); - this.ctxMenu = box; - ta.focus(); - } - - /** put `el` into the editor, near the client point and inside the window */ - private mountFloating(el: HTMLElement, clientX: number, clientY: number): void { - const root = this.root; - root.append(el); - const rb = root.getBoundingClientRect(); - el.style.position = 'absolute'; - el.style.visibility = 'hidden'; - const w = el.offsetWidth; - const hgt = el.offsetHeight; - let x = clientX - rb.left - w / 2; - let y = clientY - rb.top - hgt - 14; - if (y < 4) y = clientY - rb.top + 22; - x = Math.max(4, Math.min(x, rb.width - w - 4)); - el.style.left = `${Math.round(x)}px`; - el.style.top = `${Math.round(y)}px`; - el.style.visibility = ''; + .catch(() => this.focusEditor()); } private onPaste(e: ClipboardEvent): void { diff --git a/src/ui/i18n.ts b/src/ui/i18n.ts index 4a4baac..993a777 100644 --- a/src/ui/i18n.ts +++ b/src/ui/i18n.ts @@ -14,10 +14,6 @@ const OUTPUT_AR: Record = { 'Accessible text': 'نص قابل للوصول', 'Plain text': 'نص عادي', Copy: 'نسخ', - Cut: 'قص', - Paste: 'لصق', - 'Select all': 'تحديد الكل', - 'Paste here': 'الصق هنا', Download: 'تنزيل', Retry: 'إعادة المحاولة', Error: 'خطأ', diff --git a/src/ui/styles.ts b/src/ui/styles.ts index 83ad5df..b1f2609 100644 --- a/src/ui/styles.ts +++ b/src/ui/styles.ts @@ -77,12 +77,6 @@ export const CSS = ` .wrs_focusElement{position:absolute;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0;margin:0;background:transparent;color:transparent;caret-color:transparent;font-size:16px} .wrs_ariaLiveRegion{position:absolute;left:-9999px;top:0;width:1px;height:1px;overflow:hidden} .wrs_hiddenButDisplayed{clip-path:inset(50%)} -.wrs_ctxMenu{position:absolute;z-index:60;display:flex;flex-wrap:nowrap;width:max-content;max-width:calc(100vw - 8px);box-sizing:border-box;gap:2px;background:#fff;border:1px solid #6f8793;border-radius:8px;box-shadow:0 4px 16px rgba(30,50,60,.35);padding:6px;-webkit-user-select:none;user-select:none;touch-action:manipulation} -.wrs_ctxItem{flex:0 0 auto;min-width:56px;min-height:40px;padding:0 14px;white-space:nowrap;pointer-events:auto;border:0;border-radius:6px;background:transparent;color:#1f3b48;font:600 14px Arial,sans-serif;cursor:pointer} -.wrs_ctxItem:hover:not(:disabled),.wrs_ctxItem:active:not(:disabled){background:#dbe8ee} -.wrs_ctxItem:disabled{color:#9aaab3;cursor:default} -.wrs_pasteBox{padding:6px} -.wrs_pasteField{width:210px;max-width:60vw;box-sizing:border-box;border:1px solid #6f8793;border-radius:6px;padding:6px 8px;font:16px Arial,sans-serif;resize:none} .wrs_clipboardTextarea{position:absolute;left:-9999px;top:0;width:1px;height:1px;opacity:0} .wrs_scaled{height:calc(415px + (var(--wrs-k) - 1) * 97px)} .wrs_scaled .wrs_toolbar.wrs_main{zoom:var(--wrs-k)} From d9a61aa7f67a153684b8ae96796c6ea0200580b2 Mon Sep 17 00:00:00 2001 From: Mohammad-Erhim Date: Wed, 14 Oct 2026 12:01:58 +0300 Subject: [PATCH 3/3] fix(page): the page is exactly as high as the visible window on phones (browser bars, in-app browsers, keyboard) and never scrolls Co-Authored-By: Claude Sonnet 5.5 --- public/demo.js | 18 ++++++++++++++++++ public/page.css | 6 ++++-- tools/probe_height.cjs | 22 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 tools/probe_height.cjs diff --git a/public/demo.js b/public/demo.js index 0a0f326..f3426c6 100644 --- a/public/demo.js +++ b/public/demo.js @@ -7,6 +7,24 @@ opts.layout = 'classic'; document.documentElement.classList.add('classic'); } + // the page is exactly as high as what the user sees: browser bars, the bars of in-app browsers, the soft keyboard. + // (dvh does that in current browsers; the measured visual viewport is the answer where it does not) + if (opts.layout !== 'classic') { + var root = document.documentElement; + var vv = window.visualViewport; + var fit = function () { + var h = vv && vv.scale <= 1.02 ? vv.height : window.innerHeight; + if (h > 0) root.style.setProperty('--app-h', Math.round(h) + 'px'); + if (window.scrollY || window.scrollX) window.scrollTo(0, 0); + }; + fit(); + window.addEventListener('resize', fit); + window.addEventListener('orientationchange', fit); + if (vv) { + vv.addEventListener('resize', fit); + vv.addEventListener('scroll', fit); + } + } if (q.has('toolbarSize')) opts.toolbarSize = parseFloat(q.get('toolbarSize')); if (q.has('autoformat')) opts.autoformat = q.get('autoformat') === 'true'; // handwriting is switched off for now (?detectHand=true brings it back) diff --git a/public/page.css b/public/page.css index ebdadef..23e8659 100644 --- a/public/page.css +++ b/public/page.css @@ -3,13 +3,15 @@ html,body{height:100%;margin:0;padding:0;background:#fff;color:#18212f;font-fami .sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0} /* the page is exactly as high as the window: the editor takes what the output panel leaves, nothing scrolls, the output panel is always visible */ -.page{box-sizing:border-box;display:flex;flex-direction:column;width:100%;max-width:1200px;height:100vh;height:100dvh;min-height:380px;margin:0 auto;padding:12px max(12px,env(safe-area-inset-right)) max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left))} +.page{box-sizing:border-box;display:flex;flex-direction:column;width:100%;max-width:1200px;height:100vh;height:100dvh;height:var(--app-h,100dvh);min-height:380px;margin:0 auto;padding:12px max(12px,env(safe-area-inset-right)) max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left))} .wp-block-columns,.wp-block-column{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0} #editorContainer{flex:1 1 auto;min-height:0;width:100%;height:auto} #outputContainer{flex:none;width:100%} #outputContainer .mtx_output{max-width:none;margin-top:12px;height:clamp(110px,30vh,230px);min-height:110px} @media (max-width:724px),(max-height:480px){ - .page{padding:0} + /* phones: the page never scrolls (the editor and the output panel have their own scrolling) */ + html,body{overflow:hidden;overscroll-behavior:none} + .page{padding:0;min-height:0} #outputContainer .mtx_output{margin-top:0;border-radius:0;border-width:1px 0 0} } /* the export bar: only where the export section can be folded away (phones, low windows) */ diff --git a/tools/probe_height.cjs b/tools/probe_height.cjs new file mode 100644 index 0000000..2e62404 --- /dev/null +++ b/tools/probe_height.cjs @@ -0,0 +1,22 @@ +// does the page fit the window on phones (no page scroll, nothing cut off) for several window sizes? +const { chromium } = require('playwright'); +(async () => { + const b = await chromium.launch({ channel: 'chrome' }); + for (const [w, h] of [[390, 844], [360, 640], [412, 732], [844, 390], [320, 568], [390, 500], [390, 380], [390, 300]]) { + const ctx = await b.newContext({ viewport: { width: w, height: h }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 }); + const p = await ctx.newPage(); + await p.goto(process.env.URL || 'http://localhost:8099/', { waitUntil: 'load' }); + await p.waitForFunction(() => window.editor && document.querySelector('#editorContainer .wrs_toolbar button')); + await p.waitForTimeout(700); + const r = await p.evaluate(() => { + const se = document.scrollingElement, page = document.querySelector('.page'); + const pr = page.getBoundingClientRect(); + const exp = document.querySelector('.mtx_exportBar'); + const er = exp && exp.getBoundingClientRect(); + return { inner: innerHeight, vv: Math.round(visualViewport.height), docScroll: se.scrollHeight, bodyScroll: document.body.scrollHeight, pageBottom: Math.round(pr.bottom), exportBarBottom: er && Math.round(er.bottom), canScroll: se.scrollHeight > innerHeight + 1, overflowY: getComputedStyle(document.documentElement).overflowY + '/' + getComputedStyle(document.body).overflowY }; + }); + console.log(`${w}x${h}`.padEnd(9), JSON.stringify(r)); + await ctx.close(); + } + await b.close(); +})();