refactor: one card builds a comment or a change in either review view

CommentCard and RevisionCard hold what the pane rows carried inline, so the
margin balloon can show the same thing. The comment card gains the reply box
its thread needs and moves edit / resolve / remove behind a ⋯ menu that stays
in the flow — a balloon clips a floating popover, and three buttons in a row
cost it two lines.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:03 +02:00
1 parent 6d5db7135b
commit 01b8a97404
7 files changed
+339 -164

No files matched your search

+11 -5
View File
@@ -133,10 +133,15 @@ One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, per
## Comments (`CommentsPane.svelte`)
Word's Reviewing Pane, not its margin bubbles: the page here fills its own scroller, so a
pane beside it (`.editor-row` in `App.svelte`) keeps the sheet at its true width. It lists
`comments(editor.state.doc)` in document order — click a card to select the annotated
range, edit / resolve / remove in place. `App.svelte` owns the New-comment prompt, which
Word's Reviewing Pane, docked beside the scroller (`.editor-row` in `App.svelte`): the
list view a comment goes to instead of the margin balloon below — one control picks the
place (`storage/markup`), so no comment is in both at once. It lists `visibleComments` in
document order — click a card to select the
annotated range, answer it in the reply box, and edit / resolve / remove behind the card's
**⋯** button (`CommentCard.svelte`, shared with the balloon). That menu is **in the flow**,
not floating: the balloon clips what leaves it, so a popover would be cut off there. Only
the answer box is a command a card shows outright — it is the one used most, and three
buttons in a row cost a balloon two lines. `App.svelte` owns the New-comment prompt, which
both the Review tab and the context menu (`OPEN_COMMENT_EVENT`) fire; the author comes
from the document properties. The card of the comment the selection lies within (`commentIdAt`)
is marked and scrolled into view, the pane half of the pairing `pm-comment-active`,
@@ -147,7 +152,8 @@ is marked and scrolled into view, the pane half of the pairing `pm-comment-activ
The reviewing pane both word processors list revisions in, docked beside `CommentsPane`
and built the same way: one row per change **id** (a paragraph boundary splits one change
into several ranges), in the author's own colour, click to select the text, accept or
reject in place through `acceptRevision`/`rejectRevision`. Opened from the Review tab.
reject in place through `acceptRevision`/`rejectRevision`. The Review tab's Show-changes
button picks between this list and the balloons; the pane is not a second view of them.
The row of the change the selection lies within (`revisionIdAt`) is marked with an inset accent in the
author's colour — the same colour its margin bar takes — and scrolled into view.
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import type { CommentRange } from '../editor/extensions/comment';
import { t } from '../i18n/i18n.svelte';
// One comment thread as both reviewing views show it: the quote it hangs on, its text,
// the answers to it and a box to add one. Edit / resolve / remove sit behind the ⋯
// button, as they do in both word processors — a balloon has no room for a row of three. Shared by the pane's row and the margin balloon, so the commands exist once.
let { editor, c, author, onSelect }: {
editor: Editor | null;
c: CommentRange;
author: string;
onSelect: () => void;
} = $props();
let editing = $state(false);
let draft = $state('');
let replying = $state(false);
let reply = $state('');
let menu = $state(false);
function startEdit() {
draft = c.text;
editing = true;
menu = false;
}
function commit() {
editor?.chain().focus().updateComment(c.id, { text: draft.trim() }).run();
editing = false;
}
function sendReply() {
if (!reply.trim()) return;
editor?.chain().focus().replyToComment(c.id, { author, text: reply }).run();
reply = '';
replying = false;
}
// The stored ISO date, in the reader's locale — an imported file may carry any format.
function when(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
// Close the ⋯ menu on an outside click or Escape (as HistoryButton's popover does).
function popover(node: HTMLElement) {
const onDown = (e: MouseEvent) => { if (!node.contains(e.target as Node)) menu = false; };
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') menu = false; };
window.addEventListener('mousedown', onDown);
window.addEventListener('keydown', onKey);
return {
destroy() {
window.removeEventListener('mousedown', onDown);
window.removeEventListener('keydown', onKey);
},
};
}
function focus(node: HTMLTextAreaElement) {
node.focus();
}
</script>
<div class="head" use:popover>
<button class="card" onclick={onSelect}>
<div class="meta">
<b>{c.author || author}</b>
<span>{when(c.date)}</span>
</div>
</button>
<button
class="more"
aria-label={t().comments.more}
title={t().comments.more}
aria-expanded={menu}
onclick={() => (menu = !menu)}
>⋯</button>
</div>
{#if menu}
<!-- In the flow, not floating: the balloon clips what leaves it, and a menu of three
rows fits under the head in either view. -->
<div class="menu">
<button onclick={startEdit}>{t().comments.edit}</button>
<button onclick={() => { menu = false; editor?.chain().focus().updateComment(c.id, { resolved: !c.resolved }).run(); }}>
{c.resolved ? t().comments.reopen : t().comments.resolve}
</button>
<button onclick={() => { menu = false; editor?.chain().focus().removeComment(c.id).run(); }}>{t().common.remove}</button>
</div>
{/if}
<button class="card" onclick={onSelect}>
<div class="quote">{c.quote}</div>
</button>
{#if editing}
<textarea
rows="3"
use:focus
bind:value={draft}
onkeydown={(e) => { if (e.key === 'Escape') editing = false; }}
></textarea>
<div class="actions">
<button onclick={() => (editing = false)}>{t().common.cancel}</button>
<button class="primary" onclick={commit}>{t().common.ok}</button>
</div>
{:else}
<p class="body">{c.text}</p>
{/if}
{#each c.replies as r, i (i)}
<div class="reply">
<div class="meta"><b>{r.author || author}</b><span>{when(r.date)}</span></div>
<p class="body">{r.text}</p>
</div>
{/each}
{#if replying}
<textarea
rows="2"
use:focus
bind:value={reply}
placeholder={t().comments.reply}
onkeydown={(e) => { if (e.key === 'Escape') replying = false; }}
></textarea>
<div class="actions">
<button onclick={() => { replying = false; reply = ''; }}>{t().common.cancel}</button>
<button class="primary" onclick={sendReply}>{t().comments.reply}</button>
</div>
{:else if !editing}
<div class="actions">
<button onclick={() => (replying = true)}>{t().comments.reply}</button>
</div>
{/if}
<style>
.head { display: flex; align-items: flex-start; gap: 4px; }
.head .card { flex: 1; min-width: 0; }
.card {
display: block;
width: 100%;
border: none;
background: none;
color: inherit;
padding: 0;
text-align: left;
font: inherit;
cursor: pointer;
}
.meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; color: var(--color-text-muted); }
.meta b { color: var(--color-text); }
.more {
flex: none;
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: var(--color-text-muted);
padding: 0 4px;
font: inherit;
line-height: 1.2;
cursor: pointer;
}
.more:hover { background: var(--color-btn-hover); color: var(--color-text); }
.menu { display: flex; flex-direction: column; align-items: stretch; margin: 2px 0; }
.menu button {
border: none;
border-radius: var(--radius);
background: none;
color: var(--color-text);
padding: 3px 6px;
font: inherit;
text-align: left;
cursor: pointer;
}
.menu button:hover { background: var(--color-btn-hover); }
.quote {
margin: 3px 0;
padding-left: 6px;
border-left: 2px solid rgba(200, 140, 0, 0.7);
color: var(--color-text-muted);
/* Two lines of the annotated text is enough to recognise it. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.body { margin: 4px 0; white-space: pre-wrap; }
/* An answer is indented under the comment it belongs to, as in a threaded pane. */
.reply {
margin-top: 4px;
padding-left: 6px;
border-left: 1px solid var(--color-border);
}
textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 4px 6px;
font: inherit;
resize: vertical;
}
/* Wraps: the balloon is as wide as it is and a locale's labels are as long as they
are — an unwrapped row would push its first button out of the card. */
.actions { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.actions button {
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: var(--color-text-muted);
padding: 2px 6px;
font: inherit;
cursor: pointer;
}
.actions button:hover { background: var(--color-btn-hover); color: var(--color-text); }
.actions .primary { border-color: var(--color-primary); background: var(--color-primary); color: #fff; }
</style>
+10 -102
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import { comments, commentIdAt, type CommentRange } from '../editor/extensions/comment';
import { commentIdAt, type CommentRange } from '../editor/extensions/comment';
import { selectRange, visibleComments } from './reviewItems';
import CommentCard from './CommentCard.svelte';
import { t } from '../i18n/i18n.svelte';
// Word's Reviewing Pane: every comment in document order, click to jump to the text it
// annotates. Margin bubbles are what Word draws beside the page, but the page here fills
// its own scroller — a pane keeps the sheet at its true width.
// annotates. The balloons beside the page are the other view (ReviewMarginLayer); both
// are open at once in Word too, and both build their card from CommentCard.
let { editor, tick, author, onClose }: {
editor: Editor | null;
tick: number;
@@ -16,7 +17,7 @@
let list = $derived.by<CommentRange[]>(() => {
if (tick < 0 || !editor) return [];
return comments(editor.state.doc);
return visibleComments(editor.state.doc);
});
// The comment the caret sits in: its card is marked and pulled into view, the other
@@ -31,31 +32,8 @@
if (activeId) cards[activeId]?.scrollIntoView({ block: 'nearest' });
});
let editingId = $state<string | null>(null);
let draft = $state('');
function jumpTo(c: CommentRange) {
if (!editor) return;
const { state, view } = editor;
const tr = state.tr.setSelection(TextSelection.create(state.doc, c.from, c.to)).scrollIntoView();
view.dispatch(tr);
view.focus();
}
function startEdit(c: CommentRange) {
editingId = c.id;
draft = c.text;
}
function commit(id: string) {
editor?.chain().focus().updateComment(id, { text: draft.trim() }).run();
editingId = null;
}
// The stored ISO date, in the reader's locale — an imported file may carry any format.
function when(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
if (editor) selectRange(editor, c.from, c.to);
}
</script>
@@ -75,33 +53,7 @@
<ul>
{#each list as c (c.id)}
<li bind:this={cards[c.id]} class:resolved={c.resolved} class:active={c.id === activeId}>
<button class="card" onclick={() => jumpTo(c)}>
<div class="meta">
<b>{c.author || author}</b>
<span>{when(c.date)}</span>
</div>
<div class="quote">{c.quote}</div>
</button>
{#if editingId === c.id}
<textarea
rows="3"
bind:value={draft}
onkeydown={(e) => { if (e.key === 'Escape') editingId = null; }}
></textarea>
<div class="actions">
<button onclick={() => (editingId = null)}>{t().common.cancel}</button>
<button class="primary" onclick={() => commit(c.id)}>{t().common.ok}</button>
</div>
{:else}
<p class="body">{c.text}</p>
<div class="actions">
<button onclick={() => startEdit(c)}>{t().comments.edit}</button>
<button onclick={() => editor?.chain().focus().updateComment(c.id, { resolved: !c.resolved }).run()}>
{c.resolved ? t().comments.reopen : t().comments.resolve}
</button>
<button onclick={() => editor?.chain().focus().removeComment(c.id).run()}>{t().common.remove}</button>
</div>
{/if}
<CommentCard {editor} {c} {author} onSelect={() => jumpTo(c)} />
</li>
{/each}
</ul>
@@ -132,7 +84,7 @@
}
.title { flex: 1; font-weight: 600; }
.count { color: var(--color-text-muted); }
.close, .actions button {
.close {
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
@@ -141,8 +93,7 @@
font: inherit;
cursor: pointer;
}
.close:hover, .actions button:hover { background: var(--color-btn-hover); color: var(--color-text); }
.actions .primary { border-color: var(--color-primary); background: var(--color-primary); color: #fff; }
.close:hover { background: var(--color-btn-hover); color: var(--color-text); }
.empty { padding: 12px 10px; color: var(--color-text-muted); }
@@ -154,47 +105,4 @@
}
li.resolved { opacity: 0.55; }
li.active { background: var(--color-btn-hover); box-shadow: inset 3px 0 0 rgba(200, 140, 0, 0.9); }
.card {
display: block;
width: 100%;
border: none;
background: none;
color: inherit;
padding: 0;
text-align: left;
font: inherit;
cursor: pointer;
}
.meta { display: flex; justify-content: space-between; gap: 8px; color: var(--color-text-muted); }
.meta b { color: var(--color-text); }
.quote {
margin: 3px 0;
padding-left: 6px;
border-left: 2px solid rgba(200, 140, 0, 0.7);
color: var(--color-text-muted);
/* Two lines of the annotated text is enough to recognise it. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.body { margin: 4px 0; white-space: pre-wrap; }
textarea {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 4px 6px;
font: inherit;
resize: vertical;
}
.actions { display: flex; gap: 4px; justify-content: flex-end; }
</style>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import type { Revision } from '../editor/extensions/trackChanges';
import { t } from '../i18n/i18n.svelte';
// One recorded change as both reviewing views show it: what it did, to which text, and
// accept / reject. Shared by the pane's row and the margin balloon.
let { editor, r, color, author, onSelect }: {
editor: Editor | null;
r: Revision;
color: string;
author: string;
onSelect: () => void;
} = $props();
// The stored ISO date, in the reader's locale — an imported file may carry any format.
function when(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
</script>
<button class="card" onclick={onSelect}>
<div class="meta">
<b style:color={color}>{r.author || author || t().revisions.unknownAuthor}</b>
<span>{when(r.date)}</span>
</div>
<div class="kind">{r.kind === 'insertion' ? t().revisions.inserted : t().revisions.deleted}</div>
<div class="quote" class:struck={r.kind === 'deletion'} style:color={color}>{r.text}</div>
</button>
<div class="actions">
<button onclick={() => editor?.chain().focus().acceptRevision(r.id).run()}>{t().revisions.accept}</button>
<button onclick={() => editor?.chain().focus().rejectRevision(r.id).run()}>{t().revisions.reject}</button>
</div>
<style>
.card {
display: block;
width: 100%;
border: none;
background: none;
color: inherit;
padding: 0;
text-align: left;
font: inherit;
cursor: pointer;
}
.meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; color: var(--color-text-muted); }
.kind { color: var(--color-text-muted); }
.quote {
margin: 3px 0;
padding-left: 6px;
border-left: 2px solid currentColor;
/* Two lines of the changed text is enough to recognise it. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.quote.struck { text-decoration: line-through; }
/* Wraps: the balloon is 4.5cm wide and a locale's labels are as long as they are —
an unwrapped row would push its first button out of the card. */
.actions { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.actions button {
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: var(--color-text-muted);
padding: 2px 6px;
font: inherit;
cursor: pointer;
}
.actions button:hover { background: var(--color-btn-hover); color: var(--color-text); }
</style>
+9 -57
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import { revisions, revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS, type Revision } from '../editor/extensions/trackChanges';
import { revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS, type Revision } from '../editor/extensions/trackChanges';
import { selectRange, visibleRevisions } from './reviewItems';
import RevisionCard from './RevisionCard.svelte';
import { t } from '../i18n/i18n.svelte';
// The reviewing pane both word processors list revisions in, beside the comments one:
// every recorded change in document order, click to jump, accept or reject in place.
// The balloons beside the page are the other view; both build their card from RevisionCard.
let { editor, tick, author, onClose }: {
editor: Editor | null;
tick: number;
@@ -17,7 +19,7 @@
let list = $derived.by<Revision[]>(() => {
if (tick < 0 || !editor) return [];
const seen = new Set<string>();
return revisions(editor.state.doc).filter((r) => !seen.has(r.id) && seen.add(r.id));
return visibleRevisions(editor.state.doc).filter((r) => !seen.has(r.id) && seen.add(r.id));
});
let colors = $derived(authorColorIndex(list));
@@ -39,16 +41,7 @@
}
function jumpTo(r: Revision) {
if (!editor) return;
const { state, view } = editor;
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, r.from, r.to)).scrollIntoView());
view.focus();
}
// The stored ISO date, in the reader's locale — an imported file may carry any format.
function when(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
if (editor) selectRange(editor, r.from, r.to);
}
</script>
@@ -68,18 +61,7 @@
<ul>
{#each list as r (r.id)}
<li bind:this={cards[r.id]} class:active={r.id === activeId} style:box-shadow={r.id === activeId ? `inset 3px 0 0 ${colorOf(r)}` : null}>
<button class="card" onclick={() => jumpTo(r)}>
<div class="meta">
<b style:color={colorOf(r)}>{r.author || author || t().revisions.unknownAuthor}</b>
<span>{when(r.date)}</span>
</div>
<div class="kind">{r.kind === 'insertion' ? t().revisions.inserted : t().revisions.deleted}</div>
<div class="quote" class:struck={r.kind === 'deletion'} style:color={colorOf(r)}>{r.text}</div>
</button>
<div class="actions">
<button onclick={() => editor?.chain().focus().acceptRevision(r.id).run()}>{t().revisions.accept}</button>
<button onclick={() => editor?.chain().focus().rejectRevision(r.id).run()}>{t().revisions.reject}</button>
</div>
<RevisionCard {editor} {r} {author} color={colorOf(r)} onSelect={() => jumpTo(r)} />
</li>
{/each}
</ul>
@@ -110,7 +92,7 @@
}
.title { flex: 1; font-weight: 600; }
.count { color: var(--color-text-muted); }
.close, .actions button {
.close {
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
@@ -119,7 +101,7 @@
font: inherit;
cursor: pointer;
}
.close:hover, .actions button:hover { background: var(--color-btn-hover); color: var(--color-text); }
.close:hover { background: var(--color-btn-hover); color: var(--color-text); }
.empty { padding: 12px 10px; color: var(--color-text-muted); }
@@ -131,34 +113,4 @@
}
/* The accent is the author's colour, set inline — the same one the margin bar takes. */
li.active { background: var(--color-btn-hover); }
.card {
display: block;
width: 100%;
border: none;
background: none;
color: inherit;
padding: 0;
text-align: left;
font: inherit;
cursor: pointer;
}
.meta { display: flex; justify-content: space-between; gap: 8px; color: var(--color-text-muted); }
.kind { color: var(--color-text-muted); }
.quote {
margin: 3px 0;
padding-left: 6px;
border-left: 2px solid currentColor;
/* Two lines of the changed text is enough to recognise it. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.quote.struck { text-decoration: line-through; }
.actions { display: flex; gap: 4px; justify-content: flex-end; }
</style>
+2
View File
@@ -83,6 +83,8 @@ const de: Messages = {
title: 'Kommentare',
onPage: (n: number) => `Seite ${n}`,
empty: 'Noch keine Kommentare. Text markieren und einen hinzufügen.',
more: 'Mehr',
reply: 'Antworten',
edit: 'Bearbeiten',
resolve: 'Erledigt',
reopen: 'Wieder öffnen',
+2
View File
@@ -86,6 +86,8 @@ const en = {
title: 'Comments',
onPage: (n: number) => `Page ${n}`,
empty: 'No comments yet. Select some text and add one.',
more: 'More',
reply: 'Reply',
edit: 'Edit',
resolve: 'Resolve',
reopen: 'Reopen',