feat(edit-history): word-level diff view
The Edit History modal listed each version's full text with no indication of what changed. Add a word-level diff: each edit highlights the words added (green) and removed (struck-through red) relative to the previous version, so a one-word fix is obvious at a glance. - New pure, dependency-free diffWords (LCS over word/whitespace tokens) in utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace preserved, empty, no-mutation, word-not-char granularity). - EditHistoryModal renders each edit via a DiffText component using semantic <ins>/<del> (screen-reader-meaningful) styled with folds Success/Critical tokens. A "Highlight changes" header toggle (default on) switches back to full text, which keeps the rich formatted render; the Original row is always the plain baseline. - Diff is plain-text (body) only by design; formatted markup isn't diffed (the toggle restores the rich view), and media/no-body edits diff as empty strings gracefully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,9 @@ import {
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
@@ -28,6 +30,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { diffWords } from '../../../utils/textDiff';
|
||||
|
||||
type RawEditEvent = {
|
||||
type: string;
|
||||
@@ -87,11 +90,71 @@ function getVersionContent(evt: MatrixEvent): ReactNode {
|
||||
return renderContent(newContent ?? content);
|
||||
}
|
||||
|
||||
const asText = (source: Record<string, unknown> | null | undefined): string => {
|
||||
const body = source?.body;
|
||||
return typeof body === 'string' ? body : '';
|
||||
};
|
||||
|
||||
// Plain-text (body) of the pre-edit message — mirrors getOriginalContent, but
|
||||
// returns the raw string for diffing rather than a rendered node.
|
||||
function getOriginalText(evt: MatrixEvent): string {
|
||||
const raw =
|
||||
(evt.getClearContent() as Record<string, unknown> | null) ??
|
||||
(evt.event as { content?: Record<string, unknown> }).content ??
|
||||
{};
|
||||
return asText(raw);
|
||||
}
|
||||
|
||||
// Plain-text (body) of an edit's new content.
|
||||
function getVersionText(evt: MatrixEvent): string {
|
||||
const content = evt.getContent();
|
||||
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
|
||||
return asText(newContent ?? content);
|
||||
}
|
||||
|
||||
// Renders a word-level diff of prev -> next: added words highlighted, removed
|
||||
// words struck-through. Uses semantic <ins>/<del> so screen readers announce
|
||||
// the change. Plain-text only (formatting isn't diffed — see the toggle).
|
||||
function DiffText({ prev, next }: { prev: string; next: string }) {
|
||||
const segments = diffWords(prev, next);
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
const key = `${i}-${seg.type}`;
|
||||
if (seg.type === 'added') {
|
||||
return (
|
||||
<ins
|
||||
key={key}
|
||||
style={{
|
||||
background: color.Success.Container,
|
||||
color: color.Success.OnContainer,
|
||||
borderRadius: config.radii.R300,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{seg.text}
|
||||
</ins>
|
||||
);
|
||||
}
|
||||
if (seg.type === 'removed') {
|
||||
return (
|
||||
<del key={key} style={{ color: color.Critical.Main }}>
|
||||
{seg.text}
|
||||
</del>
|
||||
);
|
||||
}
|
||||
return <span key={key}>{seg.text}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(560);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const [showDiff, setShowDiff] = useState(true);
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const roomId = room.roomId;
|
||||
@@ -171,6 +234,10 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
|
||||
const originalTs = mEvent.getTs();
|
||||
|
||||
// Ordered plain-text of every version: [original, ...each edit]. Edit i diffs
|
||||
// against versionTexts[i] (the version immediately before it).
|
||||
const versionTexts = [getOriginalText(mEvent), ...edits.map(getVersionText)];
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -200,6 +267,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
Edit History
|
||||
</Text>
|
||||
</Box>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Text size="T200" priority="300">
|
||||
Highlight changes
|
||||
</Text>
|
||||
<Switch variant="Primary" value={showDiff} onChange={setShowDiff} />
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
@@ -258,7 +331,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
size="T300"
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{getVersionContent(editEvt)}
|
||||
{showDiff ? (
|
||||
<DiffText prev={versionTexts[index]} next={versionTexts[index + 1]} />
|
||||
) : (
|
||||
getVersionContent(editEvt)
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user