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.
This commit is contained in:
Steffen Becker committed 2026-08-14 08:32:30 +02:00
1 parent 49e485038e
commit c97a38ec77
14 files changed
+450 -4

No files matched your search

+1
View File
@@ -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)
+10
View File
@@ -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)}
/>
</div>
{:else}
@@ -1241,6 +1246,8 @@
{#if toolbarExpanded}
<div class="extended-wrap" transition:expand>
<ToolbarExpanded
{navigatorOpen}
onToggleNavigator={() => (navigatorOpen = !navigatorOpen)}
editor={activeEditor}
tick={activeTick}
bind:showFormattingMarks
@@ -1312,6 +1319,9 @@
orientation={pageOrientation}
{pageFormat}
/>
{#if navigatorOpen}
<NavigatorPane {editor} {tick} onClose={() => (navigatorOpen = false)} />
{/if}
{#if commentsOpen}
<CommentsPane {editor} {tick} author={docProps.author} onClose={() => (commentsOpen = false)} />
{/if}
+8
View File
@@ -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
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import { outline, type OutlineEntry } from '../editor/extensions/outline';
import { bookmarks } from '../editor/extensions/bookmark';
import { t } from '../i18n/i18n.svelte';
// LibreOffice's Navigator and Word's Navigation pane: the chapters of the document,
// click to jump, and the four chapter operations beside them. Below them the other
// things LibreOffice lists — tables, pictures, bookmarks — as plain jump targets.
let { editor, tick, onClose }: {
editor: Editor | null;
tick: number;
onClose: () => void;
} = $props();
let chapters = $derived.by<OutlineEntry[]>(() => (tick < 0 || !editor ? [] : outline(editor.state.doc)));
type Target = { pos: number; label: string };
// Tables and pictures anywhere in the document, in document order.
let objects = $derived.by<{ tables: Target[]; images: Target[]; marks: Target[] }>(() => {
const tables: Target[] = [];
const images: Target[] = [];
if (tick < 0 || !editor) return { tables, images, marks: [] };
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'table') {
tables.push({ pos, label: t().navigator.tableN(tables.length + 1) });
} else if (node.type.name === 'image') {
images.push({ pos, label: String(node.attrs.alt || t().navigator.imageN(images.length + 1)) });
}
return true;
});
return { tables, images, marks: bookmarks(editor.state.doc).map((b) => ({ pos: b.from, label: b.name })) };
});
function jumpTo(pos: number) {
if (!editor) return;
const { state, view } = editor;
const at = Math.min(Math.max(pos, 0), state.doc.content.size);
view.dispatch(state.tr.setSelection(TextSelection.near(state.doc.resolve(at))).scrollIntoView());
view.focus();
}
// The caret's own chapter, so the pane shows where the reader is.
let currentPos = $derived.by<number | null>(() => {
if (tick < 0 || !editor) return null;
const head = editor.state.selection.head;
let found: number | null = null;
for (const c of chapters) if (c.pos <= head) found = c.pos;
return found;
});
const move = (c: OutlineEntry, dir: -1 | 1) => editor?.chain().focus().moveChapter(c.pos, dir).run();
const shift = (c: OutlineEntry, d: -1 | 1) => editor?.chain().focus().shiftChapterLevel(c.pos, d).run();
</script>
<aside class="navigator-pane" aria-label={t().navigator.title}>
<header>
<span class="title">{t().navigator.title}</span>
<button class="close" onclick={onClose} aria-label={t().common.close} title={t().common.close}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</header>
{#if !chapters.length}
<p class="empty">{t().navigator.empty}</p>
{:else}
<ul class="chapters">
{#each chapters as c (c.pos)}
<li style="padding-left: {4 + (c.level - 1) * 12}px" class:current={c.pos === currentPos}>
<button class="jump" onclick={() => jumpTo(c.pos)} title={c.text}>
{c.text || t().navigator.untitled}
</button>
<span class="ops">
<button onclick={() => move(c, -1)} title={t().navigator.moveUp} aria-label={t().navigator.moveUp}>↑</button>
<button onclick={() => move(c, 1)} title={t().navigator.moveDown} aria-label={t().navigator.moveDown}>↓</button>
<button onclick={() => shift(c, -1)} title={t().navigator.promote} aria-label={t().navigator.promote}>←</button>
<button onclick={() => shift(c, 1)} title={t().navigator.demote} aria-label={t().navigator.demote}>→</button>
</span>
</li>
{/each}
</ul>
{/if}
{#each [
{ label: t().navigator.tables, items: objects.tables },
{ label: t().navigator.images, items: objects.images },
{ label: t().navigator.bookmarks, items: objects.marks },
] as group}
{#if group.items.length}
<div class="group">{group.label}</div>
<ul>
{#each group.items as o (o.pos)}
<li><button class="jump" onclick={() => jumpTo(o.pos)} title={o.label}>{o.label}</button></li>
{/each}
</ul>
{/if}
{/each}
</aside>
<style>
.navigator-pane {
display: flex;
flex-direction: column;
flex: 0 0 240px;
min-width: 0;
overflow-y: auto;
border-left: 1px solid var(--color-border);
background: var(--color-surface);
font-family: var(--font-sans);
font-size: 0.8rem;
/* The classic chrome's toolbar island overlays the top of the row; a margin, not
padding, or the sticky header would scroll up under it. The ribbon sets the
variable to 0, being in flow. */
margin-top: var(--toolbar-overlay-h, 0px);
}
header {
display: flex;
align-items: center;
gap: 6px;
position: sticky;
top: 0;
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.title { flex: 1; font-weight: 600; }
.empty { padding: 12px 10px; color: var(--color-text-muted); }
.group {
padding: 8px 10px 2px;
color: var(--color-text-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
ul { list-style: none; margin: 0; padding: 0; }
li {
display: flex;
align-items: center;
gap: 2px;
padding: 1px 4px 1px 4px;
}
li.current { background: var(--color-btn-hover); }
button {
border: none;
border-radius: var(--radius);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.jump {
flex: 1;
min-width: 0;
padding: 3px 4px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.jump:hover { background: var(--color-btn-hover); }
/* The chapter operations stay out of the way until the row is worth acting on. */
.ops { display: none; gap: 0; }
li:hover .ops, li.current .ops { display: flex; }
.ops button {
padding: 1px 3px;
color: var(--color-text-muted);
line-height: 1;
}
.ops button:hover { background: var(--color-btn-hover); color: var(--color-text); }
.close {
padding: 2px 6px;
color: var(--color-text-muted);
}
.close:hover { background: var(--color-btn-hover); color: var(--color-text); }
</style>
+13 -2
View File
@@ -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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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 @@
<path d="M4.75 5.25v2M7 5.25v3M9.25 5.25v2M11.5 5.25v3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</button>
<button
class:active={navigatorOpen}
onclick={() => onToggleNavigator?.()}
title={`${t().navigator.title} (${shortcutHint('navigator')})`}
aria-pressed={navigatorOpen}
>
<!-- Outline: three headings, each with its rule -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2.25 3.5h11.5M4.25 6.75h9.5M6.25 10h7.5M6.25 13.25h7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="toolbar-separator"></div>
+4 -1
View File
@@ -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'}
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} {onNewComment} {commentsOpen} {onToggleComments} />
{:else if tab === 'view'}
<ViewTab bind:showRuler bind:showFormattingMarks {zoom} {onZoom} {onDebugDump} />
<ViewTab bind:showRuler bind:showFormattingMarks {zoom} {onZoom} {onDebugDump} {navigatorOpen} {onToggleNavigator} />
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
<TableTabs {editor} {tick} which={tab === 'tableDesign' ? 'design' : 'layout'} />
{:else if tab === 'pictureFormat' || tab === 'shapeFormat'}
@@ -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();
</script>
<RibbonGroup label={t().ribbon.groups.show}>
<RibbonButton variant="big" icon="toc" label={t().navigator.title} title={`${t().navigator.title} (${shortcutHint('navigator')})`} active={navigatorOpen} onclick={() => onToggleNavigator?.()} />
<div class="rb-col">
<RibbonButton variant="small" icon="ruler" label={t().ruler.show} active={showRuler} onclick={() => (showRuler = !showRuler)} />
<RibbonButton variant="small" icon="pilcrow" label={t().toolbarExpanded.formattingMarks} title={`${t().toolbarExpanded.formattingMarks} (${shortcutHint('formattingMarks')})`} active={showFormattingMarks} onclick={() => (showFormattingMarks = !showFormattingMarks)} />
+5 -1
View File
@@ -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
// <text:table-of-content> (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 <text:note> and DOCX word/footnotes.xml.
+1
View File
@@ -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.
+119
View File
@@ -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<ReturnType> {
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;
},
};
},
});
+2
View File
@@ -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',
+14
View File
@@ -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.',
+14
View File
@@ -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.',
+68
View File
@@ -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 }]);
});
});