Skip to content

Commit 5b3be11

Browse files
feat: highlight inline diff additions and removals inside lines
1 parent 2a47bad commit 5b3be11

17 files changed

Lines changed: 270 additions & 81 deletions

File tree

app/(navigation)/(code)/components/Editor.module.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@
6262
}
6363
}
6464

65+
:global(.diff-word-add) {
66+
border-radius: 2px;
67+
background-color: rgba(46, 160, 67, 0.4) !important;
68+
}
69+
6570
:global(.diff-remove) {
6671
background-color: rgba(248, 81, 73, 0.15) !important;
6772

@@ -74,6 +79,11 @@
7479
font-weight: 600;
7580
}
7681
}
82+
83+
:global(.diff-word-remove) {
84+
border-radius: 2px;
85+
background-color: rgba(248, 81, 73, 0.4) !important;
86+
}
7787
}
7888

7989
.isHighlightingLines {

app/(navigation)/(code)/components/HighlightedCode.tsx

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,96 @@ import styles from "./Editor.module.css";
66
import { highlightedLinesAtom, highlighterAtom, loadingLanguageAtom, diffModeAtom } from "../store";
77
import { useAtom, useAtomValue, useSetAtom } from "jotai";
88
import { themeDarkModeAtom, themeAtom } from "../store/themes";
9+
import { diffWordsWithSpace } from "diff";
910

1011
type PropTypes = {
1112
selectedLanguage: Language | null;
1213
code: string;
1314
};
1415

16+
interface InlineRange {
17+
start: number;
18+
end: number;
19+
type: "add" | "remove";
20+
}
21+
22+
function applyInlineDiffs(lineNode: any, ranges: InlineRange[]) {
23+
if (!ranges || ranges.length === 0) return;
24+
25+
const textNodes: { parent: any; textNode: any; text: string; offset: number }[] = [];
26+
let currentOffset = 0;
27+
28+
function walk(node: any) {
29+
if (node.type === "text") {
30+
textNodes.push({ parent: null, textNode: node, text: node.value, offset: currentOffset });
31+
currentOffset += node.value.length;
32+
} else if (node.children) {
33+
for (const child of node.children) {
34+
const startLen = textNodes.length;
35+
walk(child);
36+
const endLen = textNodes.length;
37+
for (let i = startLen; i < endLen; i++) {
38+
if (!textNodes[i].parent) textNodes[i].parent = node;
39+
}
40+
}
41+
}
42+
}
43+
walk(lineNode);
44+
45+
const tokenToNewChildren = new Map<any, any[]>();
46+
47+
for (const item of textNodes) {
48+
const { parent, textNode, text, offset } = item;
49+
const tokenStart = offset;
50+
const tokenEnd = offset + text.length;
51+
52+
const intersectingRanges = ranges.filter((r) => r.start < tokenEnd && r.end > tokenStart);
53+
54+
if (intersectingRanges.length === 0) {
55+
if (!tokenToNewChildren.has(parent)) {
56+
tokenToNewChildren.set(parent, []);
57+
}
58+
tokenToNewChildren.get(parent)!.push(textNode);
59+
continue;
60+
}
61+
62+
let currentIdx = 0;
63+
const newChildren = [];
64+
65+
for (const r of intersectingRanges) {
66+
const rStartInText = Math.max(0, r.start - tokenStart);
67+
if (rStartInText > currentIdx) {
68+
newChildren.push({ type: "text", value: text.substring(currentIdx, rStartInText) });
69+
}
70+
71+
const rEndInText = Math.min(text.length, r.end - tokenStart);
72+
if (rEndInText > rStartInText) {
73+
const className = r.type === "add" ? "diff-word-add" : "diff-word-remove";
74+
newChildren.push({
75+
type: "element",
76+
tagName: "span",
77+
properties: { className: [className] },
78+
children: [{ type: "text", value: text.substring(Math.max(currentIdx, rStartInText), rEndInText) }],
79+
});
80+
currentIdx = rEndInText;
81+
}
82+
}
83+
84+
if (currentIdx < text.length) {
85+
newChildren.push({ type: "text", value: text.substring(currentIdx) });
86+
}
87+
88+
if (!tokenToNewChildren.has(parent)) {
89+
tokenToNewChildren.set(parent, []);
90+
}
91+
tokenToNewChildren.get(parent)!.push(...newChildren);
92+
}
93+
94+
tokenToNewChildren.forEach((newChildren, parent) => {
95+
parent.children = newChildren;
96+
});
97+
}
98+
1599
const HighlightedCode: React.FC<PropTypes> = ({ selectedLanguage, code }) => {
16100
const [highlightedHtml, setHighlightedHtml] = useState("");
17101
const highlighter = useAtomValue(highlighterAtom);
@@ -42,6 +126,49 @@ const HighlightedCode: React.FC<PropTypes> = ({ selectedLanguage, code }) => {
42126
lang = "tsx";
43127
}
44128

129+
const lineDiffs: Record<number, InlineRange[]> = {};
130+
const lines = code.split("\n");
131+
132+
if (diffMode) {
133+
for (let i = 0; i < lines.length; i++) {
134+
if (lines[i].startsWith("-") && i + 1 < lines.length && lines[i + 1].startsWith("+")) {
135+
const removedCode = lines[i].substring(1);
136+
const addedCode = lines[i + 1].substring(1);
137+
const changes = diffWordsWithSpace(removedCode, addedCode);
138+
139+
let removedOffset = 1;
140+
let addedOffset = 1;
141+
142+
const removedRanges: InlineRange[] = [];
143+
const addedRanges: InlineRange[] = [];
144+
145+
for (const part of changes) {
146+
if (part.added) {
147+
addedRanges.push({
148+
start: addedOffset,
149+
end: addedOffset + part.value.length,
150+
type: "add",
151+
});
152+
addedOffset += part.value.length;
153+
} else if (part.removed) {
154+
removedRanges.push({
155+
start: removedOffset,
156+
end: removedOffset + part.value.length,
157+
type: "remove",
158+
});
159+
removedOffset += part.value.length;
160+
} else {
161+
addedOffset += part.value.length;
162+
removedOffset += part.value.length;
163+
}
164+
}
165+
166+
lineDiffs[i + 1] = removedRanges;
167+
lineDiffs[i + 2] = addedRanges;
168+
}
169+
}
170+
}
171+
45172
return highlighter.codeToHtml(code, {
46173
lang: lang,
47174
theme: themeName,
@@ -53,12 +180,16 @@ const HighlightedCode: React.FC<PropTypes> = ({ selectedLanguage, code }) => {
53180

54181
// Handle diff mode
55182
if (diffMode) {
56-
const lineContent = code.split("\n")[line - 1];
183+
const lineContent = lines[line - 1];
57184
if (lineContent?.startsWith("+")) {
58185
this.addClassToHast(node, "diff-add");
59186
} else if (lineContent?.startsWith("-")) {
60187
this.addClassToHast(node, "diff-remove");
61188
}
189+
190+
if (lineDiffs[line]) {
191+
applyInlineDiffs(node, lineDiffs[line]);
192+
}
62193
}
63194
},
64195
},

app/(navigation)/icon/components/ExportModal.tsx

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useState, useEffect } from "react";
3+
import React, { useState } from "react";
44
import cn from "classnames";
55

66
import { PlusIcon, TrashIcon } from "@raycast/icons";
@@ -16,7 +16,6 @@ import download from "../../(code)/util/download";
1616
type ExportFormat = "PNG" | "SVG";
1717

1818
type ExportOption = {
19-
fileName: string;
2019
format: ExportFormat;
2120
size: number;
2221
};
@@ -58,32 +57,17 @@ type ExportModalProps = {
5857
function ExportModal({ open, onOpenChange, onStartExport, fileName, svgRef }: ExportModalProps) {
5958
const [exportOptions, setExportOptions] = useState<ExportOption[]>([
6059
{
61-
fileName,
6260
format: "PNG",
6361
size: 512,
6462
},
6563
]);
6664

67-
// Update export options when fileName changes
68-
useEffect(() => {
69-
setExportOptions((prevOptions) =>
70-
prevOptions.map((option, index) => {
71-
if (index === 0) {
72-
// Update the first option's fileName to match the current fileName
73-
return {
74-
...option,
75-
fileName: option.format === "SVG" ? fileName : fileName,
76-
};
77-
} else {
78-
// Update other options to use the new fileName as base
79-
return {
80-
...option,
81-
fileName: option.format === "SVG" ? fileName : `${fileName}@${option.size}px`,
82-
};
83-
}
84-
}),
85-
);
86-
}, [fileName]);
65+
const getExportFileName = (option: ExportOption, index: number) => {
66+
if (option.format === "SVG") {
67+
return fileName;
68+
}
69+
return index === 0 ? fileName : `${fileName}@${option.size}px`;
70+
};
8771

8872
const onExport = async () => {
8973
onOpenChange(false);
@@ -95,7 +79,7 @@ function ExportModal({ open, onOpenChange, onStartExport, fileName, svgRef }: Ex
9579
// Export each option sequentially to avoid browser download blocking
9680
for (let i = 0; i < exportOptions.length; i++) {
9781
const option = exportOptions[i];
98-
await Exporters[option.format](svgRef, option.fileName, option.size);
82+
await Exporters[option.format](svgRef, getExportFileName(option, i), option.size);
9983
// Add a small delay between downloads to prevent browser blocking
10084
if (i < exportOptions.length - 1) {
10185
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -112,7 +96,6 @@ function ExportModal({ open, onOpenChange, onStartExport, fileName, svgRef }: Ex
11296
{
11397
format: "PNG",
11498
size: newSize,
115-
fileName: `${fileName}@${newSize}px`,
11699
},
117100
]);
118101
};
@@ -133,11 +116,7 @@ function ExportModal({ open, onOpenChange, onStartExport, fileName, svgRef }: Ex
133116
const newExportOption = {
134117
...exportOption,
135118
[key]: value,
136-
fileName: updateIndex != 0 && key === "size" ? `${fileName}@${value}px` : exportOption.fileName,
137119
};
138-
if (newExportOption.format === "SVG") {
139-
newExportOption.fileName = fileName;
140-
}
141120
const newExportOptions = exportOptions.reduce((acc: ExportOption[], value, index) => {
142121
acc.push(updateIndex === index ? newExportOption : value);
143122
return acc;
@@ -152,7 +131,7 @@ function ExportModal({ open, onOpenChange, onStartExport, fileName, svgRef }: Ex
152131
{exportOptions.map((option, index) => (
153132
<div className={styles.exportOption} key={index}>
154133
<div className={styles.exportOptionFileName}>
155-
{option.fileName}.{option.format.toLowerCase()}
134+
{getExportFileName(option, index)}.{option.format.toLowerCase()}
156135
</div>
157136
<div className={styles.exportOptionSettings}>
158137
<div className={styles.exportOptionSize}>

app/(navigation)/icon/components/ResultIcon.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@ import React, { useId } from "react";
22

33
import noisePicture from "../assets/noise.inline.png";
44

5-
import { SettingsType } from "../lib/types";
5+
import type { IconComponentType, SettingsType } from "../lib/types";
66

77
type PropTypes = {
88
settings: SettingsType;
99
size?: number;
1010
isPreview?: boolean;
11-
// TODO: fix icon type?
12-
IconComponent?: React.FC<React.SVGProps<SVGSVGElement>>;
11+
IconComponent?: IconComponentType;
1312
};
1413

1514
const ResultIcon = React.forwardRef<SVGSVGElement, PropTypes>(

0 commit comments

Comments
 (0)