From 59825635791557ee593a140911cfdca163ff5b48 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sat, 31 Oct 2026 21:18:05 +0100 Subject: [PATCH] fix(mobile): outline bar only while the keyboard is open The indent/move bar above the keyboard stayed after the keyboard was closed (back button) because the bullet kept its focus, and covered the bottom navigation. useKeyboardInset now reports keyboardOpen by comparing the visible height with the height without keyboard, which works whether the browser shrinks the page or only the visual viewport. Release v0.27.1. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++- README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../features/workspace/MobileOutlineBar.tsx | 7 +++- .../lib/__tests__/useKeyboardInset.test.tsx | 20 +++++++++ frontend/src/lib/useKeyboardInset.ts | 41 ++++++++++++++++--- 7 files changed, 71 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06af852..68990fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.27.1] - 2026-10-31 + +### Fixed +- Notizen am Handy: Die Leiste zum Ein- und Ausrücken über der Tastatur blieb stehen, wenn man die Tastatur schloss (z. B. mit der Zurück-Taste), und verdeckte die untere Navigation. Sie erscheint jetzt nur, solange die Tastatur offen ist; ein Tipp in den Punkt holt beides zurück. + ## [0.27.0] - 2026-10-31 ### Changed @@ -539,7 +544,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.1...dev +[0.27.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...v0.27.1 [0.27.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.1...v0.27.0 [0.26.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.0...v0.26.1 [0.26.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.1...v0.26.0 diff --git a/README.md b/README.md index 38ca075..171401a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.27.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.27.1-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 33a1dda..361e31c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.27.0", + "version": "0.27.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.27.0", + "version": "0.27.1", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 507c144..d1720e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.27.0", + "version": "0.27.1", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/workspace/MobileOutlineBar.tsx b/frontend/src/features/workspace/MobileOutlineBar.tsx index ba13ccc..7bf43b8 100644 --- a/frontend/src/features/workspace/MobileOutlineBar.tsx +++ b/frontend/src/features/workspace/MobileOutlineBar.tsx @@ -4,7 +4,7 @@ import { ArrowDown, ArrowUp, IndentDecrease, IndentIncrease } from 'lucide-react import { useKeyboardInset } from '../../lib/useKeyboardInset'; // Phones and tablets have no Tab key: while a bullet is being edited, a slim bar sits right above the -// on-screen keyboard with outdent / indent and move up / down (the bullet takes its children along). +// on-screen keyboard (only while it is open) with outdent / indent and move up / down (the bullet takes its children along). export const isTouchOnly = typeof window !== 'undefined' && !!window.matchMedia?.('(hover: none) and (pointer: coarse)').matches && @@ -19,7 +19,7 @@ interface MobileOutlineBarProps { export const MobileOutlineBar: React.FC = ({ onOutdent, onIndent, onMoveUp, onMoveDown }) => { // iOS keeps fixed elements behind the keyboard: lift the bar by the keyboard's height - const { inset } = useKeyboardInset(); + const { inset, keyboardOpen } = useKeyboardInset(); const button = (label: string, icon: React.ReactNode, action: () => void) => ( ); + // Keyboard closed (e.g. back button) while the bullet keeps its focus: no bar over the bottom navigation + if (!keyboardOpen) return null; + return createPortal(
{ const vv = mockVisualViewport(844); const { result } = renderHook(() => useKeyboardInset()); expect(result.current.inset).toBe(0); + expect(result.current.keyboardOpen).toBe(false); act(() => vv.open(336)); expect(result.current.inset).toBe(336); expect(result.current.visibleHeight).toBe(508); + expect(result.current.keyboardOpen).toBe(true); act(() => vv.open(0)); expect(result.current.inset).toBe(0); + expect(result.current.keyboardOpen).toBe(false); + }); + + it('notices the keyboard also when the page shrinks instead (no inset)', () => { + const vv = mockVisualViewport(844); + const { result } = renderHook(() => useKeyboardInset()); + act(() => { + Object.defineProperty(window, 'innerHeight', { value: 508, configurable: true }); + vv.open(336); + }); + expect(result.current.inset).toBe(0); + expect(result.current.keyboardOpen).toBe(true); + // Browser bars appearing are not a keyboard + act(() => { + Object.defineProperty(window, 'innerHeight', { value: 788, configurable: true }); + vv.open(56); + }); + expect(result.current.keyboardOpen).toBe(false); }); }); diff --git a/frontend/src/lib/useKeyboardInset.ts b/frontend/src/lib/useKeyboardInset.ts index 074f5f6..ae3eeba 100644 --- a/frontend/src/lib/useKeyboardInset.ts +++ b/frontend/src/lib/useKeyboardInset.ts @@ -1,13 +1,42 @@ import { useEffect, useState } from 'react'; -// Space the on-screen keyboard takes at the bottom (0 without keyboard) and the height that stays -// visible. Fixed bottom sheets use it to sit right above the keyboard instead of behind it (iOS keeps -// fixed elements on the layout viewport; on Android the inset is usually 0 because the page shrinks). -export function useKeyboardInset(): { inset: number; visibleHeight: number } { +// Tallest visible height seen at the current width, i.e. the height without keyboard. Tracked from app +// start, so it is known before the first field is focused; a new width (rotation) starts over. +let fullHeight = 0; +let fullWidth = 0; +function noteFullHeight() { + const vv = window.visualViewport; + if (!vv) return; + if (window.innerWidth !== fullWidth) { + fullWidth = window.innerWidth; + fullHeight = 0; + } + fullHeight = Math.max(fullHeight, Math.round(vv.height)); +} +if (typeof window !== 'undefined' && window.visualViewport) { + noteFullHeight(); + window.visualViewport.addEventListener('resize', noteFullHeight); +} + +// More than browser bars appearing or disappearing (about 60 px) +const KEYBOARD_MIN_PX = 150; + +// Space the on-screen keyboard takes at the bottom (0 without keyboard), the height that stays visible +// and whether a keyboard is open. Fixed bottom sheets use the inset to sit right above the keyboard +// instead of behind it (iOS keeps fixed elements on the layout viewport; on Android the inset is usually +// 0 because the page shrinks). keyboardOpen compares with the height without keyboard, so it works in +// both cases; without visualViewport it is unknown and reported as open. +export function useKeyboardInset(): { inset: number; visibleHeight: number; keyboardOpen: boolean } { const read = () => { const vv = typeof window !== 'undefined' ? window.visualViewport : null; - if (!vv) return { inset: 0, visibleHeight: typeof window !== 'undefined' ? window.innerHeight : 0 }; - return { inset: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)), visibleHeight: Math.round(vv.height) }; + if (!vv) return { inset: 0, visibleHeight: typeof window !== 'undefined' ? window.innerHeight : 0, keyboardOpen: true }; + noteFullHeight(); + const visibleHeight = Math.round(vv.height); + return { + inset: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)), + visibleHeight, + keyboardOpen: visibleHeight < fullHeight - KEYBOARD_MIN_PX, + }; }; const [state, setState] = useState(read);