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: 18 additions & 0 deletions public/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions public/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
192 changes: 17 additions & 175 deletions src/ui/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,66 +419,17 @@ 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<typeof setTimeout> | 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();
if ((e as PointerEvent).pointerType === 'touch') this.lastTouchAt = Date.now();
});
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
disp.view.addEventListener('focus', () => {
if (this.coarse() && this.kbOn) input.focus({ preventScroll: true });
});
// 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));
document.addEventListener('selectionchange', this.keepKeyboard);
disp.view.addEventListener('selectstart', (e) => e.preventDefault());
// keyboard
Expand Down Expand Up @@ -1363,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 });
Expand Down Expand Up @@ -1484,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;
}
Expand Down Expand Up @@ -1569,130 +1520,21 @@ 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) => {
if (t && t.trim()) {
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 openClipboardMenu(clientX: number, clientY: number): void {
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());
b.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
fn();
});
menu.append(b);
}
this.mountFloating(menu, clientX, clientY);
this.ctxMenu = menu;
const away = (e: Event) => {
if (menu.contains(e.target as Node)) 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 {
Expand Down
4 changes: 0 additions & 4 deletions src/ui/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ const OUTPUT_AR: Record<string, string> = {
'Accessible text': 'نص قابل للوصول',
'Plain text': 'نص عادي',
Copy: 'نسخ',
Cut: 'قص',
Paste: 'لصق',
'Select all': 'تحديد الكل',
'Paste here': 'الصق هنا',
Download: 'تنزيل',
Retry: 'إعادة المحاولة',
Error: 'خطأ',
Expand Down
6 changes: 0 additions & 6 deletions src/ui/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
22 changes: 22 additions & 0 deletions tools/probe_height.cjs
Original file line number Diff line number Diff line change
@@ -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();
})();
Loading