Skip to content

Commit b22e322

Browse files
authored
Merge pull request iib0011#228 from Pitchouneee/feat/223-text-compare
feat: text compare
2 parents 2a24bc1 + 44fc4e0 commit b22e322

11 files changed

Lines changed: 433 additions & 19 deletions

File tree

package-lock.json

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

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
"cron-validator": "^1.3.1",
5252
"cronstrue": "^3.0.0",
5353
"dayjs": "^1.11.13",
54+
"diff": "^8.0.2",
55+
"dompurify": "^3.4.2",
5456
"fast-xml-parser": "^5.2.5",
5557
"formik": "^2.4.6",
5658
"heic-to": "^1.4.2",

public/locales/en/string.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,13 +328,27 @@
328328
"nonSpecialCharPlaceholder": "Encode non-special characters",
329329
"title": "Encoding Options"
330330
},
331-
"inputTitle": "Input String",
332-
"resultTitle": "Url-escaped String",
331+
"inputTitle": "Input String(URL-escaped)",
332+
"resultTitle": "Output string",
333333
"toolInfo": {
334334
"description": "Load your string and it will automatically get URL-escaped.",
335335
"longDescription": "This tool URL-encodes a string. Special URL characters get converted to percent-sign encoding. This encoding is called percent-encoding because each character's numeric value gets converted to a percent sign followed by a two-digit hexadecimal value. The hex values are determined based on the character's codepoint value. For example, a space gets escaped to %20, a colon to %3a, a slash to %2f. Characters that are not special stay unchanged. In case you also need to convert non-special characters to percent-encoding, then we've also added an extra option that lets you do that. Select the encode-non-special-chars option to enable this behavior.",
336336
"shortDescription": "Quickly URL-escape a string.",
337337
"title": "String URL encoder"
338338
}
339+
},
340+
"textCompare": {
341+
"title": "Compare Texts",
342+
"description": "Analyze two text blocks to highlight differences, including additions, deletions, and changes.",
343+
"shortDescription": "Compare two texts and spot differences instantly",
344+
"longDescription": "This tool helps you quickly identify differences between two versions of a text. It highlights inserted, removed, and modified content, making it ideal for reviewing edits, tracking changes, or comparing documents. You can choose the level of precision to detect differences at the word or character level depending on your needs.",
345+
"inputTitle": "Input Text",
346+
"resultTitle": "Highlighted differences",
347+
"options": {
348+
"title": "Comparison Settings",
349+
"wordLevel": "Word level",
350+
"charLevel": "Character level",
351+
"levelDesc": "Choose how detailed the comparison should be. Word level highlights changes by words, while character level detects more granular differences."
352+
}
339353
}
340354
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { Box, Typography, CircularProgress, useTheme } from '@mui/material';
2+
import InputHeader from '../InputHeader';
3+
import ResultFooter from './ResultFooter';
4+
import { useContext } from 'react';
5+
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
6+
import { globalInputHeight } from '../../config/uiConfig';
7+
import { useTranslation } from 'react-i18next';
8+
import { stripAndDecodeHtml } from 'utils/string';
9+
import DOMPurify from 'dompurify';
10+
11+
const DOMPURIFY_CONFIG = {
12+
ALLOWED_TAGS: ['div', 'span', 'br'],
13+
ALLOWED_ATTR: ['class']
14+
};
15+
16+
export default function ToolDiffResult({
17+
title = 'Differences',
18+
value,
19+
loading,
20+
isHtml = false
21+
}: {
22+
title?: string;
23+
value: string;
24+
loading?: boolean;
25+
isHtml?: boolean;
26+
}) {
27+
const theme = useTheme();
28+
const { t } = useTranslation();
29+
const { showSnackBar } = useContext(CustomSnackBarContext);
30+
31+
const handleCopy = () => {
32+
const text = isHtml ? stripAndDecodeHtml(value) : value;
33+
navigator.clipboard
34+
.writeText(text)
35+
.then(() => showSnackBar(t('toolTextResult.copied'), 'success'))
36+
.catch((err) =>
37+
showSnackBar(t('toolTextResult.copyFailed', { error: err }), 'error')
38+
);
39+
};
40+
41+
const handleDownload = () => {
42+
const mimeType = isHtml ? 'text/html' : 'text/plain';
43+
const blob = new Blob([value], { type: mimeType });
44+
const url = window.URL.createObjectURL(blob);
45+
const a = document.createElement('a');
46+
a.href = url;
47+
a.download = isHtml ? 'diff-output.html' : 'diff-output.txt';
48+
document.body.appendChild(a);
49+
a.click();
50+
document.body.removeChild(a);
51+
setTimeout(() => window.URL.revokeObjectURL(url), 100);
52+
};
53+
54+
const sharedBoxSx = {
55+
p: 2,
56+
backgroundColor: 'background.paper',
57+
border: '1px solid',
58+
borderColor: 'divider',
59+
borderRadius: 1,
60+
fontFamily: 'monospace',
61+
whiteSpace: 'pre-wrap',
62+
wordBreak: 'break-word',
63+
minHeight: '265px'
64+
} as const;
65+
66+
return (
67+
<Box>
68+
<InputHeader title={title} />
69+
{loading ? (
70+
<Box
71+
sx={{
72+
display: 'flex',
73+
flexDirection: 'column',
74+
alignItems: 'center',
75+
justifyContent: 'center',
76+
height: globalInputHeight
77+
}}
78+
>
79+
<CircularProgress />
80+
<Typography variant="body2" sx={{ mt: 2 }}>
81+
{t('toolTextResult.loading')}
82+
</Typography>
83+
</Box>
84+
) : isHtml ? (
85+
<Box
86+
sx={{
87+
...sharedBoxSx,
88+
'& .diff-added': {
89+
backgroundColor:
90+
theme.palette.mode === 'dark'
91+
? theme.palette.success.dark
92+
: theme.palette.success.light,
93+
color: theme.palette.success.contrastText
94+
},
95+
'& .diff-removed': {
96+
backgroundColor:
97+
theme.palette.mode === 'dark'
98+
? theme.palette.error.dark
99+
: theme.palette.error.light,
100+
color: theme.palette.error.contrastText
101+
}
102+
}}
103+
dangerouslySetInnerHTML={{
104+
__html: DOMPurify.sanitize(value, DOMPURIFY_CONFIG)
105+
}}
106+
/>
107+
) : (
108+
<Box sx={sharedBoxSx}>{value}</Box>
109+
)}
110+
<ResultFooter handleCopy={handleCopy} handleDownload={handleDownload} />
111+
</Box>
112+
);
113+
}

