From 9ef1ac28330134f826db87ba0665c1df7dc24e89 Mon Sep 17 00:00:00 2001 From: Corentin Bringer Date: Fri, 25 Jul 2025 18:32:00 +0200 Subject: [PATCH 01/11] feat: add text compare 223 --- package-lock.json | 18 +++- package.json | 1 + public/locales/en/string.json | 17 +++- src/components/result/ToolDiffResult.tsx | 91 +++++++++++++++++++ src/pages/tools/string/index.ts | 6 +- src/pages/tools/string/text-compare/index.tsx | 49 ++++++++++ src/pages/tools/string/text-compare/meta.ts | 15 +++ .../tools/string/text-compare/service.ts | 24 +++++ .../text-compare/text-compare.service.test.ts | 72 +++++++++++++++ 9 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 src/components/result/ToolDiffResult.tsx create mode 100644 src/pages/tools/string/text-compare/index.tsx create mode 100644 src/pages/tools/string/text-compare/meta.ts create mode 100644 src/pages/tools/string/text-compare/service.ts create mode 100644 src/pages/tools/string/text-compare/text-compare.service.test.ts diff --git a/package-lock.json b/package-lock.json index 835edf1..69ad16c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", "i18next": "^25.3.2", @@ -5433,10 +5434,9 @@ "dev": true }, "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" @@ -8409,6 +8409,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", diff --git a/package.json b/package.json index 494c547..2c66ca3 100644 --- a/package.json +++ b/package.json @@ -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", "i18next": "^25.3.2", diff --git a/public/locales/en/string.json b/public/locales/en/string.json index 2994890..422e267 100644 --- a/public/locales/en/string.json +++ b/public/locales/en/string.json @@ -280,8 +280,23 @@ "longDescription": "This tool URL-decodes a previously URL-encoded string. URL-decoding is the inverse operation of URL-encoding. All percent-encoded characters get decoded to characters that you can understand. Some of the most well known percent-encoded values are %20 for a space, %3a for a colon, %2f for a slash, and %3f for a question mark. The two digits following the percent sign are character's char code values in hex.", "title": "String URL decoder" }, - "inputTitle": "Input String(URL-escaped)", "resultTitle": "Output string" + }, + "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." + } } } diff --git a/src/components/result/ToolDiffResult.tsx b/src/components/result/ToolDiffResult.tsx new file mode 100644 index 0000000..6751217 --- /dev/null +++ b/src/components/result/ToolDiffResult.tsx @@ -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 ( + + + {loading ? ( + {t('toolTextResult.loading')} + ) : isHtml ? ( + + ) : ( + + {value} + + )} + + + ); +} diff --git a/src/pages/tools/string/index.ts b/src/pages/tools/string/index.ts index bd645f7..ffd26ca 100644 --- a/src/pages/tools/string/index.ts +++ b/src/pages/tools/string/index.ts @@ -19,7 +19,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, @@ -43,5 +44,6 @@ export const stringTools = [ stringCensor, stringPasswordGenerator, stringEncodeUrl, - StringDecodeUrl + stringDecodeUrl, + stringCompare ]; diff --git a/src/pages/tools/string/text-compare/index.tsx b/src/pages/tools/string/text-compare/index.tsx new file mode 100644 index 0000000..1c57dce --- /dev/null +++ b/src/pages/tools/string/text-compare/index.tsx @@ -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(''); + const [inputB, setInputB] = useState(''); + const [result, setResult] = useState(''); + + const compute = () => { + setResult(compareTextsHtml(inputA, inputB)); + }; + + useEffect(() => { + compute(); + }, [inputA, inputB]); + + return ( + + + + + } + resultComponent={} + initialValues={{}} + getGroups={null} + setInput={() => { + setInputA(''); + setInputB(''); + setResult(''); + }} + compute={compute} + toolInfo={{ + title: t('textCompare.toolInfo.title'), + description: t('textCompare.toolInfo.description') + }} + /> + ); +} diff --git a/src/pages/tools/string/text-compare/meta.ts b/src/pages/tools/string/text-compare/meta.ts new file mode 100644 index 0000000..8329b2c --- /dev/null +++ b/src/pages/tools/string/text-compare/meta.ts @@ -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')) +}); diff --git a/src/pages/tools/string/text-compare/service.ts b/src/pages/tools/string/text-compare/service.ts new file mode 100644 index 0000000..2b4141c --- /dev/null +++ b/src/pages/tools/string/text-compare/service.ts @@ -0,0 +1,24 @@ +import { diffWordsWithSpace } from 'diff'; + +function escapeHtml(str: string): string { + return str.replace(//g, '>'); +} + +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, ' '); + if (part.added) { + return `${val}`; + } + if (part.removed) { + return `${val}`; + } + return `${val}`; + }) + .join(''); + + return `
${html}
`; +} diff --git a/src/pages/tools/string/text-compare/text-compare.service.test.ts b/src/pages/tools/string/text-compare/text-compare.service.test.ts new file mode 100644 index 0000000..c2e3578 --- /dev/null +++ b/src/pages/tools/string/text-compare/text-compare.service.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { compareTextsHtml } from './service'; + +describe('compareTextsHtml', () => { + it('should highlight added text', () => { + const textA = 'Bonjour tout le monde'; + const textB = 'Bonjour tout le monde ici'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain(' ici'); + }); + + it('should highlight removed text', () => { + const textA = 'Bonjour tout le monde ici'; + const textB = 'Bonjour tout le monde'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain(' ici'); + }); + + it('should highlight changes in the middle of a sentence', () => { + const textA = 'Je suis à Lyon'; + const textB = 'Je suis à Marseille'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain('Je suis à '); + expect(result).toContain('Lyon'); + expect(result).toContain('Marseille'); + }); + + it('should return empty diff if texts are identical', () => { + const input = 'Même texte partout'; + const result = compareTextsHtml(input, input); + expect(result).toContain('Même texte partout'); + expect(result).not.toContain('diff-added'); + expect(result).not.toContain('diff-removed'); + }); + + it('should handle HTML escaping', () => { + const textA = 'Hello'; + const textB = 'Hello world'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain('<b>Hello'); + expect(result).toContain(' world'); + }); + + it('should return a single div with class diff-line', () => { + const result = compareTextsHtml('foo', 'bar'); + expect(result.startsWith('
')).toBe(true); + expect(result.endsWith('
')).toBe(true); + }); + + it('should handle empty strings', () => { + const result = compareTextsHtml('', ''); + expect(result).toBe('
'); + }); + + it('should handle only added content', () => { + const result = compareTextsHtml('', 'Nouveau texte'); + expect(result).toContain( + 'Nouveau texte' + ); + }); + + it('should handle only removed content', () => { + const result = compareTextsHtml('Ancien texte', ''); + expect(result).toContain( + 'Ancien texte' + ); + }); +}); From 170bdfad8a6463bb36549859307b1ab74c8094f4 Mon Sep 17 00:00:00 2001 From: Corentin Bringer Date: Fri, 25 Jul 2025 18:34:06 +0200 Subject: [PATCH 02/11] chore: set text in english not the best langage, french --- .../text-compare/text-compare.service.test.ts | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/pages/tools/string/text-compare/text-compare.service.test.ts b/src/pages/tools/string/text-compare/text-compare.service.test.ts index c2e3578..d5a0816 100644 --- a/src/pages/tools/string/text-compare/text-compare.service.test.ts +++ b/src/pages/tools/string/text-compare/text-compare.service.test.ts @@ -3,40 +3,40 @@ import { compareTextsHtml } from './service'; describe('compareTextsHtml', () => { it('should highlight added text', () => { - const textA = 'Bonjour tout le monde'; - const textB = 'Bonjour tout le monde ici'; + const textA = 'Hello world'; + const textB = 'Hello world here'; const result = compareTextsHtml(textA, textB); - expect(result).toContain(' ici'); + expect(result).toContain(' here'); }); it('should highlight removed text', () => { - const textA = 'Bonjour tout le monde ici'; - const textB = 'Bonjour tout le monde'; + const textA = 'Hello world here'; + const textB = 'Hello world'; const result = compareTextsHtml(textA, textB); - expect(result).toContain(' ici'); + expect(result).toContain(' here'); }); it('should highlight changes in the middle of a sentence', () => { - const textA = 'Je suis à Lyon'; - const textB = 'Je suis à Marseille'; + const textA = 'I am in Lyon'; + const textB = 'I am in Marseille'; const result = compareTextsHtml(textA, textB); - expect(result).toContain('Je suis à '); + expect(result).toContain('I am in '); expect(result).toContain('Lyon'); expect(result).toContain('Marseille'); }); - it('should return empty diff if texts are identical', () => { - const input = 'Même texte partout'; + it('should return plain diff if texts are identical', () => { + const input = 'Same text everywhere'; const result = compareTextsHtml(input, input); - expect(result).toContain('Même texte partout'); + expect(result).toContain('Same text everywhere'); expect(result).not.toContain('diff-added'); expect(result).not.toContain('diff-removed'); }); - it('should handle HTML escaping', () => { + it('should escape HTML characters', () => { const textA = 'Hello'; const textB = 'Hello world'; @@ -45,28 +45,24 @@ describe('compareTextsHtml', () => { expect(result).toContain(' world'); }); - it('should return a single div with class diff-line', () => { + it('should wrap result in a single diff-line div', () => { const result = compareTextsHtml('foo', 'bar'); expect(result.startsWith('
')).toBe(true); expect(result.endsWith('
')).toBe(true); }); - it('should handle empty strings', () => { + it('should handle empty input strings', () => { const result = compareTextsHtml('', ''); expect(result).toBe('
'); }); - it('should handle only added content', () => { - const result = compareTextsHtml('', 'Nouveau texte'); - expect(result).toContain( - 'Nouveau texte' - ); + it('should handle only added input', () => { + const result = compareTextsHtml('', 'New text'); + expect(result).toContain('New text'); }); - it('should handle only removed content', () => { - const result = compareTextsHtml('Ancien texte', ''); - expect(result).toContain( - 'Ancien texte' - ); + it('should handle only removed input', () => { + const result = compareTextsHtml('Old text', ''); + expect(result).toContain('Old text'); }); }); From fd198ccf03fd9fd99f16320910aab18da77f2c93 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 21:52:45 +0200 Subject: [PATCH 03/11] chore: string utils --- src/utils/string.ts | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/utils/string.ts b/src/utils/string.ts index b5a4cc0..e874c28 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -138,3 +138,49 @@ export const getI18nNamespaceFromToolCategory = ( return 'translation'; }; + +/** + * Strips HTML tags from a string and decodes HTML entities into plain text. + * + * This function parses the provided HTML string using the browser's DOMParser, + * extracts the text content, and returns a clean, human-readable string. + * + * @param html - A string containing HTML markup + * @returns A plain text string with all HTML tags removed and entities decoded + * + * @example + * stripAndDecodeHtml('

Hello world

') + * // returns: 'Hello world' + * + * @example + * stripAndDecodeHtml('<div>Test</div>') + * // returns: '
Test
' + */ +export function stripAndDecodeHtml(html: string): string { + const doc = new DOMParser().parseFromString(html, 'text/html'); + return doc.body.textContent ?? ''; +} + +/** + * Escapes special HTML characters in a string to prevent HTML injection. + * + * Replaces the following characters with their corresponding HTML entities: + * - & → & + * - < → < + * - > → > + * - " → " + * + * @param {string} str - The input string to escape. + * @returns {string} The escaped string safe for insertion into HTML. + * + * @example + * escapeHtml('
Hello & welcome
'); + * // Returns: '<div class="test">Hello & welcome</div>' + */ +export function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} From e0b11a200020b7a1e8029d05e16912c2ddca0c7b Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 21:53:38 +0200 Subject: [PATCH 04/11] chore: dompurify added to avoid xss injection --- package-lock.json | 20 +++++++++----------- package.json | 1 + 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index c75437d..91491f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "cronstrue": "^3.0.0", "dayjs": "^1.11.13", "diff": "^8.0.2", + "dompurify": "^3.4.2", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", "heic-to": "^1.4.2", @@ -3537,7 +3538,6 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3598,7 +3598,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -5759,10 +5758,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", - "optional": true, + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -5860,7 +5859,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "iconv-lite": "^0.6.2" @@ -7803,7 +7802,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -9535,7 +9534,6 @@ "version": "0.53.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "^1.0.6" @@ -11742,7 +11740,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": { @@ -13236,7 +13234,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index c671a45..5b2cf63 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "cronstrue": "^3.0.0", "dayjs": "^1.11.13", "diff": "^8.0.2", + "dompurify": "^3.4.2", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", "heic-to": "^1.4.2", From 35bb5720ce0238dc7ac36bde74097a4c98fb2a99 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 22:07:33 +0200 Subject: [PATCH 05/11] chore: updated service - removing span around unchanged parts - diff level added (char, word) can be extended later --- .../tools/string/text-compare/service.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/pages/tools/string/text-compare/service.ts b/src/pages/tools/string/text-compare/service.ts index 2b4141c..7030905 100644 --- a/src/pages/tools/string/text-compare/service.ts +++ b/src/pages/tools/string/text-compare/service.ts @@ -1,22 +1,29 @@ -import { diffWordsWithSpace } from 'diff'; +import { diffWordsWithSpace, diffChars } from 'diff'; +import { level } from './types'; +import { escapeHtml } from 'utils/string'; -function escapeHtml(str: string): string { - return str.replace(//g, '>'); -} +const DIFF_FN = { + word: diffWordsWithSpace, + char: diffChars +} as const; -export function compareTextsHtml(textA: string, textB: string): string { - const diffs = diffWordsWithSpace(textA, textB); +export function compareTextsHtml( + textA: string, + textB: string, + level: level +): string { + const diffs = DIFF_FN[level](textA, textB); const html = diffs .map((part) => { - const val = escapeHtml(part.value).replace(/ /g, ' '); + const val = escapeHtml(part.value).replace(/\n/g, '
'); if (part.added) { return `${val}`; } if (part.removed) { return `${val}`; } - return `${val}`; + return val; }) .join(''); From ec411a0c80c7a0bf138a85c2f2af450d50bfdb8c Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 22:08:59 +0200 Subject: [PATCH 06/11] chore: - diff result component refactored - protrectionn against xss --- src/components/result/ToolDiffResult.tsx | 92 +++++++++++++++--------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/src/components/result/ToolDiffResult.tsx b/src/components/result/ToolDiffResult.tsx index 6751217..0572cd9 100644 --- a/src/components/result/ToolDiffResult.tsx +++ b/src/components/result/ToolDiffResult.tsx @@ -1,9 +1,17 @@ -import { Box, Typography } from '@mui/material'; +import { Box, Typography, CircularProgress, useTheme } from '@mui/material'; import InputHeader from '../InputHeader'; import ResultFooter from './ResultFooter'; import { useContext } from 'react'; import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; +import { globalInputHeight } from '../../config/uiConfig'; import { useTranslation } from 'react-i18next'; +import { stripAndDecodeHtml } from 'utils/string'; +import DOMPurify from 'dompurify'; + +const DOMPURIFY_CONFIG = { + ALLOWED_TAGS: ['div', 'span', 'br'], + ALLOWED_ATTR: ['class'] +}; export default function ToolDiffResult({ title = 'Differences', @@ -16,12 +24,14 @@ export default function ToolDiffResult({ loading?: boolean; isHtml?: boolean; }) { + const theme = useTheme(); const { t } = useTranslation(); const { showSnackBar } = useContext(CustomSnackBarContext); const handleCopy = () => { + const text = isHtml ? stripAndDecodeHtml(value) : value; navigator.clipboard - .writeText(value.replace(/<[^>]*>/g, '')) + .writeText(text) .then(() => showSnackBar(t('toolTextResult.copied'), 'success')) .catch((err) => showSnackBar(t('toolTextResult.copyFailed', { error: err }), 'error') @@ -29,61 +39,73 @@ export default function ToolDiffResult({ }; const handleDownload = () => { - const blob = new Blob([value], { type: 'text/html' }); + const mimeType = isHtml ? 'text/html' : 'text/plain'; + const blob = new Blob([value], { type: mimeType }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'diff-output.html'; + a.download = isHtml ? 'diff-output.html' : 'diff-output.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); - window.URL.revokeObjectURL(url); + setTimeout(() => window.URL.revokeObjectURL(url), 100); }; + const sharedBoxSx = { + p: 2, + backgroundColor: 'background.paper', + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + fontFamily: 'monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + minHeight: '265px' + } as const; + return ( {loading ? ( - {t('toolTextResult.loading')} + + + + {t('toolTextResult.loading')} + + ) : isHtml ? ( ) : ( - - {value} - + {value} )} From e7e319ee041a4ee7bdde7a524fbbd724efd02344 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 22:09:48 +0200 Subject: [PATCH 07/11] chore: - level selection enabled - full i8n support --- src/pages/tools/string/text-compare/index.tsx | 65 ++++++++++++++++--- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/src/pages/tools/string/text-compare/index.tsx b/src/pages/tools/string/text-compare/index.tsx index 1c57dce..a3ee528 100644 --- a/src/pages/tools/string/text-compare/index.tsx +++ b/src/pages/tools/string/text-compare/index.tsx @@ -2,24 +2,57 @@ import { Box } from '@mui/material'; import { useState, useEffect } from 'react'; import ToolContent from '@components/ToolContent'; import { ToolComponentProps } from '@tools/defineTool'; +import { GetGroupsType } from '@components/options/ToolOptions'; import ToolTextInput from '@components/input/ToolTextInput'; import ToolDiffResult from '@components/result/ToolDiffResult'; +import SelectWithDesc from '@components/options/SelectWithDesc'; import { useTranslation } from 'react-i18next'; import { compareTextsHtml } from './service'; +import { level, InitialValuesType } from './types'; + +const initialValues: InitialValuesType = { + level: 'word' +}; export default function TextCompare({ title }: ToolComponentProps) { const { t } = useTranslation('string'); const [inputA, setInputA] = useState(''); const [inputB, setInputB] = useState(''); + const [level, setLevel] = useState('word'); const [result, setResult] = useState(''); const compute = () => { - setResult(compareTextsHtml(inputA, inputB)); + setResult(compareTextsHtml(inputA, inputB, level)); }; useEffect(() => { compute(); - }, [inputA, inputB]); + }, [inputA, inputB, level]); + + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + title: t('textCompare.options.title'), + component: ( + + { + updateField('level', value); + setLevel(value); + }} + description={t('textCompare.options.levelDesc')} + /> + + ) + } + ]; return ( - - + + } - resultComponent={} - initialValues={{}} - getGroups={null} + resultComponent={ + + } + initialValues={initialValues} + getGroups={getGroups} setInput={() => { setInputA(''); setInputB(''); @@ -41,8 +88,8 @@ export default function TextCompare({ title }: ToolComponentProps) { }} compute={compute} toolInfo={{ - title: t('textCompare.toolInfo.title'), - description: t('textCompare.toolInfo.description') + title: t('textCompare.title'), + description: t('textCompare.longDescription') }} /> ); From b3acc38505176a03ae52e3132922fbf2e45b8364 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 22:10:20 +0200 Subject: [PATCH 08/11] chore: updated tests with new service signature --- .../text-compare/text-compare.service.test.ts | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/src/pages/tools/string/text-compare/text-compare.service.test.ts b/src/pages/tools/string/text-compare/text-compare.service.test.ts index d5a0816..44b81be 100644 --- a/src/pages/tools/string/text-compare/text-compare.service.test.ts +++ b/src/pages/tools/string/text-compare/text-compare.service.test.ts @@ -3,66 +3,80 @@ 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(' here'); + const result = compareTextsHtml('Hello world', 'Hello world here', 'word'); + expect(result).toContain(' here'); }); it('should highlight removed text', () => { - const textA = 'Hello world here'; - const textB = 'Hello world'; - - const result = compareTextsHtml(textA, textB); - expect(result).toContain(' here'); + const result = compareTextsHtml('Hello world here', 'Hello world', 'word'); + expect(result).toContain(' here'); }); 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 am in '); + const result = compareTextsHtml( + 'I am in Lyon', + 'I am in Marseille', + 'word' + ); + expect(result).toContain('I am in '); expect(result).toContain('Lyon'); expect(result).toContain('Marseille'); }); - it('should return plain diff if texts are identical', () => { - const input = 'Same text everywhere'; - const result = compareTextsHtml(input, input); - expect(result).toContain('Same text everywhere'); + it('should return plain text for identical inputs', () => { + const result = compareTextsHtml( + 'Same text everywhere', + 'Same text everywhere', + 'word' + ); + expect(result).toContain('Same text everywhere'); expect(result).not.toContain('diff-added'); expect(result).not.toContain('diff-removed'); }); it('should escape HTML characters', () => { - const textA = 'Hello'; - const textB = 'Hello world'; - - const result = compareTextsHtml(textA, textB); + const result = compareTextsHtml( + 'Hello', + 'Hello world', + 'word' + ); expect(result).toContain('<b>Hello'); - expect(result).toContain(' world'); + expect(result).toContain(' world'); }); - it('should wrap result in a single diff-line div', () => { - const result = compareTextsHtml('foo', 'bar'); + it('should escape & character', () => { + const result = compareTextsHtml('AT&T', 'AT&T rocks', 'word'); + expect(result).toContain('AT&T'); + }); + + it('should wrap result in a diff-line div', () => { + const result = compareTextsHtml('foo', 'bar', 'word'); expect(result.startsWith('
')).toBe(true); expect(result.endsWith('
')).toBe(true); }); it('should handle empty input strings', () => { - const result = compareTextsHtml('', ''); + const result = compareTextsHtml('', '', 'word'); expect(result).toBe('
'); }); it('should handle only added input', () => { - const result = compareTextsHtml('', 'New text'); - expect(result).toContain('New text'); + const result = compareTextsHtml('', 'New text', 'word'); + expect(result).toContain('New text'); }); it('should handle only removed input', () => { - const result = compareTextsHtml('Old text', ''); - expect(result).toContain('Old text'); + const result = compareTextsHtml('Old text', '', 'word'); + expect(result).toContain('Old text'); + }); + + it('should convert newlines to
', () => { + const result = compareTextsHtml('line1\nline2', 'line1\nline2', 'word'); + expect(result).toContain('
'); + }); + + it('should highlight individual characters with char level', () => { + const result = compareTextsHtml('color', 'colour', 'char'); + expect(result).toContain('u'); }); }); From 4861d9221043475b909367a41e1c7baab745cc37 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 22:10:52 +0200 Subject: [PATCH 09/11] chore: types.ts file --- src/pages/tools/string/text-compare/types.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/pages/tools/string/text-compare/types.ts diff --git a/src/pages/tools/string/text-compare/types.ts b/src/pages/tools/string/text-compare/types.ts new file mode 100644 index 0000000..441f9e4 --- /dev/null +++ b/src/pages/tools/string/text-compare/types.ts @@ -0,0 +1,5 @@ +export type level = 'word' | 'char'; + +export type InitialValuesType = { + level: level; +}; From 5845110755a80d59619873ae9fc9497dd6df3b5c Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 22:11:25 +0200 Subject: [PATCH 10/11] chore: locales updated for full i8n support --- public/locales/en/string.json | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/public/locales/en/string.json b/public/locales/en/string.json index 0f82d50..86e5bf2 100644 --- a/public/locales/en/string.json +++ b/public/locales/en/string.json @@ -339,18 +339,16 @@ }, "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." + "description": "Analyze two text blocks to highlight differences, including additions, deletions, and changes.", + "shortDescription": "Compare two texts and spot differences instantly", + "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.", + "inputTitle": "Input Text", + "resultTitle": "Highlighted differences", + "options": { + "title": "Comparison Settings", + "wordLevel": "Word level", + "charLevel": "Character level", + "levelDesc": "Choose how detailed the comparison should be. Word level highlights changes by words, while character level detects more granular differences." } } } From 44fc4e013e53f94d52b43516db5eff9c2101de16 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 4 May 2026 22:19:59 +0200 Subject: [PATCH 11/11] chore: icon change --- src/pages/tools/string/text-compare/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tools/string/text-compare/meta.ts b/src/pages/tools/string/text-compare/meta.ts index 8329b2c..9132e37 100644 --- a/src/pages/tools/string/text-compare/meta.ts +++ b/src/pages/tools/string/text-compare/meta.ts @@ -9,7 +9,7 @@ export const tool = defineTool('string', { longDescription: 'string:textCompare.longDescription' }, path: 'text-compare', - icon: 'material-symbols-light:search', + icon: 'ic:twotone-difference', keywords: ['text', 'compare'], component: lazy(() => import('./index')) });