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 }]);
+ });
+});