src/pages/tools/string/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import { tool as stringStatistic } from './statistic/meta';
2121
import { tool as stringCensor } from './censor/meta';
2222
import { tool as stringPasswordGenerator } from './password-generator/meta';
2323
import { tool as stringEncodeUrl } from './url-encode/meta';
24-
import { tool as StringDecodeUrl } from './url-decode/meta';
24+
import { tool as stringDecodeUrl } from './url-decode/meta';
25+
import { tool as stringCompare } from './text-compare/meta';
2526
import { tool as stringUnicode } from './unicode/meta';
2627

2728
export const stringTools = [
@@ -46,8 +47,9 @@ export const stringTools = [
4647
stringCensor,
4748
stringPasswordGenerator,
4849
stringEncodeUrl,
49-
StringDecodeUrl,
50+
stringDecodeUrl,
5051
stringUnicode,
5152
stringHiddenCharacterDetector,
53+
stringCompare,
5254
stringSlugGenerator
5355
];
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Box } from '@mui/material';
2+
import { useState, useEffect } from 'react';
3+
import ToolContent from '@components/ToolContent';
4+
import { ToolComponentProps } from '@tools/defineTool';
5+
import { GetGroupsType } from '@components/options/ToolOptions';
6+
import ToolTextInput from '@components/input/ToolTextInput';
7+
import ToolDiffResult from '@components/result/ToolDiffResult';
8+
import SelectWithDesc from '@components/options/SelectWithDesc';
9+
import { useTranslation } from 'react-i18next';
10+
import { compareTextsHtml } from './service';
11+
import { level, InitialValuesType } from './types';
12+
13+
const initialValues: InitialValuesType = {
14+
level: 'word'
15+
};
16+
17+
export default function TextCompare({ title }: ToolComponentProps) {
18+
const { t } = useTranslation('string');
19+
const [inputA, setInputA] = useState<string>('');
20+
const [inputB, setInputB] = useState<string>('');
21+
const [level, setLevel] = useState<level>('word');
22+
const [result, setResult] = useState<string>('');
23+
24+
const compute = () => {
25+
setResult(compareTextsHtml(inputA, inputB, level));
26+
};
27+
28+
useEffect(() => {
29+
compute();
30+
}, [inputA, inputB, level]);
31+
32+
const getGroups: GetGroupsType<InitialValuesType> = ({
33+
values,
34+
updateField
35+
}) => [
36+
{
37+
title: t('textCompare.options.title'),
38+
component: (
39+
<Box>
40+
<SelectWithDesc
41+
selected={values.level}
42+
options={[
43+
{ label: t('textCompare.options.wordLevel'), value: 'word' },
44+
{ label: t('textCompare.options.charLevel'), value: 'char' }
45+
]}
46+
onChange={(value) => {
47+
updateField('level', value);
48+
setLevel(value);
49+
}}
50+
description={t('textCompare.options.levelDesc')}
51+
/>
52+
</Box>
53+
)
54+
}
55+
];
56+
57+
return (
58+
<ToolContent
59+
title={title}
60+
input={inputA}
61+
inputComponent={
62+
<Box display="flex" flexDirection="column" gap={2}>
63+
<ToolTextInput
64+
value={inputA}
65+
onChange={setInputA}
66+
title={t('textCompare.inputTitle')}
67+
/>
68+
<ToolTextInput
69+
value={inputB}
70+
onChange={setInputB}
71+
title={t('textCompare.inputTitle')}
72+
/>
73+
</Box>
74+
}
75+
resultComponent={
76+
<ToolDiffResult
77+
value={result}
78+
title={t('textCompare.resultTitle')}
79+
isHtml
80+
/>
81+
}
82+
initialValues={initialValues}
83+
getGroups={getGroups}
84+
setInput={() => {
85+
setInputA('');
86+
setInputB('');
87+
setResult('');
88+
}}
89+
compute={compute}
90+
toolInfo={{
91+
title: t('textCompare.title'),
92+
description: t('textCompare.longDescription')
93+
}}
94+
/>
95+
);
96+
}

0 commit comments

Comments
 (0)