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:
1 parent
49e485038e
commit
c97a38ec77
14 files changed
+450
-4
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user