Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94f60cf0ec | |||
| f13105333a |
@@ -40,6 +40,7 @@
|
|||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"blueimp-load-image": "5.16.0",
|
"blueimp-load-image": "5.16.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
|
|||||||
@@ -108,10 +108,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||||
renderItem(suggestion(), true);
|
const { container } = renderItem(suggestion(), true);
|
||||||
// Old text appears both as the selection quote and as the struck diff row.
|
// Old text appears as the selection quote (a single unsplit Text node).
|
||||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||||
|
// so it is no longer a single text node — assert the concatenated content.
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
// Apply button is present.
|
// Apply button is present.
|
||||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
// No Applied badge yet.
|
// No Applied badge yet.
|
||||||
@@ -119,9 +121,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides the Apply button when canEdit is false", () => {
|
it("hides the Apply button when canEdit is false", () => {
|
||||||
renderItem(suggestion(), false);
|
const { container } = renderItem(suggestion(), false);
|
||||||
// Diff still renders...
|
// Diff still renders (as per-fragment spans, #331)...
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
expect(container.textContent).toContain("new wording here");
|
||||||
// ...but no Apply button.
|
// ...but no Apply button.
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
computeSuggestionDiff,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -54,6 +57,18 @@ function CommentListItem({
|
|||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
|
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||||
|
// fragments that actually changed get emphasised inside the red/green block,
|
||||||
|
// instead of striking through / greening the whole line. Memoised on the
|
||||||
|
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||||
|
const suggestionDiff = useMemo(
|
||||||
|
() =>
|
||||||
|
comment.suggestedText != null
|
||||||
|
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||||
|
: null,
|
||||||
|
[comment.selection, comment.suggestedText],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContent(comment.content);
|
setContent(comment.content);
|
||||||
}, [comment]);
|
}, [comment]);
|
||||||
@@ -236,12 +251,28 @@ function CommentListItem({
|
|||||||
{!comment.parentCommentId && comment.suggestedText && (
|
{!comment.parentCommentId && comment.suggestedText && (
|
||||||
<Box className={classes.suggestionBlock}>
|
<Box className={classes.suggestionBlock}>
|
||||||
{comment.selection && (
|
{comment.selection && (
|
||||||
|
// Old line: read as removed as a whole (line-through/red); only the
|
||||||
|
// changed fragments carry the extra intraline emphasis.
|
||||||
<Text size="xs" className={classes.suggestionOld}>
|
<Text size="xs" className={classes.suggestionOld}>
|
||||||
{comment.selection}
|
{suggestionDiff?.old.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" className={classes.suggestionNew}>
|
<Text size="xs" className={classes.suggestionNew}>
|
||||||
{comment.suggestedText}
|
{suggestionDiff?.new.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{comment.suggestionAppliedAt ? (
|
{comment.suggestionAppliedAt ? (
|
||||||
|
|||||||
@@ -53,6 +53,21 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intraline diff (#331): the fragment that actually changed within the
|
||||||
|
red "before" / green "after" block. It inherits the surrounding red/green
|
||||||
|
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||||
|
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||||
|
container's line-through (old) / green (new) still marks the full line. */
|
||||||
|
.suggestionChanged {
|
||||||
|
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||||
|
within the block. `currentColor` follows the parent's red (old) or green
|
||||||
|
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||||
|
inherited line-through must survive on the changed letters too. */
|
||||||
|
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
|
// Reconstruct the plain string from a segment stream — the diff must be
|
||||||
|
// lossless (concatenating every fragment yields the original input).
|
||||||
|
const join = (segments: Segment[]): string =>
|
||||||
|
segments.map((s) => s.text).join("");
|
||||||
|
|
||||||
|
// The subset of segments (in order) that the UI would emphasise.
|
||||||
|
const changed = (segments: Segment[]): string[] =>
|
||||||
|
segments.filter((s) => s.changed).map((s) => s.text);
|
||||||
|
|
||||||
|
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||||
|
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||||
|
segments.find((s) => s.text.includes(needle));
|
||||||
|
|
||||||
|
describe("computeSuggestionDiff", () => {
|
||||||
|
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("заведем");
|
||||||
|
expect(join(neu)).toBe("заведём");
|
||||||
|
|
||||||
|
// Old side: exactly the `е` is changed, the rest is common.
|
||||||
|
expect(changed(old)).toEqual(["е"]);
|
||||||
|
expect(old).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "е", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// New side: exactly the `ё` is changed.
|
||||||
|
expect(changed(neu)).toEqual(["ё"]);
|
||||||
|
expect(neu).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "ё", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the differing words changed but keeps the shared word common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff(
|
||||||
|
"привет мир",
|
||||||
|
"здравствуй мир",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("привет мир");
|
||||||
|
expect(join(neu)).toBe("здравствуй мир");
|
||||||
|
|
||||||
|
// The shared trailing word stays common on both sides (no per-letter noise
|
||||||
|
// leaking across the differing words into `мир`).
|
||||||
|
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||||
|
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||||
|
|
||||||
|
// The differing words are emphasised somewhere on each side.
|
||||||
|
expect(changed(old).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(neu).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||||
|
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||||
|
|
||||||
|
// No changed fragment on either side touches the word `мир`.
|
||||||
|
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a c");
|
||||||
|
expect(join(neu)).toBe("a b c");
|
||||||
|
|
||||||
|
// Old line has no changed fragment (nothing was removed).
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
// The inserted word is the only changed fragment on the new side.
|
||||||
|
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(neu)).toEqual(["b "]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a b c");
|
||||||
|
expect(join(neu)).toBe("a c");
|
||||||
|
|
||||||
|
// The deleted word is the only changed fragment on the old side.
|
||||||
|
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(old)).toEqual(["b "]);
|
||||||
|
// New line has no changed fragment (nothing was added).
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks everything common for identical strings", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||||
|
|
||||||
|
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||||
@@ -12,3 +13,105 @@ export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
|||||||
!comment.parentCommentId,
|
!comment.parentCommentId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||||
|
// `changed` marks the fragment that actually differs from the other side, so
|
||||||
|
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||||
|
// the whole line.
|
||||||
|
export interface Segment {
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||||
|
// common vs. removed-and-changed fragments, and the new line split into common
|
||||||
|
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||||
|
// the original strings.
|
||||||
|
export interface SuggestionDiff {
|
||||||
|
old: Segment[];
|
||||||
|
new: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||||
|
// so the render emits as few spans as possible and tests stay predictable.
|
||||||
|
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||||
|
if (text === "") return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.changed === changed) {
|
||||||
|
last.text += text;
|
||||||
|
} else {
|
||||||
|
segments.push({ text, changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an intraline diff between the old `selection` and the new
|
||||||
|
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||||
|
//
|
||||||
|
// Hybrid word + char algorithm (per #331):
|
||||||
|
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||||
|
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||||
|
// `diffChars`: shared characters stay common, differing characters are
|
||||||
|
// marked `changed` on their respective side. This is what keeps a
|
||||||
|
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||||
|
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||||
|
// fragment `changed`.
|
||||||
|
// 4. An unchanged part is `common` on both sides.
|
||||||
|
//
|
||||||
|
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||||
|
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||||
|
export function computeSuggestionDiff(
|
||||||
|
oldStr: string,
|
||||||
|
newStr: string,
|
||||||
|
): SuggestionDiff {
|
||||||
|
const oldSegments: Segment[] = [];
|
||||||
|
const newSegments: Segment[] = [];
|
||||||
|
|
||||||
|
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const next = parts[i + 1];
|
||||||
|
|
||||||
|
// A word replacement: a removed part immediately followed by an added part
|
||||||
|
// (or the reverse). Refine it character-by-character so only the differing
|
||||||
|
// letters are highlighted while shared letters stay common.
|
||||||
|
const isReplacementPair =
|
||||||
|
next &&
|
||||||
|
((part.removed && next.added) || (part.added && next.removed));
|
||||||
|
|
||||||
|
if (isReplacementPair) {
|
||||||
|
const removedPart = part.removed ? part : next;
|
||||||
|
const addedPart = part.added ? part : next;
|
||||||
|
|
||||||
|
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||||
|
for (const cp of charParts) {
|
||||||
|
if (cp.added) {
|
||||||
|
pushSegment(newSegments, cp.value, true);
|
||||||
|
} else if (cp.removed) {
|
||||||
|
pushSegment(oldSegments, cp.value, true);
|
||||||
|
} else {
|
||||||
|
// Shared character: common on both sides.
|
||||||
|
pushSegment(oldSegments, cp.value, false);
|
||||||
|
pushSegment(newSegments, cp.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++; // consume the paired part as well
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.added) {
|
||||||
|
// Lone insertion: only present in the new line, wholly changed.
|
||||||
|
pushSegment(newSegments, part.value, true);
|
||||||
|
} else if (part.removed) {
|
||||||
|
// Lone deletion: only present in the old line, wholly changed.
|
||||||
|
pushSegment(oldSegments, part.value, true);
|
||||||
|
} else {
|
||||||
|
// Unchanged: common on both sides.
|
||||||
|
pushSegment(oldSegments, part.value, false);
|
||||||
|
pushSegment(newSegments, part.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { old: oldSegments, new: newSegments };
|
||||||
|
}
|
||||||
|
|||||||
Generated
+3
@@ -335,6 +335,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: 2.1.1
|
specifier: 2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
diff:
|
||||||
|
specifier: 8.0.3
|
||||||
|
version: 8.0.3
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: 3.4.1
|
specifier: 3.4.1
|
||||||
version: 3.4.1
|
version: 3.4.1
|
||||||
|
|||||||
Reference in New Issue
Block a user