zhconv 0.4.1

Traditional, Simplified and regional Chinese variants converter powered by MediaWiki & OpenCC rulesets and the Aho-Corasick algorithm 中文简繁及地區詞轉換
Documentation
import React, { useState, useRef, useEffect, useCallback } from "react";
import CssBaseline from "@mui/material/CssBaseline";
import Container from "@mui/material/Container";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import { ThemeProvider } from "@mui/material/styles";
import Backdrop from "@mui/material/Backdrop";
import Typography from "@mui/material/Typography";
import Fab from "@mui/material/Fab";
import Tooltip from "@mui/material/Tooltip";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useSnackbar } from "notistack";

import Header from "./components/Header";
import Footer from "./components/Footer";
import InputEditor from "./components/InputEditor";
import OutputEditor from "./components/OutputEditor";
import OptionsControl from "./components/OptionsControl";
import { variants, Variant } from "./components/ConvertButton";
import theme from "./theme";
import { OptionsControlHandle } from "./components/OptionsControl";
import { useApp } from "./AppContext";

import PACKAGE from "../package.json";

function App() {
  const { enqueueSnackbar } = useSnackbar();

  const controlRef = useRef<OptionsControlHandle>(null);

  const [input, setInput] = useState(
    () => localStorage.getItem(`${PACKAGE.name}-text`) || "",
  );

  const [output, setOutput] = useState<string | undefined>(undefined);

  const [dragging, setDragging] = useState(false);

  const { wasm, cgroups } = useApp();

  const [activatedCGroups, setActivatedCGroups] = useState(() => {
    return JSON.parse(
      localStorage.getItem(`${PACKAGE.name}-activated-cgroups`) || "[]",
    ) as string[];
  });

  const [wikitextSupport, setWikitextSupport] = useState(() => {
    return JSON.parse(
      localStorage.getItem(`${PACKAGE.name}-wikitext-support`) || "false",
    ) as boolean;
  });

  const [targetVariant, setTargetVariant] = useState<Variant>(() => {
    const hash = window.location.hash.slice(1) as Variant;

    if (variants[hash]) {
      return hash;
    } else {
      return (
        (localStorage.getItem(`${PACKAGE.name}-target-variant`) as Variant) ??
        "zh"
      );
    }
  });

  const isMounting = useRef(true);

  useEffect(() => {
    if (isMounting.current) {
      isMounting.current = false;
      return;
    }

    const s = JSON.stringify(activatedCGroups);

    localStorage.setItem(`${PACKAGE.name}-activated-cgroups`, s);
  }, [activatedCGroups]);

  const convertText = useCallback(
    async (text: string) => {
      if (!wasm || !cgroups) {
        return;
      }

      const conversionLabel = `conversion (text.len=${text.length}, variant=${targetVariant}, wikitext=${wikitextSupport}, cgroups.len=${activatedCGroups.length})`;
      console.time(conversionLabel);
      const result = await wasm.zhconv(
        text,
        targetVariant,
        wikitextSupport,
        activatedCGroups.map((name) => cgroups.data[name]).join("\n"),
      );
      console.timeEnd(conversionLabel);
      return result;
    },

    [wasm, targetVariant, wikitextSupport, activatedCGroups, cgroups],
  );

  const handleConvert = useCallback(async () => {
    if (input.trim() === "" || !wasm || !cgroups) {
      return;
    }

    setOutput(await convertText(input));
    if (controlRef.current?.controlElement) {
      controlRef.current.controlElement.scrollIntoView({ behavior: "smooth" });
    }
  }, [input, wasm, convertText, cgroups]);

  useEffect(() => {
    if (isMounting.current) {
      return; // Already handled by the first useEffect
    }
    controlRef.current?.clickConvert();
    localStorage.setItem(`${PACKAGE.name}-target-variant`, targetVariant);
    window.history.replaceState({}, "", `#${targetVariant}`);
  }, [targetVariant, controlRef]);

  useEffect(() => {
    if (isMounting.current) {
      return; // Already handled by the first useEffect
    }

    controlRef.current?.clickConvert();
    const s = JSON.stringify(wikitextSupport);
    localStorage.setItem(`${PACKAGE.name}-wikitext-support`, s);
  }, [wikitextSupport, controlRef]);

  const handleFiles = useCallback(
    async (files: File[]) => {
      if (!files.length || !wasm || !cgroups) {
        return;
      }

      const decoder = new TextDecoder("utf-8", { fatal: true });

      for (const file of files) {
        try {
          const buffer = await file.arrayBuffer();
          const text = decoder.decode(buffer);
          if (text.trim() === "") {
            continue;
          }
          const converted = await convertText(text);
          const blob = new Blob([converted ?? ""], { type: "text/plain" });
          const url = URL.createObjectURL(blob);
          const a = document.createElement("a");
          a.href = url;

          let origName = file.name;
          let origExt = "";
          const lastDotIndex = file.name.lastIndexOf(".");

          if (lastDotIndex > 0) {
            origName = file.name.substring(0, lastDotIndex);
            origExt = file.name.substring(lastDotIndex + 1);
          }

          const newFileName = `${origName} ${targetVariant}${origExt ? `.${origExt}` : ""}`;
          a.download = newFileName;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          URL.revokeObjectURL(url);

          enqueueSnackbar(`Converted ${file.name}.`, {
            variant: "success",
          });
        } catch (e: unknown) {
          let reason = "Unknown error";

          if (e instanceof TypeError && e.message.includes("decode")) {
            reason = "Not in valid UTF-8";
          } else if (e instanceof Error) {
            reason = e.message;
          }

          enqueueSnackbar(`Failed to convert ${file.name} (${reason}).`, {
            variant: "error",
          });
        }
      }
    },

    [wasm, convertText, targetVariant, enqueueSnackbar, cgroups],
  );

  const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files.length > 0) {
      handleFiles(Array.from(e.target.files));

      e.target.value = "";
    }
  };

  const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragging(true);
  };

  const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      setDragging(false);
    }
  };

  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
  };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragging(false);
    if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
      handleFiles(Array.from(e.dataTransfer.files));
      e.dataTransfer.clearData();
    }
  };

  useEffect(() => {
    if (input) {
      localStorage.setItem(`${PACKAGE.name}-text`, input);
    }
  }, [input]);

  return (
    <ThemeProvider theme={theme}>
      <Container
        component="main"
        maxWidth="md"
        sx={{ mt: 5, mb: 2 }}
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
      >
        <CssBaseline />

        <Header />

        <Box component="main" sx={{ mt: 1, mb: 1 }}>
          <Paper component="section" elevation={3} sx={{ my: 1 }}>
            <Box p={2}>
              <InputEditor input={input} setInput={setInput} />
            </Box>
          </Paper>

          <Paper component="section" elevation={1} sx={{ my: 1 }}>
            <Box p={1}>
              <OptionsControl
                cgroups={cgroups ? Object.keys(cgroups.data) : null}
                activatedCGroups={activatedCGroups}
                onSelectCGroups={setActivatedCGroups}
                wikitextSupport={wikitextSupport}
                onToggleWikitextSupport={() =>
                  setWikitextSupport(!wikitextSupport)
                }
                onConvert={handleConvert}
                targetVariant={targetVariant}
                setTargetVariant={setTargetVariant}
                ref={controlRef}
              />
            </Box>
          </Paper>

          <Paper component="section" elevation={3} sx={{ my: 1 }}>
            <Box p={2}>
              <OutputEditor output={output} />
            </Box>
          </Paper>
        </Box>

        <Footer />

        <Backdrop
          sx={{
            color: "#fff",

            zIndex: (theme) => theme.zIndex.drawer + 1,

            backdropFilter: "blur(3px)",

            backgroundColor: "rgba(0, 0, 0, 0.5)",
          }}
          open={dragging}
        >
          <Typography variant="h4">Drop file to convert</Typography>
        </Backdrop>
      </Container>
      <input
        accept="text/*"
        style={{ display: "none" }}
        id="fab-button-file"
        multiple
        type="file"
        onChange={onFileChange}
      />
      <label htmlFor="fab-button-file">
        <Tooltip
          title={
            <>
              Convert one or more files in UTF-8 encoding
              <br />/ 轉換一個或多個檔案(須為 UTF-8 編碼)
            </>
          }
        >
          <Fab
            sx={{
              position: "fixed",
              bottom: "2rem",
              right: "2rem",
              opacity: 0.5,
              "&:hover": {
                opacity: 1,
              },
            }}
            color="secondary"
            component="span"
          >
            <FileUploadIcon />
          </Fab>
        </Tooltip>
      </label>
    </ThemeProvider>
  );
}

export default App;