fix(mobile): keep the markdown toolbar visible while editing

Tapping a line in markdown mode hid the B/I/U/S toolbar for good. The
viewport handler concluded the keyboard had gone away when
innerHeight - visualViewport.height < 80 and asked to hide it, but iOS
home-screen apps shrink innerHeight together with the visual viewport,
so an open keyboard looked closed (seen in /diag: 483 vs 409, 409 vs
409). The rich editor's blur handler could do the same 200ms after a
switch to markdown, and nothing ever re-showed the toolbar.

setMobileToolbarVisible now ignores hide requests in markdown mode; only
a locked vault hides it. Unit tests for the function, and a mobile
Playwright test that shrinks the window as the keyboard does (it fails
on the previous build). Bumps ASSET_VERSION and the service worker
cache.
This commit is contained in:
igor 2026-10-13 13:53:03 +13:00
parent b511f7b685
commit 54c4c46ad3
6 changed files with 113 additions and 3 deletions

View file

@ -659,6 +659,7 @@ These screens are shown/hidden by inline JS in `layoutPage()` using class change
- Mobile note creation uses dedicated fragment endpoints and server headers to drive the next UI step
- The floating action button is only a mobile affordance; desktop should stay unaffected
- FAB visibility should follow screen state directly (`folders` / `notes` visible, `editor` hidden), not only htmx swap side effects
- In markdown mode the mobile formatting toolbar (`#editor-toolbar`) is permanent: `setMobileToolbarVisible(false)` is ignored there (only a locked vault hides it). Callers ask to hide it when the viewport handler thinks the keyboard went away, which is wrong on iOS home-screen apps (`innerHeight` shrinks with the visual viewport, so an open keyboard looks closed), and from the rich editor's blur handler after a switch to markdown. Do not reintroduce `innerHeight - visualViewport.height` keyboard guesses; see `/diag` for real-device numbers. Covered by `tests/mobileToolbarVisibility.test.js` and `playwright-tests/mobile-toolbar-keyboard.spec.js`.
- The markdown editor's word-count / heading-outline bar (`.cm-jl-status`, `mdStatusBar` setting) is desktop-only: `_cmExtraExtensions` passes `statusBar: _mdStatusBar && !isMobileShellMode()`, so it is never created in the mobile shell (it costs a row under the editor and recounts words on every keystroke). The Outline jump-to-heading popup lives in that bar, so it is not available on mobile either. Covered by `tests/mobileStatusBar.test.js` and `playwright-tests/md-extras.spec.js`.
- `#mobile-folders-body` / `#mobile-notes-body` carry `padding-bottom: calc(96px + env(safe-area-inset-bottom))` so the last row (or the notes list's "Load more" button) scrolls clear of the FAB (56px + 20px offset) and the home indicator. Scope it to those two ids: `#mobile-editor-body` shares `.mobile-screen-body` and must not get it. Covered by `playwright-tests/mobile-list-clearance.spec.js`.
- Mobile folder rows can include a vault lock button and it must stay inline with the row actions

View file

@ -10,7 +10,7 @@ const {
} = require('./shared');
const { noteMetaFragment } = require('./fragments');
const ASSET_VERSION = '20261012nosb';
const ASSET_VERSION = '20261013tbkeep';
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';

View file

@ -0,0 +1,41 @@
'use strict';
const { test, expect } = require('@playwright/test');
const { ensureMobileFoldersScreen, login, openMobileFolder } = require('./helpers');
// In markdown mode the mobile formatting toolbar (B I U S H3 ...) is shown when a note opens and
// must stay while the soft keyboard is up. It used to disappear the moment a line was tapped:
// the viewport handler decided "keyboard gone" when innerHeight - visualViewport.height < 80,
// and iOS home-screen apps shrink innerHeight together with the visual viewport, so an open
// keyboard looked closed. Chromium reproduces that by shrinking the window, which resizes both.
// Read-only: opens a note and focuses the editor, types nothing.
test.describe('Mobile markdown toolbar and the keyboard', () => {
test('the toolbar stays visible when the keyboard opens and closes', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
await login(page);
await ensureMobileFoldersScreen(page);
await openMobileFolder(page, 'All Notes');
await page.locator('#mobile-notes-body .mobile-note-row').first().click();
await expect(page.locator('#mobile-editor-screen.mobile-screen-active')).toBeVisible();
const content = page.locator('#mobile-editor-body #cm-host .cm-content');
const toolbar = page.locator('#mobile-editor-body #editor-toolbar');
// the editor mounts a moment after the screen switches; a note that opens rendered has no #cm-host
const markdown = await content.waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false);
test.skip(!markdown, 'note opened in rendered mode, not markdown');
await expect(toolbar).toBeVisible();
const size = page.viewportSize();
await content.click();
// the keyboard: both innerHeight and the visual viewport shrink, as in an iOS home-screen app
await page.setViewportSize({ width: size.width, height: Math.round(size.height * 0.55) });
await page.waitForTimeout(700);
await expect(toolbar, 'toolbar while the keyboard is up').toBeVisible();
expect(await toolbar.evaluate(el => getComputedStyle(el).display)).not.toBe('none');
await page.setViewportSize(size);
await page.waitForTimeout(700);
await expect(toolbar, 'toolbar after the keyboard closes').toBeVisible();
});
});

