From c97a38ec77b95e0926bdf88d152a263705115a0b Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 08:32:30 +0200 Subject: [PATCH] feat: a Navigator pane lists the chapters and moves them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F5 (LibreOffice's key) opens an outline of the document's headings: click one to jump, and move it up or down or promote/demote its level, each carrying everything under the heading. Below it the tables, pictures and bookmarks LibreOffice's Navigator also lists. A move swaps with the adjacent chapter at the same level and stops at a heading above it, so a subheading never reparents itself. The pane docks right beside the comments one — the classic chrome's Tools chip owns the left edge. --- CHANGELOG.md | 1 + src/App.svelte | 10 + src/lib/components/CLAUDE.md | 8 + src/lib/components/NavigatorPane.svelte | 186 ++++++++++++++++++ src/lib/components/ToolbarExpanded.svelte | 15 +- src/lib/components/ribbon/Ribbon.svelte | 5 +- src/lib/components/ribbon/tabs/ViewTab.svelte | 5 + src/lib/editor/extensions.ts | 6 +- src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/outline.ts | 119 +++++++++++ src/lib/editor/shortcuts.ts | 2 + src/lib/i18n/locales/de.ts | 14 ++ src/lib/i18n/locales/en.ts | 14 ++ tests/unit/outline.test.ts | 68 +++++++ 14 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/NavigatorPane.svelte create mode 100644 src/lib/editor/extensions/outline.ts create mode 100644 tests/unit/outline.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e4acb..ca3febb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,7 @@ - Right-click context menu for text: cut / copy / paste / paste without formatting, link insert-edit-remove, clear formatting — with the spelling suggestions merged in on top. Shift+right-click keeps the browser's own menu; images, text boxes and header/footer stay with their existing UI - Spell check in English and German with squiggles and suggestions in the context menu (add / ignore word); selectable document language. Powered by Hunspell (WASM), so German compound words (Fußgänger, Krankenversicherung …) are recognised; dictionaries are lazy-loaded per language on demand - Word / character count statistics (whole document and selection) +- Navigator pane (F5, or View ▸ Navigator): the document's chapters as an outline, click one to jump to it and the caret's own chapter stays marked. Beside each, LibreOffice's four chapter operations — move up / down and promote / demote a level, each carrying everything under the heading with it. A move swaps with the adjacent chapter at the same level and never crosses a heading above it, so a subheading stays in its own chapter. Below the outline, the tables, pictures and bookmarks LibreOffice's Navigator also lists, as jump targets **UI & theming** - Light / Dark / AllBlack / Auto appearance modes (settings menu in toolbar) diff --git a/src/App.svelte b/src/App.svelte index 7e53980..52c5b0a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -48,6 +48,7 @@ import AboutDialog from './lib/components/AboutDialog.svelte'; import DocPropertiesDialog from './lib/components/DocPropertiesDialog.svelte'; import CommentsPane from './lib/components/CommentsPane.svelte'; + import NavigatorPane from './lib/components/NavigatorPane.svelte'; import { OPEN_COMMENT_EVENT } from './lib/editor/extensions/comment'; import AutoCorrectDialog from './lib/components/AutoCorrectDialog.svelte'; import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte'; @@ -165,6 +166,7 @@ let lineNumbering: LineNumbering = $state(loadLineNumbering()); let autoCorrectOpen = $state(false); let commentsOpen = $state(false); + let navigatorOpen = $state(false); // Width of the hidden mirror span (below), so the title input grows/shrinks // with its text instead of sitting in a fixed-width box. @@ -889,6 +891,7 @@ [DEFAULT_SHORTCUTS.findNext, () => (findOpen ? editor?.commands.findNext() : openFind('find'))], [DEFAULT_SHORTCUTS.findPrevious, () => (findOpen ? editor?.commands.findPrevious() : openFind('find'))], [DEFAULT_SHORTCUTS.formattingMarks, () => (showFormattingMarks = !showFormattingMarks)], + [DEFAULT_SHORTCUTS.navigator, () => (navigatorOpen = !navigatorOpen)], [DEFAULT_SHORTCUTS.zoomIn, () => setZoom(zoom + 10)], [DEFAULT_SHORTCUTS.zoomOut, () => setZoom(zoom - 10)], [DEFAULT_SHORTCUTS.zoomReset, () => setZoom(100)], @@ -1014,6 +1017,8 @@ onNewComment={addComment} {commentsOpen} onToggleComments={() => (commentsOpen = !commentsOpen)} + {navigatorOpen} + onToggleNavigator={() => (navigatorOpen = !navigatorOpen)} /> {:else} @@ -1241,6 +1246,8 @@ {#if toolbarExpanded}
(navigatorOpen = !navigatorOpen)} editor={activeEditor} tick={activeTick} bind:showFormattingMarks @@ -1312,6 +1319,9 @@ orientation={pageOrientation} {pageFormat} /> + {#if navigatorOpen} + (navigatorOpen = false)} /> + {/if} {#if commentsOpen} (commentsOpen = false)} /> {/if} diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 0eefd90..0f8a5bd 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -74,6 +74,14 @@ range, edit / resolve / remove in place. `App.svelte` owns the New-comment promp both the Review tab and the context menu (`OPEN_COMMENT_EVENT`) fire; the author comes from the document properties. +## Navigator (`NavigatorPane.svelte`, `editor/extensions/outline.ts`) + +LibreOffice's Navigator and Word's Navigation pane: the outline, click to jump, and the +four chapter operations on hover. Docked right beside `CommentsPane`, not left where both +reference products put it — the classic chrome's floating "Tools" chip lives there. The +toolbar island overlays the row's top, so the pane takes a `margin-top` of +`--toolbar-overlay-h` (`App.svelte` sets it; 0 for the ribbon, which is in flow). + ## Page decoration (`PageDecorLayer.svelte`, `storage/pageDecor.ts`) Background, border and watermark, all page-level. The layer sits inside the scaled diff --git a/src/lib/components/NavigatorPane.svelte b/src/lib/components/NavigatorPane.svelte new file mode 100644 index 0000000..326e97a --- /dev/null +++ b/src/lib/components/NavigatorPane.svelte @@ -0,0 +1,186 @@ + + + + + diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index ca7f22f..a9fda24 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -32,8 +32,8 @@ import { t } from '../i18n/i18n.svelte'; import { shortcutHint, type ShortcutId } from '../editor/shortcuts'; - let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }: - { editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props(); + let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions, navigatorOpen = false, onToggleNavigator }: + { editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void; navigatorOpen?: boolean; onToggleNavigator?: () => void } = $props(); const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; @@ -1586,6 +1586,17 @@ +
diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 8a8f651..331dd06 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -63,6 +63,7 @@ docxBusy = false, pdfBusy = false, onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments, + navigatorOpen = false, onToggleNavigator, recentFiles = [], onOpenRecent, onForgetRecent, }: { editor: Editor | null; @@ -113,6 +114,8 @@ onNewComment?: () => void; commentsOpen?: boolean; onToggleComments?: () => void; + navigatorOpen?: boolean; + onToggleNavigator?: () => void; } = $props(); const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const; @@ -329,7 +332,7 @@ {:else if tab === 'review'} {:else if tab === 'view'} - + {:else if tab === 'tableDesign' || tab === 'tableLayout'} {:else if tab === 'pictureFormat' || tab === 'shapeFormat'} diff --git a/src/lib/components/ribbon/tabs/ViewTab.svelte b/src/lib/components/ribbon/tabs/ViewTab.svelte index 3570332..158812d 100644 --- a/src/lib/components/ribbon/tabs/ViewTab.svelte +++ b/src/lib/components/ribbon/tabs/ViewTab.svelte @@ -11,16 +11,21 @@ zoom = 100, onZoom, onDebugDump, + navigatorOpen = false, + onToggleNavigator, }: { showRuler?: boolean; showFormattingMarks?: boolean; zoom?: number; onZoom?: (value: number) => void; onDebugDump?: () => void; + navigatorOpen?: boolean; + onToggleNavigator?: () => void; } = $props(); + onToggleNavigator?.()} />
(showRuler = !showRuler)} /> (showFormattingMarks = !showFormattingMarks)} /> diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 401293d..b75c5f5 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -58,10 +58,11 @@ import { TextBox } from './extensions/textBox'; import { Columns } from './extensions/columns'; import { ColumnsFlow } from './extensions/columnsFlow'; import { TableOfContents } from './extensions/tableOfContents'; +import { Outline } from './extensions/outline'; import { Note, NoteRef, NoteSection, Notes } from './extensions/notes'; import { Shortcuts } from './extensions/shortcuts'; import { AutoCorrect } from './extensions/autoCorrect'; -import { HEADING_LEVELS } from '../export/odt'; +import { HEADING_LEVELS, MAX_HEADING_LEVEL } from '../export/odt'; import { styleSheet } from '../styles/sheet.svelte'; import { noteSettings } from '../storage/notes.svelte'; import { recordChanges } from '../storage/trackChanges.svelte'; @@ -127,6 +128,9 @@ export const extensions = [ // Generated table of contents from headings; round-trips to ODF // (export/odt.ts, import/odt.ts) and a DOCX TOC field. TableOfContents, + // The document's chapters for the Navigator pane, plus LibreOffice's move and + // promote/demote of a whole chapter. + Outline.configure({ maxLevel: MAX_HEADING_LEVEL }), // Footnotes and endnotes: the anchor rides the text, the note text lives in the one // noteSection at the document end. pageBreaks.ts lifts a footnote to the foot of its // anchor's page. Round-trips to ODF and DOCX word/footnotes.xml. diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 145cf7b..c3547d1 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -35,6 +35,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one. - **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement. - **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu. +- **`outline.ts`** — the document's chapters for `NavigatorPane.svelte`: every **top-level** heading owning the blocks up to the next heading of its level or above (one inside a cell or a text box is not a chapter — moving it would move it out of its container). The two operations are pure functions the commands only apply: `chapterMove` returns the range to lift and where to put it back — the insert position for a move down is already corrected for the delete — and `chapterLevels` the headings a promote/demote rewrites, null when any of the subtree would leave 1…`maxLevel`. A move swaps with the adjacent chapter at the **same** level and stops at a heading above it, so a subheading never reparents itself; the level shift rewrites `styleName` too, or the paragraph would keep the old heading's look. - **`tableOfContents.ts`** — a generated index. The `index` attr picks the family: `toc` lists every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none. `figures`/`tables` instead list every caption paragraph counting that category (`caption.ts`), single-level as LibreOffice's are — ODF `text:illustration-index`/`text:table-index` (found by `text:caption-sequence-name`, not by outline level), Word the same TOC field with `\c "Figure"`. - **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go). - **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table. diff --git a/src/lib/editor/extensions/outline.ts b/src/lib/editor/extensions/outline.ts new file mode 100644 index 0000000..55cb68f --- /dev/null +++ b/src/lib/editor/extensions/outline.ts @@ -0,0 +1,119 @@ +import { Extension } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { headingStyleName } from '../../styles/styleSheet'; + +// The document's chapters, as LibreOffice's Navigator and Word's Navigation pane show +// them: every top-level heading, each owning the blocks up to the next heading of its +// own level or higher. A heading inside a cell or a text box is not a chapter — moving +// one would mean moving it out of its container — so only the body's own blocks count. + +export type OutlineEntry = { + /** Start of the heading node. */ + pos: number; + /** End of the chapter: the next heading at this level or above, else the body's end. */ + end: number; + level: number; + text: string; +}; + +export function outline(doc: PMNode): OutlineEntry[] { + const out: OutlineEntry[] = []; + doc.forEach((node, offset) => { + if (node.type.name !== 'heading') return; + out.push({ + pos: offset, + end: doc.content.size, + level: Math.max(1, Number(node.attrs.level) || 1), + text: node.textContent, + }); + }); + // A chapter ends where the next one at its level or above begins. + for (let i = 0; i < out.length; i++) { + const next = out.slice(i + 1).find((e) => e.level <= out[i].level); + if (next) out[i].end = next.pos; + } + return out; +} + +/** + * Where a chapter move lands: the range to lift out and the position to put it back + * at. Moving up lands before the previous sibling, whose position the delete leaves + * alone; moving down lands after the next one, past content the delete has shifted. + * + * The sibling is the adjacent chapter at the **same** level, and the search stops at a + * heading above it: a subheading moves inside its own chapter and never reparents + * itself by being moved. null = nothing to swap with, so the button does nothing. + */ +export function chapterMove(list: OutlineEntry[], pos: number, dir: -1 | 1): { from: number; to: number; at: number } | null { + const i = list.findIndex((e) => e.pos === pos); + if (i < 0) return null; + const me = list[i]; + const search = dir < 0 ? [...list.slice(0, i)].reverse() : list.slice(i + 1); + let sibling: OutlineEntry | undefined; + for (const e of search) { + if (e.level < me.level) break; + if (e.level === me.level) { sibling = e; break; } + } + if (!sibling) return null; + return { from: me.pos, to: me.end, at: dir < 0 ? sibling.pos : sibling.end - (me.end - me.pos) }; +} + +/** + * The headings a promote/demote of the chapter at `pos` rewrites, with their new + * levels — the heading and everything under it, as LibreOffice moves a whole chapter. + * null when any of them would leave the 1…max range, so the subtree stays intact. + */ +export function chapterLevels(list: OutlineEntry[], pos: number, delta: -1 | 1, max: number): { pos: number; level: number }[] | null { + const me = list.find((e) => e.pos === pos); + if (!me) return null; + const inside = list.filter((e) => e.pos >= me.pos && e.pos < me.end); + if (inside.some((e) => e.level + delta < 1 || e.level + delta > max)) return null; + return inside.map((e) => ({ pos: e.pos, level: e.level + delta })); +} + +declare module '@tiptap/core' { + interface Commands { + outline: { + /** Move the chapter starting at `pos` past its previous (-1) or next (+1) sibling. */ + moveChapter: (pos: number, dir: -1 | 1) => ReturnType; + /** Promote (-1) or demote (+1) the chapter's heading and its subheadings. */ + shiftChapterLevel: (pos: number, delta: -1 | 1) => ReturnType; + }; + } +} + +export const Outline = Extension.create<{ maxLevel: number }>({ + name: 'outline', + + addOptions() { + return { maxLevel: 6 }; + }, + + addCommands() { + return { + moveChapter: (pos, dir) => ({ state, tr, dispatch }) => { + const plan = chapterMove(outline(state.doc), pos, dir); + if (!plan) return false; + if (!dispatch) return true; + const slice = state.doc.slice(plan.from, plan.to); + tr.delete(plan.from, plan.to); + tr.insert(plan.at, slice.content); + return true; + }, + + shiftChapterLevel: (pos, delta) => ({ state, tr, dispatch }) => { + const plan = chapterLevels(outline(state.doc), pos, delta, this.options.maxLevel); + if (!plan) return false; + if (!dispatch) return true; + for (const { pos: at, level } of plan) { + const node = state.doc.nodeAt(at); + if (!node) continue; + // The style follows the level: a heading's own is what both word processors + // switch, and the level alone would leave the paragraph looking unchanged. + tr.setNodeMarkup(at, undefined, { ...node.attrs, level, styleName: headingStyleName(level) }); + } + return true; + }, + }; + }, +}); diff --git a/src/lib/editor/shortcuts.ts b/src/lib/editor/shortcuts.ts index 1a76d3c..d8d18a0 100644 --- a/src/lib/editor/shortcuts.ts +++ b/src/lib/editor/shortcuts.ts @@ -53,6 +53,8 @@ export const DEFAULT_SHORTCUTS = { findNext: 'F3', findPrevious: 'Shift-F3', formattingMarks: 'Mod-F10', + // LibreOffice's Navigator key; Word has none for its Navigation pane. + navigator: 'F5', zoomIn: 'Mod-+', zoomOut: 'Mod--', zoomReset: 'Mod-0', diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 75b922c..e435ced 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -40,6 +40,20 @@ const de: Messages = { urls: '', }, }, + navigator: { + title: 'Navigator', + empty: 'Noch keine Überschriften. Gib einem Absatz eine Überschriftenvorlage, dann erscheint er hier.', + untitled: '(ohne Titel)', + moveUp: 'Kapitel nach oben verschieben', + moveDown: 'Kapitel nach unten verschieben', + promote: 'Ebene höherstufen', + demote: 'Ebene tieferstufen', + tables: 'Tabellen', + images: 'Bilder', + bookmarks: 'Lesezeichen', + tableN: (n) => `Tabelle ${n}`, + imageN: (n) => `Bild ${n}`, + }, comments: { title: 'Kommentare', empty: 'Noch keine Kommentare. Text markieren und einen hinzufügen.', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index f486a99..c0447a4 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -41,6 +41,20 @@ const en = { urls: '', }, }, + navigator: { + title: 'Navigator', + empty: 'No headings yet. Give a paragraph a heading style and it shows up here.', + untitled: '(untitled)', + moveUp: 'Move chapter up', + moveDown: 'Move chapter down', + promote: 'Promote level', + demote: 'Demote level', + tables: 'Tables', + images: 'Pictures', + bookmarks: 'Bookmarks', + tableN: (n: number) => `Table ${n}`, + imageN: (n: number) => `Picture ${n}`, + }, comments: { title: 'Comments', empty: 'No comments yet. Select some text and add one.', diff --git a/tests/unit/outline.test.ts b/tests/unit/outline.test.ts new file mode 100644 index 0000000..34f6e4e --- /dev/null +++ b/tests/unit/outline.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { getSchema } from '@tiptap/core'; +import { Node as PMNode } from '@tiptap/pm/model'; +import { extensions } from '../../src/lib/editor/extensions'; +import { outline, chapterMove, chapterLevels } from '../../src/lib/editor/extensions/outline'; + +const schema = getSchema(extensions); + +const h = (level: number, text: string) => ({ type: 'heading', attrs: { level }, content: [{ type: 'text', text }] }); +const p = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] }); + +// "A" owning "A.1", then "B": the shape every chapter operation has to get right. +const doc = () => PMNode.fromJSON(schema, { + type: 'doc', + content: [h(1, 'A'), p('a text'), h(2, 'A.1'), p('a1 text'), h(1, 'B'), p('b text')], +}); + +// The move as the command performs it, so the test exercises the real positions. +function move(node: PMNode, index: number, dir: -1 | 1): string | null { + const plan = chapterMove(outline(node), outline(node)[index].pos, dir); + if (!plan) return null; + const slice = node.slice(plan.from, plan.to); + const cut = node.replace(plan.from, plan.to, PMNode.fromJSON(schema, { type: 'doc' }).slice(0, 0)); + const out = cut.replace(plan.at, plan.at, slice); + const parts: string[] = []; + out.forEach((n) => parts.push(n.textContent)); + return parts.join('|'); +} + +describe('outline', () => { + it('gives each chapter the blocks up to the next heading of its level or above', () => { + const d = doc(); + const list = outline(d); + expect(list.map((e) => `${e.level}:${e.text}`)).toEqual(['1:A', '2:A.1', '1:B']); + // A owns everything up to B; A.1 only its own paragraph. + expect(d.slice(list[0].pos, list[0].end).content.childCount).toBe(4); + expect(d.slice(list[1].pos, list[1].end).content.childCount).toBe(2); + expect(list[2].end).toBe(d.content.size); + }); + + it('moves a chapter with everything under it', () => { + expect(move(doc(), 2, -1)).toBe('B|b text|A|a text|A.1|a1 text'); + expect(move(doc(), 0, 1)).toBe('B|b text|A|a text|A.1|a1 text'); + }); + + it('has no sibling to swap with at the ends, or alone at its level', () => { + expect(move(doc(), 0, -1)).toBeNull(); + expect(move(doc(), 2, 1)).toBeNull(); + // A.1 is the only level-2 chapter, and a move never leaves its own level. + expect(move(doc(), 1, 1)).toBeNull(); + }); + + it('promotes and demotes the whole subtree', () => { + const list = outline(doc()); + expect(chapterLevels(list, list[0].pos, 1, 6)).toEqual([ + { pos: list[0].pos, level: 2 }, { pos: list[1].pos, level: 3 }, + ]); + // Only the chapter itself, when nothing is under it. + expect(chapterLevels(list, list[2].pos, 1, 6)).toEqual([{ pos: list[2].pos, level: 2 }]); + }); + + it('refuses a shift that would take any of the subtree out of range', () => { + const list = outline(doc()); + expect(chapterLevels(list, list[0].pos, -1, 6)).toBeNull(); // A is already level 1 + expect(chapterLevels(list, list[0].pos, 1, 2)).toBeNull(); // A.1 would reach 3 + expect(chapterLevels(list, list[1].pos, -1, 6)).toEqual([{ pos: list[1].pos, level: 1 }]); + }); +});