mirror of
https://github.com/ershisan99/DevToysWeb.git
synced 2025-12-16 12:32:48 +00:00
feat: implement text diff tool
This commit is contained in:
17
app/text/diff/layout.tsx
Normal file
17
app/text/diff/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { toolGroups } from "@/config/tools";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: toolGroups.text.tools.inspector_and_case_converter.longTitle,
|
||||
description: toolGroups.text.tools.inspector_and_case_converter.description,
|
||||
robots: {
|
||||
googleBot: {
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
140
app/text/diff/page.tsx
Normal file
140
app/text/diff/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Panel, PanelGroup } from "react-resizable-panels";
|
||||
|
||||
import { PERSISTENCE_KEY } from "@/config/persistence-keys";
|
||||
import { toolGroups } from "@/config/tools";
|
||||
import { cn } from "@/lib/style";
|
||||
import { DiffEditor } from "@/components/ui/diff-editor";
|
||||
import { Editor } from "@/components/ui/editor";
|
||||
import * as Button from "@/components/buttons";
|
||||
import { Configuration } from "@/components/configuration";
|
||||
import { Configurations } from "@/components/configurations";
|
||||
import { ControlMenu } from "@/components/control-menu";
|
||||
import * as Icon from "@/components/icons";
|
||||
import * as icons from "@/components/icons";
|
||||
import { Rows } from "@/components/icons";
|
||||
import { LabeledSwitch } from "@/components/labeled-switch";
|
||||
import { PageRootSection } from "@/components/page-root-section";
|
||||
import { PageSection } from "@/components/page-section";
|
||||
import { PanelResizeHandle } from "@/components/panel-resize-handler";
|
||||
|
||||
/** No particular reason for these sizes, just feels like a good balance */
|
||||
const VERTICAL_PANEL_MAX_SIZE = 80;
|
||||
const HORIZONTAL_PANEL_MAX_SIZE = 90;
|
||||
const PANEL_FULL_SIZE = 100;
|
||||
|
||||
export default function Page() {
|
||||
const [input1, setInput1] = useState<string | undefined>("Hello world");
|
||||
const [input2, setInput2] = useState<string | undefined>("Hello, World!");
|
||||
const [diffFullHeight, setDiffFullHeight] = useState(false);
|
||||
const [inlineMode, setInlineMode] = useState(false);
|
||||
const diffPanelMaxSize = useMemo(
|
||||
() => (diffFullHeight ? PANEL_FULL_SIZE : VERTICAL_PANEL_MAX_SIZE),
|
||||
[diffFullHeight]
|
||||
);
|
||||
|
||||
const clearInput1 = useCallback(() => setInput1(""), [setInput1]);
|
||||
const clearInput2 = useCallback(() => setInput2(""), [setInput2]);
|
||||
const toggleFullHeight = useCallback(() => setDiffFullHeight(prev => !prev), [setDiffFullHeight]);
|
||||
|
||||
const inlineModeConfig = useMemo(
|
||||
() => (
|
||||
<Configuration
|
||||
icon={<icons.Rows size={24} />}
|
||||
title="Inline mode"
|
||||
control={
|
||||
<LabeledSwitch
|
||||
id="uppercase-switch"
|
||||
label={inlineMode ? "On" : "Off"}
|
||||
checked={inlineMode}
|
||||
onCheckedChange={setInlineMode}
|
||||
aria-label="toggle whether to show diff in inline mode"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
[inlineMode, setInlineMode]
|
||||
);
|
||||
const input1Control = useMemo(
|
||||
() => (
|
||||
<ControlMenu
|
||||
list={[
|
||||
<Button.Paste onClipboardRead={setInput1} />,
|
||||
<Button.File onFileRead={setInput1} iconOnly aria-label="load a file with old text" />,
|
||||
<Button.Clear onClick={clearInput1} iconOnly aria-label="clear old text input" />,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
[setInput1, clearInput1]
|
||||
);
|
||||
const input2Control = useMemo(
|
||||
() => (
|
||||
<ControlMenu
|
||||
list={[
|
||||
<Button.Paste onClipboardRead={setInput2} />,
|
||||
<Button.File onFileRead={setInput2} iconOnly aria-label="load a file with new text" />,
|
||||
<Button.Clear onClick={clearInput2} iconOnly aria-label="clear new text input" />,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
[setInput2, clearInput2]
|
||||
);
|
||||
|
||||
const diffControl = useMemo(
|
||||
() => (
|
||||
<ControlMenu
|
||||
list={[
|
||||
<Button.ToggleFullSize iconOnly onClick={toggleFullHeight} expanded={diffFullHeight} />,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
[diffFullHeight, toggleFullHeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageRootSection
|
||||
className="h-full"
|
||||
title={toolGroups.text.tools.inspector_and_case_converter.longTitle}
|
||||
>
|
||||
<PageSection title="Configuration" className={cn(diffFullHeight && "hidden")}>
|
||||
<Configurations list={[inlineModeConfig]} />
|
||||
</PageSection>
|
||||
<PanelGroup direction="vertical" autoSaveId={PERSISTENCE_KEY.panels.textDiff.vertical}>
|
||||
<Panel maxSize={VERTICAL_PANEL_MAX_SIZE} className={cn(diffFullHeight && "hidden")}>
|
||||
<PanelGroup
|
||||
direction="horizontal"
|
||||
autoSaveId={PERSISTENCE_KEY.panels.textDiff.horizontal}
|
||||
>
|
||||
<Panel maxSize={HORIZONTAL_PANEL_MAX_SIZE}>
|
||||
<PageSection className="h-full" title="Old text" control={input1Control}>
|
||||
<Editor value={input1} onChange={setInput1} />
|
||||
</PageSection>
|
||||
</Panel>
|
||||
<PanelResizeHandle direction="vertical" className="mt-[42px]" />
|
||||
|
||||
<Panel maxSize={HORIZONTAL_PANEL_MAX_SIZE}>
|
||||
<PageSection className="h-full" title="New text" control={input2Control}>
|
||||
<Editor value={input2} onChange={setInput2} />
|
||||
</PageSection>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
<PanelResizeHandle direction="horizontal" className={cn(diffFullHeight && "hidden")} />
|
||||
<Panel maxSize={diffPanelMaxSize}>
|
||||
<PageSection className="h-full" title="Difference" control={diffControl}>
|
||||
<DiffEditor
|
||||
original={input1}
|
||||
modified={input2}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: !inlineMode,
|
||||
}}
|
||||
/>
|
||||
</PageSection>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</PageRootSection>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from "./clear";
|
||||
export * from "./copy";
|
||||
export * from "./file";
|
||||
export * from "./paste";
|
||||
export * from "./toggle-full-size";
|
||||
|
||||
22
components/buttons/toggle-full-size.tsx
Normal file
22
components/buttons/toggle-full-size.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { memo } from "react";
|
||||
import equal from "react-fast-compare";
|
||||
|
||||
import * as Icon from "@/components/icons";
|
||||
|
||||
import { Base, BaseProps } from "./base";
|
||||
|
||||
export type ToggleFullSizeProps = Omit<BaseProps, "icon" | "labelText"> & {
|
||||
expanded: boolean;
|
||||
};
|
||||
|
||||
function RawToggleFullSize({ expanded, ...props }: ToggleFullSizeProps) {
|
||||
return (
|
||||
<Base
|
||||
{...props}
|
||||
icon={expanded ? <Icon.Minimize size={16} /> : <Icon.Maximize size={16} />}
|
||||
labelText={expanded ? "Collapse" : "Expand"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ToggleFullSize = memo(RawToggleFullSize, equal);
|
||||
@@ -14,15 +14,21 @@ export const ChevronDown = memo(icons.ChevronDown, equal);
|
||||
export const Clipboard = memo(icons.Clipboard, equal);
|
||||
export const Code = memo(icons.Code2, equal);
|
||||
export const Copy = memo(icons.Copy, equal);
|
||||
export const Diff = memo(icons.Diff, equal);
|
||||
export const Equal = memo(icons.Equal, equal);
|
||||
export const File = memo(icons.FileIcon, equal);
|
||||
export const Fingerprint = memo(icons.Fingerprint, equal);
|
||||
export const GripHorizontal = memo(icons.GripHorizontal, equal);
|
||||
export const GripVertical = memo(icons.GripVertical, equal);
|
||||
export const Hash = memo(icons.Hash, equal);
|
||||
export const Home = memo(icons.Home, equal);
|
||||
export const Key = memo(icons.Key, equal);
|
||||
export const Link = memo(icons.Link2, equal);
|
||||
export const Maximize = memo(icons.Maximize2, equal);
|
||||
export const Minimize = memo(icons.Minimize2, equal);
|
||||
export const PackagePlus = memo(icons.PackagePlus, equal);
|
||||
export const Paintbrush = memo(icons.Paintbrush2, equal);
|
||||
export const Rows = memo(icons.Rows, equal);
|
||||
export const Search = memo(icons.Search, equal);
|
||||
export const Settings = memo(icons.Settings, equal);
|
||||
export const Settings2 = memo(icons.Settings2, equal);
|
||||
|
||||
36
components/panel-resize-handler.tsx
Normal file
36
components/panel-resize-handler.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ComponentPropsWithoutRef, useMemo } from "react";
|
||||
import { PanelResizeHandle as PanelResizeHandlePrimitive } from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
import * as Icon from "@/components/icons";
|
||||
|
||||
type Props = {
|
||||
direction?: "vertical" | "horizontal";
|
||||
} & ComponentPropsWithoutRef<typeof PanelResizeHandlePrimitive>;
|
||||
|
||||
export const PanelResizeHandle = ({ direction = "vertical", className, ...props }: Props) => {
|
||||
const isVertical = direction === "vertical";
|
||||
const isHorizontal = direction === "horizontal";
|
||||
|
||||
const classNames = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
isVertical && "w-4",
|
||||
isHorizontal && "h-4",
|
||||
"flex items-center justify-center",
|
||||
"data-[resize-handle-state=drag]:bg-neutral-200",
|
||||
"dark:data-[resize-handle-state=drag]:bg-neutral-600",
|
||||
"data-[resize-handle-state=hover]:bg-neutral-300",
|
||||
"dark:data-[resize-handle-state=hover]:bg-neutral-700",
|
||||
className
|
||||
),
|
||||
[isVertical, isHorizontal, className]
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelResizeHandlePrimitive className={classNames} {...props}>
|
||||
{isVertical && <Icon.GripVertical size={12} />}
|
||||
{isHorizontal && <Icon.GripHorizontal size={12} />}
|
||||
</PanelResizeHandlePrimitive>
|
||||
);
|
||||
};
|
||||
8
config/persistence-keys.ts
Normal file
8
config/persistence-keys.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const PERSISTENCE_KEY = {
|
||||
panels: {
|
||||
textDiff: {
|
||||
vertical: "panels/text-diff/vertical",
|
||||
horizontal: "panels/text-diff/horizontal",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -55,7 +55,7 @@ export const toolGroups = {
|
||||
shortTitle: "HTML",
|
||||
longTitle: "HTML Encoder / Decoder",
|
||||
description:
|
||||
"Encode or decode all the applicable characters to their corresponding HTML entities",
|
||||
"Encode or decode all the applicable characters to their corresponding HTML entities",
|
||||
keywords: "html encoder escaper decocder unescaper",
|
||||
href: "/encoders-decoders/html",
|
||||
},
|
||||
@@ -64,7 +64,7 @@ export const toolGroups = {
|
||||
shortTitle: "URL",
|
||||
longTitle: "URL Encoder / Decoder",
|
||||
description:
|
||||
"Encode or decode all the applicable characters to their corresponding URL entities",
|
||||
"Encode or decode all the applicable characters to their corresponding URL entities",
|
||||
keywords: "url encoder escaper decocder unescaper",
|
||||
href: "/encoders-decoders/url",
|
||||
},
|
||||
@@ -137,6 +137,14 @@ export const toolGroups = {
|
||||
keywords: "case converter convert text inspector inspect",
|
||||
href: "/text/inspector",
|
||||
},
|
||||
diff: {
|
||||
Icon: icons.Diff,
|
||||
shortTitle: "Text diff",
|
||||
longTitle: "Text Inspector & Case Converter",
|
||||
description: "Analyze text and convert it to a different case",
|
||||
keywords: "case converter convert text inspector inspect",
|
||||
href: "/text/diff",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies ToolGroups;
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-resizable-panels": "^2.0.19",
|
||||
"sharp": "^0.32.1",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"url-slug": "^4.0.1",
|
||||
|
||||
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
@@ -1,9 +1,5 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.5.1
|
||||
@@ -86,6 +82,9 @@ dependencies:
|
||||
react-fast-compare:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2
|
||||
react-resizable-panels:
|
||||
specifier: ^2.0.19
|
||||
version: 2.0.19(react-dom@18.2.0)(react@18.2.0)
|
||||
sharp:
|
||||
specifier: ^0.32.1
|
||||
version: 0.32.1
|
||||
@@ -2831,35 +2830,6 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-module-utils@2.8.0(@typescript-eslint/parser@5.59.7)(eslint-import-resolver-node@0.3.9)(eslint@8.41.0):
|
||||
resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==}
|
||||
engines: {node: '>=4'}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': '*'
|
||||
eslint: '*'
|
||||
eslint-import-resolver-node: '*'
|
||||
eslint-import-resolver-typescript: '*'
|
||||
eslint-import-resolver-webpack: '*'
|
||||
peerDependenciesMeta:
|
||||
'@typescript-eslint/parser':
|
||||
optional: true
|
||||
eslint:
|
||||
optional: true
|
||||
eslint-import-resolver-node:
|
||||
optional: true
|
||||
eslint-import-resolver-typescript:
|
||||
optional: true
|
||||
eslint-import-resolver-webpack:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@typescript-eslint/parser': 5.59.7(eslint@8.41.0)(typescript@5.0.4)
|
||||
debug: 3.2.7
|
||||
eslint: 8.41.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.59.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.41.0):
|
||||
resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2912,7 +2882,7 @@ packages:
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.41.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.59.7)(eslint-import-resolver-node@0.3.9)(eslint@8.41.0)
|
||||
eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.59.7)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.41.0)
|
||||
has: 1.0.4
|
||||
is-core-module: 2.13.0
|
||||
is-glob: 4.0.3
|
||||
@@ -4563,6 +4533,16 @@ packages:
|
||||
use-sidecar: 1.1.2(@types/react@18.2.7)(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-resizable-panels@2.0.19(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-v3E41kfKSuCPIvJVb4nL4mIZjjKIn/gh6YqZF/gDfQDolv/8XnhJBek4EiV2gOr3hhc5A3kOGOayk3DhanpaQw==}
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-style-singleton@2.2.1(@types/react@18.2.7)(react@18.2.0):
|
||||
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5506,3 +5486,7 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
Reference in New Issue
Block a user