style: adjust search & chat ui (#8)

This commit is contained in:
BiggerRain
2024-11-12 09:44:49 +08:00
committed by GitHub
parent 76d06dc3fe
commit c6ca2988a7
15 changed files with 2035 additions and 78 deletions

View File

@@ -21,12 +21,19 @@
"i18next": "^23.16.2",
"lodash": "^4.17.21",
"lucide-react": "^0.453.0",
"mermaid": "^11.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.5.1",
"react-i18next": "^15.1.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.27.0",
"rehype-highlight": "^7.0.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"use-debounce": "^10.0.4",
"zustand": "^5.0.0"
},
"devDependencies": {
@@ -36,6 +43,7 @@
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/react-i18next": "^8.1.0",
"@types/react-katex": "^3.0.4",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",

1433
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { SendHorizontal, OctagonX } from "lucide-react";
import { SendHorizontal, OctagonX, Filter, Upload } from "lucide-react";
import {
useState,
type FormEvent,
@@ -6,17 +6,20 @@ import {
useRef,
useEffect,
} from "react";
import ChatSwitch from "../SearchChat/ChatSwitch";
interface ChatInputProps {
onSend: (message: string) => void;
disabled: boolean;
disabledChange: (disabled: boolean) => void;
changeMode: (isChatMode: boolean) => void;
}
export function ChatInput({
onSend,
disabled,
disabledChange,
changeMode,
}: ChatInputProps) {
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -87,9 +90,20 @@ export function ChatInput({
</button>
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Press Enter to send, Shift + Enter for new line
</p>
<div className="flex justify-between items-center p-2 rounded-xl overflow-hidden">
<div className="flex gap-3 text-xs">
<button className="inline-flex items-center px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300">
<Filter className="w-4 h-4 mr-2" /> Coco
</button>
<button className="inline-flex items-center px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300">
<Upload className="w-4 h-4 mr-2" />
</button>
</div>
{/* Switch */}
<ChatSwitch isChat={true} changeMode={changeMode} />
</div>
</form>
);
}

View File

@@ -3,6 +3,7 @@ import { useState } from "react";
import type { Message } from "./types";
import { TypingAnimation } from "./TypingAnimation";
// import { Markdown } from "./Markdown";
interface ChatMessageProps {
message: Message;
@@ -54,6 +55,12 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
text={message._source?.message || ""}
onComplete={() => setIsAnimationComplete(true)}
/>
{/* <Markdown
key={isTyping ? "loading" : "done"}
content={(message._source?.message || "")}
loading={isTyping}
onDoubleClickCapture={() => {}}
/> */}
{!isAnimationComplete && (
<span className="inline-block w-1.5 h-4 ml-0.5 -mb-0.5 bg-current animate-pulse" />
)}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { Button } from "@headlessui/react";
import { Minimize2, Maximize2 } from "lucide-react";
export function FullScreen(props: any) {
const { children, right = 10, top = 10, ...rest } = props;
const ref = useRef<HTMLDivElement>();
const [fullScreen, setFullScreen] = useState(false);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
ref.current?.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
useEffect(() => {
const handleScreenChange = (e: any) => {
if (e.target === ref.current) {
setFullScreen(!!document.fullscreenElement);
}
};
document.addEventListener("fullscreenchange", handleScreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleScreenChange);
};
}, []);
return (
<div ref={ref} style={{ position: "relative" }} {...rest}>
<div style={{ position: "absolute", right, top }}>
<Button onClick={toggleFullscreen}>
{fullScreen ? <Minimize2 /> : <Maximize2 />}
</Button>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,314 @@
import React, { useRef, useState, RefObject, useEffect, useMemo } from "react";
import clsx from "clsx";
import ReactMarkdown from "react-markdown";
// import "katex/dist/katex.min.css";
import RemarkMath from "remark-math";
import RemarkBreaks from "remark-breaks";
import RehypeKatex from "rehype-katex";
import RemarkGfm from "remark-gfm";
import RehypeHighlight from "rehype-highlight";
import mermaid from "mermaid";
import { useDebouncedCallback } from "use-debounce";
import { copyToClipboard, useWindowSize } from "../../utils";
export function Mermaid(props: { code: string }) {
const ref = useRef<HTMLDivElement>(null);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (props.code && ref.current) {
mermaid
.run({
nodes: [ref.current],
suppressErrors: true,
})
.catch((e) => {
setHasError(true);
console.error("[Mermaid] ", e.message);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.code]);
function viewSvgInNewWindow() {
const svg = ref.current?.querySelector("svg");
if (!svg) return;
const text = new XMLSerializer().serializeToString(svg);
const blob = new Blob([text], { type: "image/svg+xml" });
// view img
// URL.createObjectURL(blob);
}
if (hasError) {
return null;
}
return (
<div
className={clsx("no-dark", "mermaid")}
style={{
cursor: "pointer",
overflow: "auto",
}}
ref={ref}
onClick={() => viewSvgInNewWindow()}
>
{props.code}
</div>
);
}
// 7
export function PreCode(props: { children: any }) {
const ref = useRef<HTMLPreElement>(null);
// const previewRef = useRef<HTMLPreviewHander>(null);
const [mermaidCode, setMermaidCode] = useState("");
const [htmlCode, setHtmlCode] = useState("");
const { height } = useWindowSize();
const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return;
const mermaidDom = ref.current.querySelector("code.language-mermaid");
if (mermaidDom) {
setMermaidCode((mermaidDom as HTMLElement).innerText);
}
const htmlDom = ref.current.querySelector("code.language-html");
const refText = ref.current.querySelector("code")?.innerText;
if (htmlDom) {
setHtmlCode((htmlDom as HTMLElement).innerText);
} else if (refText?.startsWith("<!DOCTYPE")) {
setHtmlCode(refText);
}
}, 600);
const enableArtifacts = true;
//Wrap the paragraph for plain-text
useEffect(() => {
if (ref.current) {
const codeElements = ref.current.querySelectorAll(
"code"
) as NodeListOf<HTMLElement>;
const wrapLanguages = [
"",
"md",
"markdown",
"text",
"txt",
"plaintext",
"tex",
"latex",
];
codeElements.forEach((codeElement) => {
let languageClass = codeElement.className.match(/language-(\w+)/);
let name = languageClass ? languageClass[1] : "";
if (wrapLanguages.includes(name)) {
codeElement.style.whiteSpace = "pre-wrap";
}
});
setTimeout(renderArtifacts, 1);
}
}, []);
return (
<>
<pre ref={ref}>
<span
className="copy-code-button"
onClick={() => {
if (ref.current) {
copyToClipboard(
ref.current.querySelector("code")?.innerText ?? ""
);
}
}}
></span>
{props.children}
</pre>
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
</>
);
}
// 6
function CustomCode(props: { children: any; className?: string }) {
const enableCodeFold = false;
const ref = useRef<HTMLPreElement>(null);
const [collapsed, setCollapsed] = useState(true);
const [showToggle, setShowToggle] = useState(false);
useEffect(() => {
if (ref.current) {
const codeHeight = ref.current.scrollHeight;
setShowToggle(codeHeight > 400);
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [props.children]);
const toggleCollapsed = () => {
setCollapsed((collapsed) => !collapsed);
};
const renderShowMoreButton = () => {
if (showToggle && enableCodeFold && collapsed) {
return (
<div
className={clsx("show-hide-button", {
collapsed,
expanded: !collapsed,
})}
>
<button onClick={toggleCollapsed}>{"NewChat More"}</button>
</div>
);
}
return null;
};
return (
<>
<code
className={clsx(props?.className)}
ref={ref}
style={{
maxHeight: enableCodeFold && collapsed ? "400px" : "none",
overflowY: "hidden",
}}
>
{props.children}
</code>
{renderShowMoreButton()}
</>
);
}
// 5
function escapeBrackets(text: string) {
const pattern =
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
return text.replace(
pattern,
(match, codeBlock, squareBracket, roundBracket) => {
if (codeBlock) {
return codeBlock;
} else if (squareBracket) {
return `$$${squareBracket}$$`;
} else if (roundBracket) {
return `$${roundBracket}$`;
}
return match;
}
);
}
// 4
function tryWrapHtmlCode(text: string) {
// try add wrap html code (fixed: html codeblock include 2 newline)
return text
.replace(
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
(match, quoteStart, lang, newLine, doctype) => {
return !quoteStart ? "\n```html\n" + doctype : match;
}
)
.replace(
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
}
);
}
// 3
function _MarkDownContent(props: { content: string }) {
const escapedContent = useMemo(() => {
return tryWrapHtmlCode(escapeBrackets(props.content));
}, [props.content]);
return (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={[
RehypeKatex,
[
RehypeHighlight,
{
detect: false,
ignoreMissing: true,
},
],
]}
components={{
pre: PreCode,
code: CustomCode,
p: (pProps) => <p {...pProps} dir="auto" />,
a: (aProps) => {
const href = aProps.href || "";
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
return (
<figure>
<audio controls src={href}></audio>
</figure>
);
}
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
return (
<video controls width="99.9%">
<source src={href} />
</video>
);
}
const isInternal = /^\/#/i.test(href);
const target = isInternal ? "_self" : aProps.target ?? "_blank";
return <a {...aProps} target={target} />;
},
}}
>
{escapedContent}
</ReactMarkdown>
);
}
// 2
export const MarkdownContent = React.memo(_MarkDownContent);
// 1
export function Markdown(
props: {
content: string;
loading?: boolean;
fontSize?: number;
fontFamily?: string;
parentRef?: RefObject<HTMLDivElement>;
defaultShow?: boolean;
} & React.DOMAttributes<HTMLDivElement>
) {
const mdRef = useRef<HTMLDivElement>(null);
return (
<div
className="markdown-body"
style={{
fontSize: `${props.fontSize ?? 14}px`,
fontFamily: props.fontFamily || "inherit",
}}
ref={mdRef}
onContextMenu={props.onContextMenu}
onDoubleClickCapture={props.onDoubleClickCapture}
dir="auto"
>
{props.loading ? (
<div className="flex gap-2 items-center text-gray-500 dark:text-gray-400">
<div className="w-2 h-2 rounded-full bg-current animate-bounce" />
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.2s]" />
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.4s]" />
</div>
) : (
<MarkdownContent content={props.content} />
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import "./index.css";
interface TypingAnimationProps {
@@ -31,5 +32,13 @@ export function TypingAnimation({
// console.log("text", text);
return <ReactMarkdown>{text}</ReactMarkdown>;
// return <ReactMarkdown>{text}</ReactMarkdown>;
return (
<ReactMarkdown
className="prose" // 使用 Tailwind 的 `prose` 类来美化 Markdown
children={text}
remarkPlugins={[remarkGfm]}
/>
);
}

View File

@@ -1,13 +1,17 @@
import { useState, useRef, useEffect } from "react";
import { Menu, Loader } from "lucide-react";
import {
PanelRightClose,
PanelRightOpen,
MessageSquarePlus,
} from "lucide-react";
import { motion } from "framer-motion";
import { ThemeToggle } from "./ThemeToggle";
// import { ThemeToggle } from "./ThemeToggle";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { Sidebar } from "./Sidebar";
import type { Chat, Message } from "./types";
import { useTheme } from "../ThemeProvider";
import ChatSwitch from "../SearchChat/ChatSwitch";
import { Footer } from "../SearchChat/Footer";
import { tauriFetch } from "../../api/tauriFetchClient";
import { useWebSocket } from "../../hooks/useWebSocket";
@@ -55,7 +59,6 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
}
);
// websocket
// websocket
useEffect(() => {
if (messages.length === 0 || !activeChat?._id) return;
@@ -220,9 +223,16 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
};
return (
<div className="h-screen pb-8 rounded-xl overflow-hidden">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="h-screen pb-8 rounded-xl overflow-hidden"
>
<div className="h-[100%] flex">
{/* Sidebar */}
{isSidebarOpen ? (
<div
className={`fixed inset-y-0 left-0 z-50 w-64 transform ${
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
@@ -241,6 +251,7 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
/>
) : null}
</div>
) : null}
{/* Main content */}
<div
@@ -248,7 +259,12 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
theme === "dark" ? "bg-gray-900" : "bg-white"
}`}
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.2 }}
>
<header
className={`flex items-center justify-between p-4 border-b ${
theme === "dark" ? "border-gray-800" : "border-gray-200"
@@ -256,23 +272,32 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
>
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`md:hidden p-2 rounded-lg transition-colors ${
className={`p-2 rounded-lg transition-colors ${
theme === "dark"
? "hover:bg-gray-800 text-gray-300"
: "hover:bg-gray-100 text-gray-600"
}`}
>
<Menu className="h-6 w-6" />
{isSidebarOpen ? (
<PanelRightClose className="h-6 w-6" />
) : (
<PanelRightOpen className="h-6 w-6" />
)}
</button>
<div className="flex-1">
<ChatSwitch isChat={true} changeMode={changeMode} />
</div>
<ThemeToggle />
{/* <ThemeToggle /> */}
<MessageSquarePlus className="cursor-pointer" onClick={createNewChat} />
</header>
</motion.div>
{/* Chat messages */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: 0.3 }}
className="flex-1 overflow-y-auto custom-scrollbar"
>
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
@@ -298,11 +323,6 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
/>
) : null}
{/* Loading */}
{/* {isTyping && (
<div className="flex pt-0 pb-4 pl-20">
<Loader className="animate-spin h-5 w-5 text-gray-500" />
</div>
)} */}
{isTyping && (
<div className="flex pt-0 pb-4 pl-20 gap-2 items-center text-gray-500 dark:text-gray-400">
<div className="w-2 h-2 rounded-full bg-current animate-bounce" />
@@ -311,10 +331,14 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
</div>
)}
<div ref={messagesEndRef} />
</div>
</motion.div>
{/* Input area */}
<div
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
exit={{ y: 100 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={`border-t p-4 ${
theme === "dark" ? "border-gray-800" : "border-gray-200"
}`}
@@ -323,12 +347,13 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={setIsTyping}
changeMode={changeMode}
/>
</div>
</motion.div>
</div>
</div>
<Footer isChat={true} />
</div>
</motion.div>
);
}

View File

@@ -5,6 +5,7 @@ import {
getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import { motion } from "framer-motion";
import { SearchResults } from "./SearchResults";
import { Footer } from "./Footer";
@@ -62,13 +63,17 @@ function Search({ changeMode }: SearchProps) {
}
return (
<div
className={`min-h-screen flex items-start justify-center ${
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className={`min-h-screen bg-opacity-0 flex items-start justify-center rounded-xl overflow-hidden ${
tags.length > 0 ? "pb-8" : ""
} rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-900`}
}`}
>
<div className="w-full space-y-4 rounded-xl overflow-hidden">
<div className="border b-t-none border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="w-full rounded-xl overflow-hidden">
<div className="border b-t-none bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
{/* Search Bar */}
<div className="relative">
<div className="flex items-center bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-2 focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-900 focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-all">
@@ -123,11 +128,29 @@ function Search({ changeMode }: SearchProps) {
</div>
{/* Search Results Panel */}
{tags.length > 0 ? <SearchResults /> : null}
{tags.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.2 }}
>
<SearchResults />
</motion.div>
) : null}
</div>
{tags.length > 0 ? <Footer isChat={false} /> : null}
</div>
{tags.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ delay: 0.2 }}
>
<Footer isChat={false} />
</motion.div>
) : null}
</motion.div>
);
}

View File

@@ -8,7 +8,7 @@ export const SearchResults: React.FC = () => {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mt-4 overflow-hidden">
<div className="flex h-[calc(100vh-220px)]">
<div className="flex h-[calc(100vh-130px)]">
{/* Left Panel */}
<div className="w-[420px] border-r border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
<div className="px-4 flex-shrink-0">

View File

@@ -1,6 +1,7 @@
import { useState, Fragment } from "react";
import { useState } from "react";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import { AnimatePresence, LayoutGroup } from "framer-motion";
import Search from "./Search";
import ChatAI from "../ChatAI";
@@ -9,20 +10,22 @@ export default function SearchChat() {
const [isChatMode, setIsChatMode] = useState(true);
async function changeMode(value: boolean) {
setIsChatMode(value);
if (value) {
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 800));
} else {
await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 110));
}
setIsChatMode(value);
}
return (
<Fragment>
<LayoutGroup>
<AnimatePresence mode="wait">
{isChatMode ? (
<ChatAI changeMode={changeMode} />
<ChatAI key="ChatAI" changeMode={changeMode} />
) : (
<Search changeMode={changeMode} />
<Search key="Search" changeMode={changeMode} />
)}
</Fragment>
</AnimatePresence>
</LayoutGroup>
);
}

