feat: the Review tab picks how much markup is shown and where

One button per kind shows or hides it and its caret says where its cards go —
balloons or the pane, which is why the two pane toggles are gone. Display for
review carries Word's four modes beside them, and the accept / reject / walk
pairs sit under each kind. The PDF path takes the mode as page attributes, so
a printout of a page in no markup reads as the page does.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:03 +02:00
1 parent 01b8a97404
commit 4b66631b56
9 files changed
+209 -51

No files matched your search

+9 -12
View File
@@ -46,6 +46,7 @@
import { loadLineNumbering, saveLineNumbering, DEFAULT_LINE_NUMBERING, type LineNumbering } from './lib/storage/lineNumbering';
import { loadFoldMarks, saveFoldMarks } from './lib/storage/foldMarks';
import { printMarkup } from './lib/storage/printMarkup.svelte';
import { commentsInPane, changesInPane, markupAttrs, setShowChanges, setShowComments } from './lib/storage/markup.svelte';
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
import { setTableLanguage } from './lib/storage/tableOptions.svelte';
import { spellController } from './lib/spell/controller';
@@ -192,8 +193,6 @@
let autoCorrectOpen = $state(false);
let autoTextOpen = $state(false);
let thesaurusOpen = $state(false);
let commentsOpen = $state(false);
let revisionsOpen = $state(false);
let navigatorOpen = $state(false);
// Width of the hidden mirror span (below), so the title input grows/shrinks
@@ -749,7 +748,8 @@
const text = prompt(t().comments.prompt, '');
if (text === null) return;
editor.chain().focus().addComment({ author: docProps.author.trim(), text: text.trim() }).run();
commentsOpen = true;
// A comment nobody can see is worse than none: the new one brings its kind back.
setShowComments(true);
}
$effect(() => {
@@ -1003,6 +1003,7 @@
differentOddEven,
commentLabels: { heading: t().comments.title, onPage: t().comments.onPage },
printMarkup: printMarkup(),
markupAttrs: markupAttrs(),
});
} catch (err) {
console.error('[pdf] Print failed:', err);
@@ -1184,10 +1185,6 @@
onAutoCorrect={() => (autoCorrectOpen = true)}
onAutoText={() => (autoTextOpen = true)}
onNewComment={addComment}
{commentsOpen}
onToggleComments={() => (commentsOpen = !commentsOpen)}
{revisionsOpen}
onToggleRevisions={() => (revisionsOpen = !revisionsOpen)}
{navigatorOpen}
onToggleNavigator={() => (navigatorOpen = !navigatorOpen)}
/>
@@ -1513,13 +1510,13 @@
{#if navigatorOpen}
<NavigatorPane {editor} {tick} onClose={() => (navigatorOpen = false)} />
{/if}
{#if commentsOpen}
<CommentsPane {editor} {tick} author={docProps.author} onClose={() => (commentsOpen = false)} />
{#if commentsInPane()}
<CommentsPane {editor} {tick} author={docProps.author} onClose={() => setShowComments(false)} />
{/if}
{#if revisionsOpen}
<RevisionsPane {editor} {tick} author={docProps.author} onClose={() => (revisionsOpen = false)} />
{#if changesInPane()}
<RevisionsPane {editor} {tick} author={docProps.author} onClose={() => setShowChanges(false)} />
{/if}
{#if commentsOpen || revisionsOpen}
{#if commentsInPane() || changesInPane()}
<ConnectorLayer {editor} {tick} />
{/if}
</div>
+2 -6
View File
@@ -68,7 +68,7 @@
onSelectTheme,
docxBusy = false,
pdfBusy = false,
onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onAutoText, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions,
onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onAutoText, onNewComment,
navigatorOpen = false, onToggleNavigator,
recentFiles = [], onOpenRecent, onForgetRecent,
}: {
@@ -125,10 +125,6 @@
onAutoCorrect?: () => void;
onAutoText?: () => void;
onNewComment?: () => void;
commentsOpen?: boolean;
onToggleComments?: () => void;
revisionsOpen?: boolean;
onToggleRevisions?: () => void;
navigatorOpen?: boolean;
onToggleNavigator?: () => void;
} = $props();
@@ -360,7 +356,7 @@
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} {onNewComment} {commentsOpen} {onToggleComments} {revisionsOpen} {onToggleRevisions} />
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} {onNewComment} />
{:else if tab === 'view'}
<ViewTab bind:showRuler bind:showFormattingMarks bind:splitView bind:pageColumns {zoom} {onZoom} {onDebugDump} {navigatorOpen} {onToggleNavigator} />
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
+1
View File
@@ -11,6 +11,7 @@ const DOC_OUTLINE = [
export const STROKED = {
chevronDown: ['M3.5 6 8 10.5 12.5 6'],
chevronRight: ['M6 3.5 10.5 8 6 12.5'],
chevronLeft: ['M10 3.5 5.5 8 10 12.5'],
check: ['M3.5 8.5 6.5 11.5 12.5 5'],
doc: DOC_OUTLINE,
newDoc: [...DOC_OUTLINE, 'M8 8v3.5M6.25 9.75h3.5'],
+149 -30
View File
@@ -2,32 +2,61 @@
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import Icon from '../Icon.svelte';
import LanguagePicker from '../../LanguagePicker.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu } from '../menu.svelte';
import { captionClicks, anchored, clickOutside, closeMenu, isMenuOpen, toggleMenu } from '../menu.svelte';
import { countText, type TextStats } from '../../../utils/wordCount';
import type { DocumentLanguage } from '../../../storage/documentLanguage';
import { recordChanges, setRecordChanges } from '../../../storage/trackChanges.svelte';
import { printMarkup, setPrintMarkup } from '../../../storage/printMarkup.svelte';
import {
MARKUP_MODES, MARKUP_PLACES, markupMode, setMarkupMode,
showChanges, setShowChanges, showComments, setShowComments,
commentPlace, setCommentPlace, changePlace, setChangePlace,
type MarkupMode, type MarkupPlace,
} from '../../../storage/markup.svelte';
import { revisions } from '../../../editor/extensions/trackChanges';
import { comments } from '../../../editor/extensions/comment';
import { selectRange, step, visibleComments, visibleRevisions } from '../../reviewItems';
import { OPEN_THESAURUS_EVENT } from '../../../spell/thesaurus';
import { shortcutHint } from '../../../editor/shortcuts';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick, documentLanguage, onLanguage, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions }: {
let { editor, tick, documentLanguage, onLanguage, onAutoCorrect, onNewComment }: {
editor: Editor | null;
tick: number;
documentLanguage: DocumentLanguage;
onLanguage: (code: DocumentLanguage) => void;
onAutoCorrect?: () => void;
onNewComment?: () => void;
commentsOpen?: boolean;
onToggleComments?: () => void;
revisionsOpen?: boolean;
onToggleRevisions?: () => void;
} = $props();
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
// Accepting a change stays possible even where the view hides it, as it is in Word.
let hasRevisions = $derived(tick >= 0 && !!editor && revisions(editor.state.doc).length > 0);
let hasComments = $derived(tick >= 0 && !!editor && comments(editor.state.doc).length > 0);
const PLACE_LABEL: Record<MarkupPlace, () => string> = {
balloons: () => t().revisions.placeBalloons,
pane: () => t().revisions.placePane,
};
const MODE_LABEL: Record<MarkupMode, () => { label: string; hint: string }> = {
all: () => ({ label: t().revisions.displayAll, hint: t().revisions.displayAllHint }),
simple: () => ({ label: t().revisions.displaySimple, hint: t().revisions.displaySimpleHint }),
none: () => ({ label: t().revisions.displayNone, hint: t().revisions.displayNoneHint }),
original: () => ({ label: t().revisions.displayOriginal, hint: t().revisions.displayOriginalHint }),
};
// Only what the view shows is walked: a hidden change is not a place to jump to.
function go(kind: 'comment' | 'revision', dir: 1 | -1): void {
if (!editor) return;
const doc = editor.state.doc;
const list: { from: number; to: number }[] =
kind === 'comment' ? visibleComments(doc) : visibleRevisions(doc);
const hit = step(list, editor.state.selection.from, dir);
if (hit) selectRange(editor, hit.from, hit.to);
}
let stats = $derived.by<TextStats>(() => {
if (tick < 0 || !editor) return { words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 };
@@ -93,6 +122,34 @@
<div class="ribbon-sep"></div>
<!-- One button per kind: the click shows or hides it, the caret says where its cards go.
What Word splits over Show markup, the balloon modes and the reviewing pane. -->
{#snippet showKind(id: string, label: string, on: boolean, toggle: () => void,
at: MarkupPlace, setAt: (p: MarkupPlace) => void)}
<div class="rb-menu-wrap" use:clickOutside={id}>
<RibbonButton
variant="big"
icon="comment"
{label}
title={`${label} — ${PLACE_LABEL[at]()}`}
caret
active={on}
caretActive={isMenuOpen(id)}
onclick={toggle}
onCaret={() => toggleMenu(id)}
/>
{#if isMenuOpen(id)}
<div class="ribbon-menu" use:anchored role="menu">
{#each MARKUP_PLACES as p}
<button class:selected={at === p} onclick={() => { closeMenu(); setAt(p); if (!on) toggle(); }}>
{PLACE_LABEL[p]()}
</button>
{/each}
</div>
{/if}
</div>
{/snippet}
<RibbonGroup label={t().comments.title}>
<RibbonButton
variant="big"
@@ -102,14 +159,24 @@
disabled={!editor || !hasSelection}
onclick={() => onNewComment?.()}
/>
<RibbonButton
variant="small"
icon="comment"
label={t().comments.title}
title={t().comments.showPane}
active={commentsOpen}
onclick={() => onToggleComments?.()}
/>
{@render showKind('commentPlace', t().comments.showPane, showComments(),
() => setShowComments(!showComments()), commentPlace(), setCommentPlace)}
<div class="rb-col">
<RibbonButton
variant="small"
icon="chevronLeft"
label={t().revisions.prev}
disabled={!hasComments}
onclick={() => go('comment', -1)}
/>
<RibbonButton
variant="small"
icon="chevronRight"
label={t().revisions.next}
disabled={!hasComments}
onclick={() => go('comment', 1)}
/>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
@@ -123,14 +190,28 @@
active={recordChanges()}
onclick={() => setRecordChanges(!recordChanges())}
/>
<RibbonButton
variant="big"
icon="comment"
label={t().revisions.pane}
title={t().revisions.showPane}
active={revisionsOpen}
onclick={() => onToggleRevisions?.()}
/>
<div class="rb-menu-wrap" use:clickOutside={'markupDisplay'}>
<RibbonButton
variant="big"
icon="trackChanges"
label={t().revisions.display}
title={MODE_LABEL[markupMode()]().label}
caret
active={isMenuOpen('markupDisplay')}
onclick={() => toggleMenu('markupDisplay')}
/>
{#if isMenuOpen('markupDisplay')}
<div class="ribbon-menu" use:anchored role="menu">
{#each MARKUP_MODES as m}
<button class:selected={markupMode() === m} onclick={() => { closeMenu(); setMarkupMode(m); }}>
{MODE_LABEL[m]().label}<span class="menu-sub">{MODE_LABEL[m]().hint}</span>
</button>
{/each}
</div>
{/if}
</div>
{@render showKind('changePlace', t().revisions.showPane, showChanges(),
() => setShowChanges(!showChanges()), changePlace(), setChangePlace)}
<!-- The one at the cursor over the whole document, so the pair reads as one column. -->
<div class="rb-col">
<RibbonButton
@@ -166,14 +247,39 @@
onclick={() => editor?.chain().focus().rejectRevisions(true).run()}
/>
</div>
<RibbonButton
variant="small"
icon="print"
label={t().revisions.printMarkup}
title={t().revisions.printMarkupHint}
active={printMarkup()}
onclick={() => setPrintMarkup(!printMarkup())}
/>
<div class="rb-col">
<RibbonButton
variant="small"
icon="chevronLeft"
label={t().revisions.prev}
disabled={!hasRevisions}
onclick={() => go('revision', -1)}
/>
<RibbonButton
variant="small"
icon="chevronRight"
label={t().revisions.next}
disabled={!hasRevisions}
onclick={() => go('revision', 1)}
/>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<!-- Both act on comments and changes alike, so they sit apart from the groups that
each hold one of the two. -->
<RibbonGroup label={t().ribbon.groups.markup}>
<!-- A checkbox, not a button: it settles what the next printout carries, it does not
print. A pressed-looking button read as the command itself. -->
<label class="rb-check" title={t().revisions.printMarkupHint}>
<input
type="checkbox"
checked={printMarkup()}
onchange={(e) => setPrintMarkup(e.currentTarget.checked)}
/>
{t().revisions.printMarkup}
</label>
</RibbonGroup>
<div class="ribbon-sep"></div>
@@ -190,6 +296,19 @@
.stats-menu { min-width: 240px; }
.rb-col { display: flex; flex-direction: column; gap: 2px; }
.rb-check {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
font-family: var(--w-font);
font-size: 12px;
color: var(--w-text);
white-space: nowrap;
cursor: pointer;
}
.rb-check input { accent-color: var(--w-accent); cursor: pointer; }
.rb-col :global(.rb-small) { width: 100%; }
.stat {
+5
View File
@@ -70,6 +70,11 @@ before (no list, no CSS, no margin shift). The Review tab's **Print markup** tog
(`storage/printMarkup.svelte.ts`) turns the whole thing off, as both products offer;
`printPdf` rebuilds its options field by field, so a new one has to be copied there too.
**The display mode prints too** (`storage/markup`, Word's "Display for review"): a page in
*no markup* prints as it reads. The two paths that rasterise the live `.paper` inherit its
attributes with the clone; `printPdf` builds its page from the document instead, so it
takes them as `markupAttrs`. `printMarkup` still governs the bar and the list beside it.
- **The margin strip is paid for.** The print engine clips to the page area, so a bar at a
negative offset is silently dropped — measured, not assumed. `printPdf` takes
`BAR_STRIP_CM` out of the left `@page` margin and gives it back as `.paper` padding,
+4 -1
View File
@@ -467,6 +467,8 @@ export interface PrintPdfOptions {
commentLabels?: CommentLabels;
/** Whether the review markup prints at all (`storage/printMarkup`); default on. */
printMarkup?: boolean;
/** The display mode as `.paper` attributes (`storage/markup`): printed as seen. */
markupAttrs?: string;
}
// First-row column weights from table JSON (honours colspan); mirrors tableView.
@@ -641,6 +643,7 @@ export function printPdf(opts: PrintPdfOptions): void {
differentOddEven: opts.differentOddEven ?? false,
commentLabels: opts.commentLabels,
printMarkup: opts.printMarkup,
markupAttrs: opts.markupAttrs,
};
const title = (o.fileName ?? deriveFilename(o.json)).replace(/\.(odt|pdf)$/i, '');
// The layers are Svelte DOM and generateHTML knows nothing of them, so this path
@@ -662,7 +665,7 @@ export function printPdf(opts: PrintPdfOptions): void {
idoc.write(
`<!doctype html><html data-theme="light"><head><meta charset="utf-8"><title>${title}</title>` +
`<style>${globalCss}\n${editorCss}\n${printCss(o, review)}</style></head>` +
`<body><div class="paper"><div class="tiptap">${host.innerHTML}</div>${list}</div></body></html>`,
`<body><div class="paper"${o.markupAttrs ?? ''}><div class="tiptap">${host.innerHTML}</div>${list}</div></body></html>`,
);
idoc.close();
+16 -1
View File
@@ -830,6 +830,19 @@ const de: Messages = {
},
revisions: {
record: 'Änderungen verfolgen',
display: 'Anzeige für Überprüfung',
displayAll: 'Alle Markups',
displayAllHint: 'Jede Änderung und jeder Kommentar',
displaySimple: 'Einfaches Markup',
displaySimpleHint: 'Endtext mit Änderungsbalken',
displayNone: 'Kein Markup',
displayNoneHint: 'Der Text, wie er sich liest',
displayOriginal: 'Original',
displayOriginalHint: 'Der Text vor den Änderungen',
placeBalloons: 'In Sprechblasen',
placePane: 'In der Leiste',
prev: 'Vorherige',
next: 'Nächste',
printMarkup: 'Markup drucken',
printMarkupHint: 'Randbalken und Kommentarliste mitdrucken',
recordHint: 'Jede Bearbeitung als Änderung aufzeichnen',
@@ -840,7 +853,8 @@ const de: Messages = {
acceptAll: 'Alle annehmen',
rejectAll: 'Alle verwerfen',
pane: 'Änderungen',
showPane: 'Liste der aufgezeichneten Änderungen anzeigen',
showPane: 'Änderungen anzeigen',
showPaneHint: 'Liste der aufgezeichneten Änderungen anzeigen',
empty: 'Keine aufgezeichneten Änderungen.',
unknownAuthor: 'Unbekannter Autor',
inserted: 'Eingefügt',
@@ -913,6 +927,7 @@ const de: Messages = {
citations: 'Zitate und Literaturverzeichnis',
index: 'Index',
revisions: 'Nachverfolgung',
markup: 'Markup',
proofing: 'Rechtschreibung',
language: 'Sprache',
show: 'Anzeigen',
+16 -1
View File
@@ -836,6 +836,19 @@ const en = {
},
revisions: {
record: 'Track changes',
display: 'Display for review',
displayAll: 'All markup',
displayAllHint: 'Every change and comment',
displaySimple: 'Simple markup',
displaySimpleHint: 'Final text with change bars',
displayNone: 'No markup',
displayNoneHint: 'The text as it would read',
displayOriginal: 'Original',
displayOriginalHint: 'The text before the changes',
placeBalloons: 'In balloons',
placePane: 'In the pane',
prev: 'Previous',
next: 'Next',
printMarkup: 'Print markup',
printMarkupHint: 'Print the margin bar and the comment list',
recordHint: 'Record every edit as a revision',
@@ -846,7 +859,8 @@ const en = {
acceptAll: 'Accept all',
rejectAll: 'Reject all',
pane: 'Revisions',
showPane: 'Show the list of recorded changes',
showPane: 'Show revisions',
showPaneHint: 'Show the list of recorded changes',
empty: 'No recorded changes.',
unknownAuthor: 'Unknown author',
inserted: 'Inserted',
@@ -919,6 +933,7 @@ const en = {
citations: 'Citations & Bibliography',
index: 'Index',
revisions: 'Tracking',
markup: 'Markup',
proofing: 'Proofing',
language: 'Language',
show: 'Show',
+7
View File
@@ -393,6 +393,13 @@
.ribbon-menu .menu-key,
.ribbon-menu .menu-sub { margin-left: auto; padding-left: 16px; color: var(--w-text-dim); }
.ribbon-menu .menu-key { font-size: 12px; }
/* A checkable row keeps the slot whether it is checked or not, so the labels line up. */
.ribbon-menu .menu-check {
flex: none;
width: 14px;
color: var(--w-accent);
}
.ribbon-menu .menu-sub { font-size: 11px; }
/* Section label above a run of rows, and the sticky action row below the list. */