From 889c78b5c0344c0890737734534e0297bdd7d1a0 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:05:26 +0700 Subject: [PATCH] refactor: modularize TextDiffChecker with improved UX --- frontend/src/pages/TextDiffChecker.jsx | 227 ------------------ .../components/DiffModeToggle.jsx | 66 +++++ .../TextDiffChecker/components/DiffView.jsx | 95 ++++++++ .../src/pages/TextDiffChecker/constants.js | 8 + .../src/pages/TextDiffChecker/diffUtils.js | 36 +++ frontend/src/pages/TextDiffChecker/index.jsx | 111 +++++++++ 6 files changed, 316 insertions(+), 227 deletions(-) delete mode 100644 frontend/src/pages/TextDiffChecker.jsx create mode 100644 frontend/src/pages/TextDiffChecker/components/DiffModeToggle.jsx create mode 100644 frontend/src/pages/TextDiffChecker/components/DiffView.jsx create mode 100644 frontend/src/pages/TextDiffChecker/constants.js create mode 100644 frontend/src/pages/TextDiffChecker/diffUtils.js create mode 100644 frontend/src/pages/TextDiffChecker/index.jsx diff --git a/frontend/src/pages/TextDiffChecker.jsx b/frontend/src/pages/TextDiffChecker.jsx deleted file mode 100644 index 288bb02..0000000 --- a/frontend/src/pages/TextDiffChecker.jsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import * as Diff from 'diff'; -import { Grid, Column, Button, ContentSwitcher, Switch } from '@carbon/react'; -import { Compare, Renew } from '@carbon/icons-react'; -import { ToolHeader, ToolControls, ToolPane, ToolSplitPane } from '../components/ToolUI'; -import useLayoutToggle from '../hooks/useLayoutToggle'; - -const DiffView = ({ diffs, mode }) => { - const renderDiff = () => { - if (diffs.length === 0) { - return No differences; - } - - return diffs.map((part, index) => { - const isAdded = part.added; - const isRemoved = part.removed; - - let style = { - display: 'block', - padding: '1px 4px', - margin: '1px 0', - borderRadius: '2px', - }; - - if (isAdded) { - style = { - ...style, - backgroundColor: 'var(--cds-support-success-inverse)', - color: 'var(--cds-text-on-color)', - }; - } else if (isRemoved) { - style = { - ...style, - backgroundColor: 'var(--cds-support-error-inverse)', - color: 'var(--cds-text-on-color)', - }; - } - - const prefix = isAdded ? '+ ' : isRemoved ? '- ' : ' '; - - return ( - - {prefix} - {part.value} - - ); - }); - }; - - return ( -
-
- -
-
- {renderDiff()} -
-
- ); -}; - -export default function TextDiffChecker() { - const [oldText, setOldText] = useState(''); - const [newText, setNewText] = useState(''); - const [diffs, setDiffs] = useState([]); - const [diffMode, setDiffMode] = useState(0); // 0 = lines, 1 = words, 2 = chars - - const layout = useLayoutToggle({ - toolKey: 'text-diff-layout', - defaultDirection: 'horizontal', - showToggle: true, - persist: true, - }); - - const compare = () => { - let d; - switch (diffMode) { - case 0: - d = Diff.diffLines(oldText, newText, { newlineIsToken: true }); - break; - case 1: - d = Diff.diffWords(oldText, newText); - break; - case 2: - d = Diff.diffChars(oldText, newText); - break; - default: - d = Diff.diffLines(oldText, newText, { newlineIsToken: true }); - } - setDiffs(d); - }; - - const clearAll = () => { - setOldText(''); - setNewText(''); - setDiffs([]); - }; - - // Auto-compare when inputs change - useEffect(() => { - if (oldText || newText) { - compare(); - } else { - setDiffs([]); - } - }, [oldText, newText, diffMode]); - - return ( - - - - - - - -
-
- - setDiffMode(index)} - size="sm" - style={{ width: 'auto', minWidth: '150px' }} - > - - - - -
- - - - -
-
-
- - - -
- - setOldText(e.target.value)} - placeholder="Paste original text..." - /> - setNewText(e.target.value)} - placeholder="Paste new text..." - /> - -
-
- -
-
-
-
- ); -} diff --git a/frontend/src/pages/TextDiffChecker/components/DiffModeToggle.jsx b/frontend/src/pages/TextDiffChecker/components/DiffModeToggle.jsx new file mode 100644 index 0000000..f6e70fe --- /dev/null +++ b/frontend/src/pages/TextDiffChecker/components/DiffModeToggle.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { TextAlignLeft, TextAlignCenter, LetterAa } from '@carbon/icons-react'; + +const modes = [ + { label: 'Lines', icon: TextAlignLeft }, + { label: 'Words', icon: TextAlignCenter }, + { label: 'Chars', icon: LetterAa }, +]; + +export default function DiffModeToggle({ activeMode, onChange }) { + return ( +
+ {modes.map((mode, idx) => { + const isActive = activeMode === idx; + const Icon = mode.icon; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/pages/TextDiffChecker/components/DiffView.jsx b/frontend/src/pages/TextDiffChecker/components/DiffView.jsx new file mode 100644 index 0000000..d2b889b --- /dev/null +++ b/frontend/src/pages/TextDiffChecker/components/DiffView.jsx @@ -0,0 +1,95 @@ +import React from 'react'; + +export default function DiffView({ diffs }) { + const renderDiff = () => { + if (diffs.length === 0) { + return No differences; + } + + return diffs.map((part, index) => { + const isAdded = part.added; + const isRemoved = part.removed; + + let style = { + display: 'block', + padding: '1px 4px', + margin: '1px 0', + }; + + if (isAdded) { + style = { + ...style, + backgroundColor: 'var(--cds-support-success-inverse)', + color: 'var(--cds-text-on-color)', + }; + } else if (isRemoved) { + style = { + ...style, + backgroundColor: 'var(--cds-support-error-inverse)', + color: 'var(--cds-text-on-color)', + }; + } + + const prefix = isAdded ? '+ ' : isRemoved ? '- ' : ' '; + + return ( + + {prefix} + {part.value} + + ); + }); + }; + + return ( +
+
+ +
+
+ {renderDiff()} +
+
+ ); +} diff --git a/frontend/src/pages/TextDiffChecker/constants.js b/frontend/src/pages/TextDiffChecker/constants.js new file mode 100644 index 0000000..2441863 --- /dev/null +++ b/frontend/src/pages/TextDiffChecker/constants.js @@ -0,0 +1,8 @@ +export const algorithmOptions = [ + { id: 'lines', label: 'Lines', description: 'Compare line by line' }, + { id: 'words', label: 'Words', description: 'Compare word by word' }, + { id: 'wordsWithSpace', label: 'Words with Space', description: 'Words including whitespace' }, + { id: 'chars', label: 'Characters', description: 'Compare character by character' }, + { id: 'sentences', label: 'Sentences', description: 'Compare sentence by sentence' }, + { id: 'json', label: 'JSON', description: 'Semantic JSON comparison' }, +]; diff --git a/frontend/src/pages/TextDiffChecker/diffUtils.js b/frontend/src/pages/TextDiffChecker/diffUtils.js new file mode 100644 index 0000000..3def29f --- /dev/null +++ b/frontend/src/pages/TextDiffChecker/diffUtils.js @@ -0,0 +1,36 @@ +import * as Diff from 'diff'; + +export const computeDiffResult = (oldText, newText, algorithm, ignoreWhitespace) => { + if (!oldText && !newText) { + return []; + } + + const options = {}; + + if (ignoreWhitespace && (algorithm === 'lines' || algorithm === 'words')) { + options.ignoreWhitespace = true; + } + + switch (algorithm) { + case 'lines': + return Diff.diffLines(oldText, newText, { ...options, newlineIsToken: true }); + case 'words': + return Diff.diffWords(oldText, newText, options); + case 'wordsWithSpace': + return Diff.diffWordsWithSpace(oldText, newText); + case 'chars': + return Diff.diffChars(oldText, newText); + case 'sentences': + return Diff.diffSentences(oldText, newText); + case 'json': + try { + const oldObj = oldText ? JSON.parse(oldText) : {}; + const newObj = newText ? JSON.parse(newText) : {}; + return Diff.diffJson(oldObj, newObj); + } catch { + return Diff.diffLines(oldText, newText, { newlineIsToken: true }); + } + default: + return Diff.diffLines(oldText, newText, { newlineIsToken: true }); + } +}; diff --git a/frontend/src/pages/TextDiffChecker/index.jsx b/frontend/src/pages/TextDiffChecker/index.jsx new file mode 100644 index 0000000..d912536 --- /dev/null +++ b/frontend/src/pages/TextDiffChecker/index.jsx @@ -0,0 +1,111 @@ +import React, { useState, useEffect } from 'react'; +import { Grid, Column, Checkbox } from '@carbon/react'; +import { Compare } from '@carbon/icons-react'; +import { ToolHeader, ToolPane, ToolSplitPane } from '../../components/ToolUI'; +import useLayoutToggle from '../../hooks/useLayoutToggle'; +import DiffView from './components/DiffView'; +import DiffModeToggle from './components/DiffModeToggle'; +import { computeDiffResult } from './diffUtils'; + +export default function TextDiffChecker() { + const [oldText, setOldText] = useState(''); + const [newText, setNewText] = useState(''); + const [diffs, setDiffs] = useState([]); + const [algorithm, setAlgorithm] = useState('lines'); + const [ignoreWhitespace, setIgnoreWhitespace] = useState(false); + + const layout = useLayoutToggle({ + toolKey: 'text-diff-layout', + defaultDirection: 'horizontal', + showToggle: true, + persist: true, + }); + + // Auto-compare when inputs or options change + useEffect(() => { + const result = computeDiffResult(oldText, newText, algorithm, ignoreWhitespace); + setDiffs(result); + }, [oldText, newText, algorithm, ignoreWhitespace]); + + const granularityIndex = ['lines', 'words', 'chars'].indexOf(algorithm); + + return ( + + + + + + {/* Controls Row */} + +
+ {/* Granularity Toggle */} +
+ + = 0 ? granularityIndex : 0} + onChange={(mode) => { + const algoMap = ['lines', 'words', 'chars']; + setAlgorithm(algoMap[mode]); + }} + /> +
+ + {/* Ignore Whitespace Checkbox */} + setIgnoreWhitespace(checked)} + /> +
+
+ + {/* Inputs Row */} + +
+ + setOldText(e.target.value)} + placeholder="Paste original text..." + /> + setNewText(e.target.value)} + placeholder="Paste new text..." + /> + +
+
+ +
+
+
+ ); +}