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:
1 parent
6d5db7135b
commit
01b8a97404
7 files changed
+339
-164
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user