feat: implement text diff tool

This commit is contained in:
2024-05-14 22:26:00 +02:00
parent 5113fe626b
commit 91fe326261
10 changed files with 259 additions and 36 deletions

17
app/text/diff/layout.tsx Normal file
View 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
View 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>
);
}

View File

@@ -2,3 +2,4 @@ export * from "./clear";
export * from "./copy";
export * from "./file";
export * from "./paste";
export * from "./toggle-full-size";

View 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);

View File

@@ -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);

View 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>
);
};

View File

@@ -0,0 +1,8 @@
export const PERSISTENCE_KEY = {
panels: {
textDiff: {
vertical: "panels/text-diff/vertical",
horizontal: "panels/text-diff/horizontal",
},
},
} as const;

View File

@@ -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;

View File

@@ -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
View File

@@ -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