This commit is contained in:
Corentin BRINGER 2025-10-29 21:07:43 +01:00 committed by GitHub
commit b10988d2bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 285 additions and 9 deletions

18
package-lock.json generated
View file

@ -32,6 +32,7 @@
"cron-validator": "^1.3.1",
"cronstrue": "^3.0.0",
"dayjs": "^1.11.13",
"diff": "^8.0.2",
"fast-xml-parser": "^5.2.5",
"formik": "^2.4.6",
"heic2any": "^0.0.4",
@ -5631,10 +5632,9 @@
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"dev": true,
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@ -8993,6 +8993,16 @@
"node": "^12.20.0 || >=14"
}
},
"node_modules/locize-cli/node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/locize-cli/node_modules/glob": {
"version": "9.3.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz",

View file

@ -51,6 +51,7 @@
"cron-validator": "^1.3.1",
"cronstrue": "^3.0.0",
"dayjs": "^1.11.13",
"diff": "^8.0.2",
"fast-xml-parser": "^5.2.5",
"formik": "^2.4.6",
"heic2any": "^0.0.4",

View file

@ -299,13 +299,29 @@
"nonSpecialCharPlaceholder": "Encode non-special characters",
"title": "Encoding Options"
},
"inputTitle": "Input String",
"resultTitle": "Url-escaped String",
"inputTitle": "Input String(URL-escaped)",
"resultTitle": "Output string",
"toolInfo": {
"description": "Load your string and it will automatically get URL-escaped.",
"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.",
"shortDescription": "Quickly URL-escape a string.",
"title": "String URL encoder"
}
},
"textCompare": {
"title": "Compare Texts",
"description": "Identify differences between two text blocks, highlighting insertions, deletions, and modifications.",
"shortDescription": "Compare two texts",
"longDescription": "",
"withLabel": "Options",
"outputOptions": "Comparison Options",
"addDiffHighlight": "Highlight differences",
"addDiffHighlightDescription": "Enable syntax highlighting to better visualize changes between texts",
"ignoreWhitespace": "Ignore Whitespace",
"ignoreWhitespaceDescription": "Ignore changes that are only due to whitespace (e.g., spaces, tabs, line breaks)",
"toolInfo": {
"title": "Compare Texts",
"description": "This tool compares two text inputs and highlights the differences (insertions, deletions, and substitutions). It's useful for reviewing code changes, document revisions, or analyzing updates between versions of a file or message."
}
}
}

View file

