diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5397a..f3f15d6 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.19.0] - 2026-10-31 + +### Added +- Notizen am Handy und Tablet: Solange man in einem Punkt schreibt, sitzt direkt über der Bildschirmtastatur eine schmale Leiste mit Ausrücken, Einrücken, Nach oben und Nach unten (ersetzt Tab / Umschalt+Tab). Der Punkt nimmt seine Unterpunkte mit, bleibt beim Verschieben unter seinem Elternpunkt, und die Tastatur bleibt offen. Mit Maus oder Stift erscheint die Leiste nicht. + ## [0.18.1] - 2026-10-31 ### Fixed @@ -435,7 +440,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.18.1...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...dev +[0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0 [0.18.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.0...v0.18.1 [0.18.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.4...v0.18.0 [0.17.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.3...v0.17.4 diff --git a/README.md b/README.md index a95f261..b17cd37 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.18.1-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.19.0-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. @@ -66,7 +66,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn ### 📝 Workspace: Outliner-Notizen -* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste. +* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste; am Handy eine Leiste über der Tastatur zum Ein-/Ausrücken und Verschieben. * **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren; **Fokus-Modus** (`Strg+Umschalt+F`) zeigt nur die Notiz in Lesebreite, Ordner gleiten bei Bedarf von links herein. * **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar. * Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b23a34..508c0ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.18.1", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.18.1", + "version": "0.19.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index b130b03..314e944 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.18.1", + "version": "0.19.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 281323e..3bcb118 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -32,6 +32,7 @@ import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { Presence } from '../../lib/docSync/transport'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { extractHashtags } from '../../lib/hashtags'; +import { moveSibling } from '../../lib/outlineList'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; @@ -704,6 +705,16 @@ export const DocumentEditor: React.FC = ({ [docId, canEdit, applyLocalBlocks] ); + // Swaps a bullet (with its children) with the previous or next one on the same level, within the same + // parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice). + const handleMoveSibling = useCallback( + (blockId: string, direction: -1 | 1) => { + if (!canEdit) return; + applyLocalBlocks((prev) => moveSibling(prev, blockId, direction)); + }, + [canEdit, applyLocalBlocks] + ); + const handleMoveBlock = useCallback( (blockId: string, targetIndex: number, newLevel?: number) => { if (!canEdit) return; @@ -1241,6 +1252,7 @@ export const DocumentEditor: React.FC = ({ onInsertBlocks={handleInsertBlocks} onDeleteBlock={handleDeleteBlock} onMoveBlock={handleMoveBlock} + onMoveSibling={handleMoveSibling} onOpenTask={onOpenTask} onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} @@ -1338,6 +1350,7 @@ export const DocumentEditor: React.FC = ({ onInsertBlocks={handleInsertBlocks} onDeleteBlock={handleDeleteBlock} onMoveBlock={handleMoveBlock} + onMoveSibling={handleMoveSibling} onOpenTask={onOpenTask} onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} diff --git a/frontend/src/features/workspace/MobileOutlineBar.tsx b/frontend/src/features/workspace/MobileOutlineBar.tsx new file mode 100644 index 0000000..ba13ccc --- /dev/null +++ b/frontend/src/features/workspace/MobileOutlineBar.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +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). +export const isTouchOnly = + typeof window !== 'undefined' && + !!window.matchMedia?.('(hover: none) and (pointer: coarse)').matches && + !window.matchMedia?.('(any-pointer: fine)').matches; + +interface MobileOutlineBarProps { + onOutdent: () => void; + onIndent: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +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 button = (label: string, icon: React.ReactNode, action: () => void) => ( + + ); + + return createPortal( +
+ {button('Ausrücken', , onOutdent)} + {button('Einrücken', , onIndent)} + + {button('Nach oben', , onMoveUp)} + {button('Nach unten', , onMoveDown)} +
, + document.body + ); +}; diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 100ccd1..5c9ec98 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -3,6 +3,8 @@ import { DocumentBlock, BlockType } from '../../lib/types'; import { OutlinerBlock } from './OutlinerBlock'; import { parsePastedLines } from '../../lib/pasteOutline'; import { numberedLabels } from '../../lib/outlineList'; +import { flushSync } from 'react-dom'; +import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar'; export interface OutlinerTreeHandle { focusFirstBlock: () => void; @@ -24,6 +26,8 @@ export interface OutlinerTreeProps { onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void; onDeleteBlock: (blockId: string) => void; onMoveBlock: (blockId: string, newIndex: number, level?: number) => void; + /** Swap a bullet (with children) with its previous / next sibling (phone bar ↑ ↓) */ + onMoveSibling?: (blockId: string, direction: -1 | 1) => void; onOpenTask?: (taskId: string) => void; onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string, blockId?: string) => void; @@ -51,6 +55,7 @@ export const OutlinerTree = forwardRef(({ onInsertBlocks, onDeleteBlock, onMoveBlock, + onMoveSibling, onOpenTask, onTagClick, onWikiLinkClick, @@ -612,6 +617,39 @@ export const OutlinerTree = forwardRef(({ [blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction] ); + // Phone bar: runs the action at once and puts the focus back into the same bullet within the same tap + // (React may move the field in the DOM, which drops the focus; iOS reopens the keyboard only inside a tap) + const runOnFocused = (action: (blockId: string) => void) => { + const id = focusedBlockId; + if (!id) return; + const holder = lockHolder(id); + if (holder) { + onLockedAction?.(holder); + return; + } + const before = textareaMapRef.current.get(id); + const caret = before ? before.selectionStart : null; + flushSync(() => action(id)); + const el = textareaMapRef.current.get(id); + if (!el) return; + if (document.activeElement !== el) { + el.focus({ preventScroll: true }); + if (caret !== null) { + try { + el.setSelectionRange(caret, caret); + } catch { + // ignore + } + } + } + // Keep the bullet visible above the bar (about 56 px) and the keyboard + const vv = window.visualViewport; + const visibleBottom = (vv ? vv.height + vv.offsetTop : window.innerHeight) - 64; + const rect = el.getBoundingClientRect(); + if (rect.bottom > visibleBottom || rect.top < 0) el.scrollIntoView({ block: 'center' }); + }; + const showMobileBar = isTouchOnly && canEdit && !!focusedBlockId && blocks.some((b) => b.id === focusedBlockId); + useEffect(() => { const clearDrag = () => { setDraggedIndex(null); @@ -707,6 +745,14 @@ export const OutlinerTree = forwardRef(({ onDrop={() => handleDropBlock(blocks.length)} /> )} + {showMobileBar && ( + runOnFocused((id) => handleIndentBlock(id, -1))} + onIndent={() => runOnFocused((id) => handleIndentBlock(id, 1))} + onMoveUp={() => runOnFocused((id) => onMoveSibling?.(id, -1))} + onMoveDown={() => runOnFocused((id) => onMoveSibling?.(id, 1))} + /> + )} ); }); diff --git a/frontend/src/lib/__tests__/outlineList.test.ts b/frontend/src/lib/__tests__/outlineList.test.ts index 4230088..c8cf959 100644 --- a/frontend/src/lib/__tests__/outlineList.test.ts +++ b/frontend/src/lib/__tests__/outlineList.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { listLabel, numberedLabels } from '../outlineList'; +import { listLabel, moveSibling, numberedLabels } from '../outlineList'; import type { DocumentBlock } from '../types'; const blk = (id: string, level: number, type: DocumentBlock['type'] = 'numbered'): DocumentBlock => ({ @@ -25,3 +25,33 @@ describe('numbered lists', () => { }); }); }); + +describe('moveSibling (phone bar ↑ ↓)', () => { + // A + // a1 + // a2 + // a2x + // B + // b1 + // C + const tree = [blk('A', 0), blk('a1', 1), blk('a2', 1), blk('a2x', 2), blk('B', 0), blk('b1', 1), blk('C', 0)]; + const ids = (bs: DocumentBlock[]) => bs.map((b) => b.id).join(' '); + + it('swaps with the previous sibling and takes the children along', () => { + expect(ids(moveSibling(tree, 'B', -1))).toBe('B b1 A a1 a2 a2x C'); + expect(ids(moveSibling(tree, 'a2', -1))).toBe('A a2 a2x a1 B b1 C'); + }); + + it('swaps with the next sibling, jumping over its whole branch', () => { + expect(ids(moveSibling(tree, 'A', 1))).toBe('B b1 A a1 a2 a2x C'); + expect(ids(moveSibling(tree, 'a1', 1))).toBe('A a2 a2x a1 B b1 C'); + }); + + it('never leaves its parent and stops at the ends', () => { + expect(moveSibling(tree, 'a1', -1)).toBe(tree); // first child + expect(moveSibling(tree, 'a2', 1)).toBe(tree); // last child + expect(moveSibling(tree, 'A', -1)).toBe(tree); + expect(moveSibling(tree, 'C', 1)).toBe(tree); + expect(moveSibling(tree, 'missing', 1)).toBe(tree); + }); +}); diff --git a/frontend/src/lib/outlineList.ts b/frontend/src/lib/outlineList.ts index f18aa8e..f86e872 100644 --- a/frontend/src/lib/outlineList.ts +++ b/frontend/src/lib/outlineList.ts @@ -63,3 +63,30 @@ export function numberedLabels(blocks: DocumentBlock[]): Map { } return labels; } + +// Swaps a bullet (with its children) with the previous (-1) or next (+1) bullet on the same level +// under the same parent. Returns the same array when there is no such sibling. +export function moveSibling>(blocks: T[], blockId: string, direction: -1 | 1): T[] { + const lvl = (b: T) => Math.max(0, b.level || 0); + const subtreeEnd = (start: number) => { + let end = start + 1; + while (end < blocks.length && lvl(blocks[end]) > lvl(blocks[start])) end++; + return end; + }; + const idx = blocks.findIndex((b) => b.id === blockId); + if (idx === -1) return blocks; + const level = lvl(blocks[idx]); + const end = subtreeEnd(idx); + if (direction === -1) { + for (let i = idx - 1; i >= 0; i--) { + if (lvl(blocks[i]) < level) return blocks; // reached the parent: already the first child + if (lvl(blocks[i]) === level) { + return [...blocks.slice(0, i), ...blocks.slice(idx, end), ...blocks.slice(i, idx), ...blocks.slice(end)]; + } + } + return blocks; + } + if (end >= blocks.length || lvl(blocks[end]) !== level) return blocks; // already the last child + const nextEnd = subtreeEnd(end); + return [...blocks.slice(0, idx), ...blocks.slice(end, nextEnd), ...blocks.slice(idx, end), ...blocks.slice(nextEnd)]; +}