Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 18 additions & 2 deletions public/locales/en/string.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
91 changes: 91 additions & 0 deletions src/components/result/ToolDiffResult.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
8 changes: 5 additions & 3 deletions src/pages/tools/string/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -44,6 +45,7 @@ export const stringTools = [
stringCensor,
stringPasswordGenerator,
stringEncodeUrl,
StringDecodeUrl,
stringHiddenCharacterDetector
stringDecodeUrl,
stringHiddenCharacterDetector,
stringCompare
];
49 changes: 49 additions & 0 deletions src/pages/tools/string/text-compare/index.tsx
Original file line number Diff line number Diff line change
@@ -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')
}}
/>
);
}
15 changes: 15 additions & 0 deletions src/pages/tools/string/text-compare/meta.ts
Original file line number Diff line number Diff line change
@@ -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'))
});
24 changes: 24 additions & 0 deletions src/pages/tools/string/text-compare/service.ts
Original file line number Diff line number Diff line change
@@ -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>`;
}
Original file line number Diff line number Diff line change
@@ -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>');
});
});