Files
Steffen Becker c97a38ec77 feat: a Navigator pane lists the chapters and moves them
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.
2026-08-14 08:32:30 +02:00

69 lines
3.1 KiB
TypeScript

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