View file

@ -5950,8 +5950,14 @@ function confirmLogout(event){
positionTinyMCEHost();
return;
}
// Markdown mode: this is the formatting bar (B I U S H3 ...). It is up when a note opens and
// stays up while editing, so "hide" requests are ignored: the viewport handler asks for one
// when it thinks the keyboard went away (wrong on iOS home-screen apps, where innerHeight
// shrinks together with the visual viewport, so an OPEN keyboard looks closed), and the rich
// editor's blur handler asks for one 200ms after a switch to markdown. Only a locked vault hides it.
var form=activeEditorForm();
if(form&&form.dataset.encrypted==='1'&&form.dataset.vaultUnlocked!=='1')show=false;
var locked=!!(form&&form.dataset.encrypted==='1'&&form.dataset.vaultUnlocked!=='1');
show=!locked;
tb.style.display=show?'flex':'none';
// Reposition immediately, then again after a tick so the toolbar has a
// rendered height before positionTinyMCEHost measures the anchor bottom.

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'joplock-shell-v88-20261012nosb';
const CACHE_NAME = 'joplock-shell-v89-20261013tbkeep';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/cm-extras.js', '/prism.min.js', '/hljs.min.js', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png', '/maskable-icon-192.png', '/maskable-icon-512.png', '/apple-touch-icon.png', '/fonts/CascadiaMono-Regular.woff2', '/fonts/CascadiaMono-Bold.woff2'];
self.addEventListener('install', event => {

View file

@ -0,0 +1,62 @@
'use strict';
// setMobileToolbarVisible(false) is called when the viewport handler thinks the keyboard went
// away and 200ms after the rich editor blurs. In markdown mode that used to hide the formatting
// toolbar for good (nothing re-showed it), so it vanished the moment a line was tapped. It must
// ignore "hide" in markdown mode; only a locked vault hides it.
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in app.js`);
let depth = 0;
for (let i = start; i < appSrc.length; i++) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
}
throw new Error(`no closing brace for ${name}`);
}
function run({ mode, show, form }) {
const tb = { style: { display: '' } };
let repositioned = 0;
const ctx = vm.createContext({
_editorMode: mode,
mobileEditorToolbar: () => tb,
activeEditorForm: () => form || null,
positionTinyMCEHost: () => { repositioned++; },
setTimeout: fn => fn(),
});
vm.runInContext(`${extractFn('setMobileToolbarVisible')}; setMobileToolbarVisible(${show});`, ctx);
return { display: tb.style.display, repositioned };
}
test('markdown mode keeps the toolbar when asked to hide it', () => {
for (const mode of ['markdown', 'md']) {
assert.equal(run({ mode, show: false }).display, 'flex');
assert.equal(run({ mode, show: true }).display, 'flex');
}
});
test('a locked vault hides the markdown toolbar whatever is asked', () => {
const locked = { dataset: { encrypted: '1', vaultUnlocked: '0' } };
assert.equal(run({ mode: 'markdown', show: true, form: locked }).display, 'none');
assert.equal(run({ mode: 'markdown', show: false, form: locked }).display, 'none');
const unlocked = { dataset: { encrypted: '1', vaultUnlocked: '1' } };
assert.equal(run({ mode: 'markdown', show: false, form: unlocked }).display, 'flex');
});
test('rich mode leaves the toolbar to CSS (TinyMCE has its own) and just repositions the host', () => {
for (const show of [true, false]) {
const r = run({ mode: 'rich', show });
assert.equal(r.display, '');
assert.equal(r.repositioned, 1);
}
});