feat(workspace): bar above the phone keyboard to indent, outdent and move bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32b79b5d62
commit
c6a2cfb85c
9 files changed
+183
-7
No files matched your search
+7
-1
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.18.1] - 2026-10-31
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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.
|
- 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.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.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
|
[0.17.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.3...v0.17.4
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
**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
|
### 📝 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.
|
* **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.
|
* **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`.
|
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.18.1",
|
"version": "0.19.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.18.1",
|
"version": "0.19.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.1",
|
"version": "0.19.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
|||||||
import { Presence } from '../../lib/docSync/transport';
|
import { Presence } from '../../lib/docSync/transport';
|
||||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
import { moveSibling } from '../../lib/outlineList';
|
||||||
|
|
||||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
|
||||||
@@ -704,6 +705,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, canEdit, applyLocalBlocks]
|
[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(
|
const handleMoveBlock = useCallback(
|
||||||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
@@ -1241,6 +1252,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
|
onMoveSibling={handleMoveSibling}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
@@ -1338,6 +1350,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
|
onMoveSibling={handleMoveSibling}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
|||||||
@@ -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<MobileOutlineBarProps> = ({ 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) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
// Keep the focus (and the keyboard) in the bullet: no focus change on press, act on click
|
||||||
|
onPointerDown={(e) => e.preventDefault()}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={action}
|
||||||
|
className="h-10 flex-1 max-w-[4.5rem] flex items-center justify-center rounded-btn border border-border bg-canvas text-main active:bg-panel-elevated touch-manipulation"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed left-0 right-0 z-[70] flex items-center justify-center gap-2 px-3 py-1.5 bg-panel/95 backdrop-blur-md border-t border-border"
|
||||||
|
style={{ bottom: inset }}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Punkt bearbeiten"
|
||||||
|
>
|
||||||
|
{button('Ausrücken', <IndentDecrease className="w-5 h-5" />, onOutdent)}
|
||||||
|
{button('Einrücken', <IndentIncrease className="w-5 h-5" />, onIndent)}
|
||||||
|
<span className="w-px h-6 bg-border mx-1" aria-hidden />
|
||||||
|
{button('Nach oben', <ArrowUp className="w-5 h-5" />, onMoveUp)}
|
||||||
|
{button('Nach unten', <ArrowDown className="w-5 h-5" />, onMoveDown)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ import { DocumentBlock, BlockType } from '../../lib/types';
|
|||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock } from './OutlinerBlock';
|
||||||
import { parsePastedLines } from '../../lib/pasteOutline';
|
import { parsePastedLines } from '../../lib/pasteOutline';
|
||||||
import { numberedLabels } from '../../lib/outlineList';
|
import { numberedLabels } from '../../lib/outlineList';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar';
|
||||||
|
|
||||||
export interface OutlinerTreeHandle {
|
export interface OutlinerTreeHandle {
|
||||||
focusFirstBlock: () => void;
|
focusFirstBlock: () => void;
|
||||||
@@ -24,6 +26,8 @@ export interface OutlinerTreeProps {
|
|||||||
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string) => void;
|
||||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => 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;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||||
@@ -51,6 +55,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onInsertBlocks,
|
onInsertBlocks,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onMoveBlock,
|
onMoveBlock,
|
||||||
|
onMoveSibling,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
@@ -612,6 +617,39 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
|
[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(() => {
|
useEffect(() => {
|
||||||
const clearDrag = () => {
|
const clearDrag = () => {
|
||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
@@ -707,6 +745,14 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onDrop={() => handleDropBlock(blocks.length)}
|
onDrop={() => handleDropBlock(blocks.length)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showMobileBar && (
|
||||||
|
<MobileOutlineBar
|
||||||
|
onOutdent={() => runOnFocused((id) => handleIndentBlock(id, -1))}
|
||||||
|
onIndent={() => runOnFocused((id) => handleIndentBlock(id, 1))}
|
||||||
|
onMoveUp={() => runOnFocused((id) => onMoveSibling?.(id, -1))}
|
||||||
|
onMoveDown={() => runOnFocused((id) => onMoveSibling?.(id, 1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { listLabel, numberedLabels } from '../outlineList';
|
import { listLabel, moveSibling, numberedLabels } from '../outlineList';
|
||||||
import type { DocumentBlock } from '../types';
|
import type { DocumentBlock } from '../types';
|
||||||
|
|
||||||
const blk = (id: string, level: number, type: DocumentBlock['type'] = 'numbered'): DocumentBlock => ({
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,3 +63,30 @@ export function numberedLabels(blocks: DocumentBlock[]): Map<string, string> {
|
|||||||
}
|
}
|
||||||
return labels;
|
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<T extends Pick<DocumentBlock, 'id' | 'level'>>(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)];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user