@ -0,0 +1,91 @@
import { Box, Typography } from '@mui/material';
import InputHeader from '../InputHeader';
import ResultFooter from './ResultFooter';
import { useContext } from 'react';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { useTranslation } from 'react-i18next';
export default function ToolDiffResult({
title = 'Differences',
value,
loading,
isHtml = false
}: {
title?: string;
value: string;
loading?: boolean;
isHtml?: boolean;
}) {
const { t } = useTranslation();
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleCopy = () => {
navigator.clipboard
.writeText(value.replace(/<[^>]*>/g, ''))
.then(() => showSnackBar(t('toolTextResult.copied'), 'success'))
.catch((err) =>
showSnackBar(t('toolTextResult.copyFailed', { error: err }), 'error')
);
};
const handleDownload = () => {
const blob = new Blob([value], { type: 'text/html' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'diff-output.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
};
return (
<Box>
<InputHeader title={title} />
{loading ? (
<Typography variant="body2">{t('toolTextResult.loading')}</Typography>
) : isHtml ? (
<Box
sx={{
p: 2,
backgroundColor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
'& .diff-line': {
whiteSpace: 'pre-wrap',
fontFamily: 'monospace'
},
'& .diff-added': {
backgroundColor: '#c8f7c5' // green
},
'& .diff-removed': {
backgroundColor: '#ffe0e0' // red
}
}}
dangerouslySetInnerHTML={{ __html: value }}
/>
) : (
<Box
sx={{
p: 2,
backgroundColor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{value}
</Box>
)}
<ResultFooter handleCopy={handleCopy} handleDownload={handleDownload} />
</Box>
);
}

View file

@ -20,7 +20,8 @@ import { tool as stringStatistic } from './statistic/meta';
import { tool as stringCensor } from './censor/meta';
import { tool as stringPasswordGenerator } from './password-generator/meta';
import { tool as stringEncodeUrl } from './url-encode/meta';
import { tool as StringDecodeUrl } from './url-decode/meta';
import { tool as stringDecodeUrl } from './url-decode/meta';
import { tool as stringCompare } from './text-compare/meta';
export const stringTools = [
stringSplit,
@ -44,6 +45,7 @@ export const stringTools = [
stringCensor,
stringPasswordGenerator,
stringEncodeUrl,
StringDecodeUrl,
stringHiddenCharacterDetector
stringDecodeUrl,
stringHiddenCharacterDetector,
stringCompare
];

View file

@ -0,0 +1,49 @@
import { Box } from '@mui/material';
import { useState, useEffect } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolDiffResult from '@components/result/ToolDiffResult';
import { useTranslation } from 'react-i18next';
import { compareTextsHtml } from './service';
export default function TextCompare({ title }: ToolComponentProps) {
const { t } = useTranslation('string');
const [inputA, setInputA] = useState<string>('');
const [inputB, setInputB] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = () => {
setResult(compareTextsHtml(inputA, inputB));
};
useEffect(() => {
compute();
}, [inputA, inputB]);
return (
<ToolContent
title={title}
input={inputA}
inputComponent={
<Box display="flex" flexDirection="column" gap={2}>
<ToolTextInput value={inputA} onChange={setInputA} />
<ToolTextInput value={inputB} onChange={setInputB} />
</Box>
}
resultComponent={<ToolDiffResult value={result} isHtml />}
initialValues={{}}
getGroups={null}
setInput={() => {
setInputA('');
setInputB('');
setResult('');
}}
compute={compute}
toolInfo={{
title: t('textCompare.toolInfo.title'),
description: t('textCompare.toolInfo.description')
}}
/>
);
}

View file

@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('string', {
i18n: {
name: 'string:textCompare.title',
description: 'string:textCompare.description',
shortDescription: 'string:textCompare.shortDescription',
longDescription: 'string:textCompare.longDescription'
},
path: 'text-compare',
icon: 'material-symbols-light:search',
keywords: ['text', 'compare'],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,24 @@
import { diffWordsWithSpace } from 'diff';
function escapeHtml(str: string): string {
return str.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function compareTextsHtml(textA: string, textB: string): string {
const diffs = diffWordsWithSpace(textA, textB);
const html = diffs
.map((part) => {
const val = escapeHtml(part.value).replace(/ /g, '&nbsp;');
if (part.added) {
return `<span class="diff-added">${val}</span>`;
}
if (part.removed) {
return `<span class="diff-removed">${val}</span>`;
}
return `<span>${val}</span>`;
})
.join('');
return `<div class="diff-line">${html}</div>`;
}

View file

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { compareTextsHtml } from './service';
describe('compareTextsHtml', () => {
it('should highlight added text', () => {
const textA = 'Hello world';
const textB = 'Hello world here';
const result = compareTextsHtml(textA, textB);
expect(result).toContain('<span class="diff-added">&nbsp;here</span>');
});
it('should highlight removed text', () => {
const textA = 'Hello world here';
const textB = 'Hello world';
const result = compareTextsHtml(textA, textB);
expect(result).toContain('<span class="diff-removed">&nbsp;here</span>');
});
it('should highlight changes in the middle of a sentence', () => {
const textA = 'I am in Lyon';
const textB = 'I am in Marseille';
const result = compareTextsHtml(textA, textB);
expect(result).toContain('I&nbsp;am&nbsp;in&nbsp;');
expect(result).toContain('<span class="diff-removed">Lyon</span>');
expect(result).toContain('<span class="diff-added">Marseille</span>');
});
it('should return plain diff if texts are identical', () => {
const input = 'Same text everywhere';
const result = compareTextsHtml(input, input);
expect(result).toContain('<span>Same&nbsp;text&nbsp;everywhere</span>');
expect(result).not.toContain('diff-added');
expect(result).not.toContain('diff-removed');
});
it('should escape HTML characters', () => {
const textA = '<b>Hello</b>';
const textB = '<b>Hello world</b>';
const result = compareTextsHtml(textA, textB);
expect(result).toContain('&lt;b&gt;Hello');
expect(result).toContain('<span class="diff-added">&nbsp;world</span>');
});
it('should wrap result in a single diff-line div', () => {
const result = compareTextsHtml('foo', 'bar');
expect(result.startsWith('<div class="diff-line">')).toBe(true);
expect(result.endsWith('</div>')).toBe(true);
});
it('should handle empty input strings', () => {
const result = compareTextsHtml('', '');
expect(result).toBe('<div class="diff-line"></div>');
});
it('should handle only added input', () => {
const result = compareTextsHtml('', 'New text');
expect(result).toContain('<span class="diff-added">New&nbsp;text</span>');
});
it('should handle only removed input', () => {
const result = compareTextsHtml('Old text', '');
expect(result).toContain('<span class="diff-removed">Old&nbsp;text</span>');
});
});