32
src/index.d.ts vendored
View File

@@ -20,3 +20,35 @@ declare global {
__TAURI__: Record<string, unknown>;
}
}
declare interface Window {
__TAURI__?: {
writeText(text: string): Promise<void>;
invoke(command: string, payload?: Record<string, unknown>): Promise<any>;
dialog: {
save(options?: Record<string, unknown>): Promise<string | null>;
};
fs: {
writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
writeTextFile(path: string, data: string): Promise<void>;
};
notification: {
requestPermission(): Promise<Permission>;
isPermissionGranted(): Promise<boolean>;
sendNotification(options: string | Options): void;
};
updater: {
checkUpdate(): Promise<UpdateResult>;
installUpdate(): Promise<void>;
onUpdaterEvent(
handler: (status: UpdateStatusResult) => void,
): Promise<UnlistenFn>;
};
http: {
fetch<T>(
url: string,
options?: Record<string, unknown>,
): Promise<Response<T>>;
};
};
}

View File

@@ -21,11 +21,13 @@
@apply box-border border-[--border];
}
body {
body,
#root {
@apply text-gray-900 rounded-lg shadow-lg overflow-hidden antialiased;
}
.dark body {
.dark body,
.dark #root{
@apply text-gray-100 rounded-lg shadow-lg overflow-hidden antialiased;
}
}

52
src/utils/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
// 1
export async function copyToClipboard(text: string) {
try {
if (window.__TAURI__) {
window.__TAURI__.writeText(text);
} else {
await navigator.clipboard.writeText(text);
}
console.info("Copy Success");
} catch (error) {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
console.info("Copy Success");
} catch (error) {
console.info("Copy Failed");
}
document.body.removeChild(textArea);
}
}
// 2
export function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const onResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
return size;
}

View File

@@ -5,14 +5,12 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,