Editor keyboard: docked at the bottom, following the writing cursor #79

Merged
anas merged 1 commits from feature/floating-keyboard into main 2026-10-09 16:18:36 +00:00
2 changed files with 45 additions and 5 deletions

View File

@ -211,7 +211,8 @@ import { brand } from '../lib/brand';
kb.innerHTML = `<div>${L[0].map((c) => key(c)).join('')}</div><div>${L[1].map((c) => key(c)).join('')}</div>` + kb.innerHTML = `<div>${L[0].map((c) => key(c)).join('')}</div><div>${L[1].map((c) => key(c)).join('')}</div>` +
`<div><button type="button" data-act="shift" class="fn${shift ? ' on' : ''}" aria-label="شفٹ">⇧</button>${L[2].map((c) => key(c)).join('')}` + `<div><button type="button" data-act="shift" class="fn${shift ? ' on' : ''}" aria-label="شفٹ">⇧</button>${L[2].map((c) => key(c)).join('')}` +
'<button type="button" data-act="bs" class="fn" aria-label="مٹائیں">⌫</button></div>' + '<button type="button" data-act="bs" class="fn" aria-label="مٹائیں">⌫</button></div>' +
`<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}</div>`; `<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}` +
'<button type="button" data-act="close" class="fn" aria-label="کی بورڈ بند کریں" title="بند کریں">✕</button></div>';
}; };
render(); render();
// the Urdu keyboard and Roman typing work on the search box and on any input marked data-urdu // the Urdu keyboard and Roman typing work on the search box and on any input marked data-urdu
@ -232,6 +233,7 @@ import { brand } from '../lib/brand';
kb.onclick = (ev) => { kb.onclick = (ev) => {
const b = ev.target.closest('button'); if (!b) return; const b = ev.target.closest('button'); if (!b) return;
if (b.dataset.act === 'shift') { shift = !shift; render(); return; } if (b.dataset.act === 'shift') { shift = !shift; render(); return; }
if (b.dataset.act === 'close') { closeKb(); return; }
if (b.dataset.act === 'bs') { if (b.dataset.act === 'bs') {
live(); live();
if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; } if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; }
@ -244,7 +246,41 @@ import { brand } from '../lib/brand';
}; };
kbBtn.onclick = () => { kbBtn.onclick = () => {
kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus(); kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus();
if (kb.hidden) home(); else place();
}; };
// back to its place in the header (not floating)
const home = () => { kb.classList.remove('kb-float'); document.body.style.paddingBottom = ''; if (kb.parentElement !== kbHome[0]) kbHome[0].insertBefore(kb, kbHome[1]); };
const closeKb = () => {
kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); home();
document.querySelectorAll('[data-urdu-kb][aria-pressed=true]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
};
// For the content editor the keyboard is docked at the bottom of the window (like a phone's) and the page follows
// the writing cursor: the line being written is kept in view just above the keyboard, never under it. The page
// gets room at the bottom while it is open, so the last lines can come up above it.
const caret = () => {
const sel = getSelection();
if (sel?.rangeCount && target.contains(sel.anchorNode)) {
const r = sel.getRangeAt(0), rect = r.getClientRects()[0] ?? r.getBoundingClientRect();
if (rect && (rect.width || rect.height)) return rect;
const el = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
if (el) return el.getBoundingClientRect(); // an empty line has no caret box of its own
}
return null;
};
let placing = 0;
function place() {
if (kb.hidden || !kb.classList.contains('kb-float')) return;
cancelAnimationFrame(placing);
placing = requestAnimationFrame(() => {
document.body.style.paddingBottom = `${kb.offsetHeight + 24}px`;
const c = caret(); if (!c) return;
const room = kb.getBoundingClientRect().top - 16, top = 90; // keep the line between the header area and the keyboard
if (c.bottom > room) scrollBy({ top: c.bottom - room + 40, behavior: 'smooth' });
else if (c.top < top) scrollBy({ top: c.top - top - 40, behavior: 'smooth' });
});
}
document.addEventListener('selectionchange', place);
addEventListener('resize', place);
romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); }; romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); };
// other inputs can open the same keyboard / Roman typing with their own buttons // other inputs can open the same keyboard / Roman typing with their own buttons
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
@ -252,14 +288,14 @@ import { brand } from '../lib/brand';
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom); target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
// in an open dialog (the note box) the header can't be reached: the keyboard moves under the box, and back when it closes // in an open dialog (the note box) the header can't be reached: the keyboard moves under the box, and back when it closes
const box = b.dataset.urduKb && target.closest('dialog'); const box = b.dataset.urduKb && target.closest('dialog');
if (box) target.closest('.in-box-field')?.after(kb); else if (b.dataset.urduKb) kbHome[0].insertBefore(kb, kbHome[1]); if (box) { kb.classList.remove('kb-float'); target.closest('.in-box-field')?.after(kb); }
else if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating
else if (b.dataset.urduKb) home();
(b.dataset.urduKb ? kbBtn : romBtn).click(); (b.dataset.urduKb ? kbBtn : romBtn).click();
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed')); b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
}); });
document.addEventListener('close', (ev) => { // a dialog closing takes nothing of the header's with it document.addEventListener('close', (ev) => { // a dialog closing takes nothing of the header's with it
if (!ev.target.contains?.(kb)) return; if (ev.target.contains?.(kb)) closeKb();
kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); kbHome[0].insertBefore(kb, kbHome[1]);
ev.target.querySelectorAll('[data-urdu-kb]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
}, true); }, true);
document.addEventListener('keydown', (ev) => { document.addEventListener('keydown', (ev) => {
if (!ev.target.matches?.('[data-urdu]')) return; if (!ev.target.matches?.('[data-urdu]')) return;

View File

@ -249,6 +249,10 @@ h1 + .muted { text-align: center; margin-top: 0; }
.kb button { font-size: var(--fs-lg); min-width: 2.4em; padding: 2px 6px; background: var(--surface-raised); border-radius: var(--r-sm); } .kb button { font-size: var(--fs-lg); min-width: 2.4em; padding: 2px 6px; background: var(--surface-raised); border-radius: var(--r-sm); }
.kb .wide { min-width: 12em; } .kb .wide { min-width: 12em; }
.kb .fn { min-width: 3.4em; background: var(--surface-sunken); } .kb .fn { min-width: 3.4em; background: var(--surface-sunken); }
/* the content editor's keyboard: docked at the bottom of the window; the page keeps the writing line above it */
.kb.kb-float { position: fixed; z-index: 40; inset-inline: 0; bottom: 0; margin: 0 auto; max-width: min(720px, 100vw); padding: 6px 10px 12px;
background: var(--surface-raised); border: 1.5px solid var(--border); border-bottom: 0; border-radius: var(--r-lg) var(--r-lg) 0 0;
box-shadow: 0 -8px 28px rgb(0 0 0 / .18); }
/* word sidebar (Wiktionary): left side on wide screens, bottom sheet on phones */ /* word sidebar (Wiktionary): left side on wide screens, bottom sheet on phones */
.dict { position: fixed; top: 0; left: 0; bottom: 0; width: min(340px, 92vw); z-index: 20; overflow-y: auto; padding: 14px 18px 24px; .dict { position: fixed; top: 0; left: 0; bottom: 0; width: min(340px, 92vw); z-index: 20; overflow-y: auto; padding: 14px 18px 24px;