Skip to content

Commit fe7d0e8

Browse files
committed
diffをハイライト表示
1 parent 62c9a7d commit fe7d0e8

5 files changed

Lines changed: 173 additions & 19 deletions

File tree

app/[lang]/[pageId]/markdown.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@ import { JSX, ReactNode } from "react";
88
import { langConstants, MarkdownLang } from "@my-code/runtime/languages";
99
import { ReplTerminal } from "@/terminal/repl";
1010
import { StyledSyntaxHighlighter } from "./styledSyntaxHighlighter";
11+
import clsx from "clsx";
12+
import { remarkMultiHighlight, ReplacedRange } from "@/markdown/multiHighlight";
1113

12-
export function StyledMarkdown({ content }: { content: string }) {
14+
export function StyledMarkdown(props: {
15+
content: string;
16+
replacedRange?: ReplacedRange[];
17+
}) {
1318
return (
1419
<Markdown
15-
remarkPlugins={[remarkGfm, removeComments, remarkCjkFriendly]}
20+
remarkPlugins={[
21+
remarkGfm,
22+
removeComments,
23+
remarkCjkFriendly,
24+
[remarkMultiHighlight, props.replacedRange],
25+
]}
1626
components={components}
1727
>
18-
{content}
28+
{props.content}
1929
</Markdown>
2030
);
2131
}
@@ -50,6 +60,17 @@ const components: Components = {
5060
code: ({ node, className, ref, style, ...props }) => (
5161
<CodeComponent {...{ node, className, ref, style, ...props }} />
5262
),
63+
ins: ({ node, className, ...props }) => (
64+
<ins
65+
className={clsx(
66+
// classNameにチャットidが入っている。
67+
// 選択しているチャットに対応するdiffのみ濃いハイライトにするなど (TODO)
68+
className,
69+
"underline decoration-dashed underline-offset-[0.2rem] decoration-secondary/50"
70+
)}
71+
{...props}
72+
/>
73+
),
5374
};
5475

5576
export function Heading({
@@ -151,9 +172,7 @@ function CodeComponent({
151172
);
152173
} else {
153174
// inline
154-
return (
155-
<InlineCode>{String(props.children || "").replace(/\n$/, "")}</InlineCode>
156-
);
175+
return <InlineCode>{props.children}</InlineCode>;
157176
}
158177
}
159178

app/[lang]/[pageId]/pageContent.tsx

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import {
1313
PageEntry,
1414
PagePath,
1515
} from "@/lib/docs";
16+
import { ReplacedRange } from "@/markdown/multiHighlight";
1617

1718
/**
1819
* MarkdownSectionに追加で、動的な情報を持たせる
1920
*/
20-
export type DynamicMarkdownSection = MarkdownSection & {
21+
export interface DynamicMarkdownSection extends MarkdownSection {
2122
/**
2223
* ユーザーが今そのセクションを読んでいるかどうか
2324
*/
@@ -26,7 +27,8 @@ export type DynamicMarkdownSection = MarkdownSection & {
2627
* チャットの会話を元にAIが書き換えた後の内容
2728
*/
2829
replacedContent: string;
29-
};
30+
replacedRange: ReplacedRange[];
31+
}
3032

3133
interface PageContentProps {
3234
splitMdContent: MarkdownSection[];
@@ -43,21 +45,53 @@ export function PageContent(props: PageContentProps) {
4345
const { chatHistories } = useChatHistoryContext();
4446

4547
const initDynamicMdContent = useCallback(() => {
46-
const newContent = splitMdContent.map((section) => ({
47-
...section,
48-
inView: false,
49-
replacedContent: section.rawContent,
50-
}));
48+
const newContent: DynamicMarkdownSection[] = splitMdContent.map(
49+
(section) => ({
50+
...section,
51+
inView: false,
52+
replacedContent: section.rawContent,
53+
replacedRange: [],
54+
})
55+
);
5156
const chatDiffs = chatHistories.map((chat) => chat.diff).flat();
5257
chatDiffs.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
5358
for (const diff of chatDiffs) {
5459
const targetSection = newContent.find((s) => s.id === diff.sectionId);
5560
if (targetSection) {
56-
if (targetSection.replacedContent.includes(diff.search)) {
57-
targetSection.replacedContent = targetSection.replacedContent.replace(
58-
diff.search,
59-
diff.replace
60-
);
61+
const startIndex = targetSection.replacedContent.indexOf(diff.search);
62+
if (startIndex !== -1) {
63+
const endIndex = startIndex + diff.search.length;
64+
const replaceLen = diff.replace.length;
65+
const diffLen = replaceLen - diff.search.length; // 文字列長の増減分
66+
67+
// 1. 文字列の置換
68+
targetSection.replacedContent =
69+
targetSection.replacedContent.slice(0, startIndex) +
70+
diff.replace +
71+
targetSection.replacedContent.slice(endIndex);
72+
73+
// 2. 既存のハイライト範囲のズレを補正(今回の置換箇所より後ろにあるものをシフト)
74+
targetSection.replacedRange = targetSection.replacedRange.map((h) => {
75+
if (h.start >= endIndex) {
76+
// 完全に後ろにある場合は単純にシフト
77+
return {
78+
start: h.start + diffLen,
79+
end: h.end + diffLen,
80+
id: h.id,
81+
};
82+
}
83+
if (h.end >= endIndex) {
84+
return { start: h.start, end: h.end + diffLen, id: h.id };
85+
}
86+
return h;
87+
});
88+
89+
// 3. 今回の置換箇所を新たなハイライト範囲として追加
90+
targetSection.replacedRange.push({
91+
start: startIndex,
92+
end: startIndex + replaceLen,
93+
id: diff.chatId,
94+
});
6195
} else {
6296
// TODO: md5ハッシュを参照し過去バージョンのドキュメントへ適用を試みる
6397
console.error(
@@ -144,7 +178,10 @@ export function PageContent(props: PageContentProps) {
144178
}}
145179
>
146180
{/* ドキュメントのコンテンツ */}
147-
<StyledMarkdown content={section.replacedContent} />
181+
<StyledMarkdown
182+
content={section.replacedContent}
183+
replacedRange={section.replacedRange}
184+
/>
148185
</div>
149186
<div>
150187
{/* 右側に表示するチャット履歴欄 */}

app/markdown/multiHighlight.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { visit } from "unist-util-visit";
2+
import type { Plugin } from "unified";
3+
import type { Root, PhrasingContent } from "mdast";
4+
5+
export interface ReplacedRange {
6+
start: number;
7+
end: number;
8+
id: string;
9+
}
10+
export const remarkMultiHighlight: Plugin<[ReplacedRange[]], Root> = (
11+
replacedRange?: ReplacedRange[]
12+
) => {
13+
return (tree) => {
14+
visit(tree, "text", (node, index, parent) => {
15+
const nodeStart = node.position?.start?.offset;
16+
if (
17+
nodeStart === undefined ||
18+
index === undefined ||
19+
parent === undefined
20+
)
21+
return;
22+
23+
const textLen = node.value.length;
24+
const nodeEnd = nodeStart + textLen;
25+
26+
// 1. このテキストノードに被るハイライトが1つもなければスキップ (最適化)
27+
if (!replacedRange) return;
28+
const hasOverlap = replacedRange.some(
29+
(hl) => hl.start < nodeEnd && hl.end > nodeStart
30+
);
31+
if (!hasOverlap) return;
32+
33+
// 2. テキストを分割するための「境界インデックス(相対位置)」を収集
34+
let boundaries = [0, textLen]; // ノードの最初と最後は必ず入れる
35+
36+
replacedRange.forEach((hl) => {
37+
const relStart = hl.start - nodeStart;
38+
const relEnd = hl.end - nodeStart;
39+
40+
// ノードの途中にある境界だけを追加
41+
if (relStart > 0 && relStart < textLen) boundaries.push(relStart);
42+
if (relEnd > 0 && relEnd < textLen) boundaries.push(relEnd);
43+
});
44+
45+
// 3. 境界インデックスの重複を排除し、昇順にソートする
46+
boundaries = Array.from(new Set(boundaries)).sort((a, b) => a - b);
47+
48+
const newNodes: PhrasingContent[] = [];
49+
50+
// 4. 隣り合う境界ごとに区間(セグメント)を切り出す
51+
for (let i = 0; i < boundaries.length - 1; i++) {
52+
const startIdx = boundaries[i];
53+
const endIdx = boundaries[i + 1];
54+
55+
const textValue = node.value.slice(startIdx, endIdx);
56+
const absStart = nodeStart + startIdx;
57+
const absEnd = nodeStart + endIdx;
58+
59+
// 5. この区間を完全に包含しているハイライトIDをすべて抽出
60+
const activeIds = replacedRange
61+
.filter((hl) => hl.start <= absStart && hl.end >= absEnd)
62+
.map((hl) => hl.id);
63+
64+
if (activeIds.length > 0) {
65+
// 該当するハイライトがある場合、クラス名を複数付与してカスタムノード化
66+
const classNames = activeIds;
67+
68+
// カスタムタイプ('highlight')は標準のmdastに存在しないため、型アサーションでエラーを回避します
69+
newNodes.push({
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
type: "highlight" as any,
72+
data: {
73+
hName: "ins",
74+
// AST(hast)の仕様上、classNameは配列で渡すとスペース区切りで展開されます
75+
hProperties: { className: classNames },
76+
},
77+
children: [{ type: "text", value: textValue }],
78+
});
79+
} else {
80+
// どのハイライトにも含まれない場合は通常のテキスト
81+
newNodes.push({ type: "text", value: textValue });
82+
}
83+
}
84+
85+
// 6. 元のテキストノードを分割したノード群に置き換える
86+
parent.children.splice(index, 1, ...newNodes);
87+
88+
// 追加したノード分インデックスを進める
89+
return index + newNodes.length;
90+
});
91+
};
92+
};

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@
4646
"remark-gfm": "^4.0.1",
4747
"remark-remove-comments": "^1.1.1",
4848
"swr": "^2.3.6",
49+
"unist-util-visit": "^5.1.0",
4950
"zod": "^4.0.17"
5051
},
5152
"devDependencies": {
5253
"@eslint/eslintrc": "^3",
5354
"@pyodide/webpack-plugin": "^1.4.0",
5455
"@tailwindcss/postcss": "^4",
5556
"@types/js-yaml": "^4.0.9",
57+
"@types/mdast": "^4.0.4",
5658
"@types/mocha": "^10.0.10",
5759
"@types/node": "^20",
5860
"@types/pako": "^2.0.4",
@@ -72,6 +74,7 @@
7274
"tailwindcss": "^4",
7375
"tsx": "^4.20.6",
7476
"typescript": "5.9.3",
77+
"unified": "^11.0.5",
7578
"wrangler": "^4.27.0"
7679
}
7780
}

0 commit comments

Comments
 (0)