From 6a62df9ee032b93673d7404095107117ab4a7893 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 18:15:26 +0200 Subject: [PATCH] Editor keyboard: docked at the bottom of the window and the page keeps the writing line above it; a close key; search keeps the header keyboard Co-Authored-By: Claude Opus 5.5 --- web/src/layouts/Base.astro | 46 +++++++++++++++++++++++++++++++++----- web/src/styles/global.css | 4 ++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index dd437d43..17a74a44 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -211,7 +211,8 @@ import { brand } from '../lib/brand'; kb.innerHTML = `
${L[0].map((c) => key(c)).join('')}
${L[1].map((c) => key(c)).join('')}
` + `
${L[2].map((c) => key(c)).join('')}` + '
' + - `
${key('،')}${key('۔')}${key('؟')}
`; + `
${key('،')}${key('۔')}${key('؟')}` + + '
'; }; render(); // 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) => { const b = ev.target.closest('button'); if (!b) return; if (b.dataset.act === 'shift') { shift = !shift; render(); return; } + if (b.dataset.act === 'close') { closeKb(); return; } if (b.dataset.act === 'bs') { live(); if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; } @@ -244,7 +246,41 @@ import { brand } from '../lib/brand'; }; kbBtn.onclick = () => { 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(); }; // other inputs can open the same keyboard / Roman typing with their own buttons 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); // 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'); - 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.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 - if (!ev.target.contains?.(kb)) return; - 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')); + if (ev.target.contains?.(kb)) closeKb(); }, true); document.addEventListener('keydown', (ev) => { if (!ev.target.matches?.('[data-urdu]')) return; diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 35e7e6ba..d0f01a6a 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -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 .wide { min-width: 12em; } .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 */ .dict { position: fixed; top: 0; left: 0; bottom: 0; width: min(340px, 92vw); z-index: 20; overflow-y: auto; padding: 14px 18px 24px;