feat: close #680 lazy rendering markdown

This commit is contained in:
Yidadaa 2023-04-13 02:07:24 +08:00
parent d790b0b372
commit 8363cdd9fa
11 changed files with 168 additions and 20 deletions

View File

@ -1,5 +1,29 @@
@import "../styles/animation.scss"; @import "../styles/animation.scss";
.chat-input-actions {
display: flex;
flex-wrap: wrap;
.chat-input-action {
display: inline-flex;
border-radius: 20px;
font-size: 12px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
padding: 4px 10px;
animation: slide-in ease 0.3s;
box-shadow: var(--card-shadow);
transition: all ease 0.3s;
margin-bottom: 10px;
align-items: center;
&:not(:last-child) {
margin-right: 5px;
}
}
}
.prompt-toast { .prompt-toast {
position: absolute; position: absolute;
bottom: -50px; bottom: -50px;

View File

@ -14,6 +14,11 @@ import DeleteIcon from "../icons/delete.svg";
import MaxIcon from "../icons/max.svg"; import MaxIcon from "../icons/max.svg";
import MinIcon from "../icons/min.svg"; import MinIcon from "../icons/min.svg";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg";
import { import {
Message, Message,
SubmitKey, SubmitKey,
@ -22,6 +27,7 @@ import {
ROLES, ROLES,
createMessage, createMessage,
useAccessStore, useAccessStore,
Theme,
} from "../store"; } from "../store";
import { import {
@ -31,6 +37,7 @@ import {
isMobileScreen, isMobileScreen,
selectOrCopy, selectOrCopy,
autoGrowTextArea, autoGrowTextArea,
getCSSVar,
} from "../utils"; } from "../utils";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -60,7 +67,11 @@ export function Avatar(props: { role: Message["role"] }) {
const config = useChatStore((state) => state.config); const config = useChatStore((state) => state.config);
if (props.role !== "user") { if (props.role !== "user") {
return <BotIcon className={styles["user-avtar"]} />; return (
<div className="no-dark">
<BotIcon className={styles["user-avtar"]} />
</div>
);
} }
return ( return (
@ -316,22 +327,78 @@ function useScrollToBottom() {
// for auto-scroll // for auto-scroll
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const scrollToBottom = () => {
const dom = scrollRef.current;
if (dom) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
};
// auto scroll // auto scroll
useLayoutEffect(() => { useLayoutEffect(() => {
const dom = scrollRef.current; autoScroll && scrollToBottom();
if (dom && autoScroll) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
}); });
return { return {
scrollRef, scrollRef,
autoScroll, autoScroll,
setAutoScroll, setAutoScroll,
scrollToBottom,
}; };
} }
export function ChatActions(props: {
showPromptModal: () => void;
scrollToBottom: () => void;
hitBottom: boolean;
}) {
const chatStore = useChatStore();
const theme = chatStore.config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
chatStore.updateConfig((config) => (config.theme = nextTheme));
}
return (
<div className={chatStyle["chat-input-actions"]}>
{!props.hitBottom && (
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={props.scrollToBottom}
>
<BottomIcon />
</div>
)}
{props.hitBottom && (
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={props.showPromptModal}
>
<BrainIcon />
</div>
)}
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={nextTheme}
>
{theme === Theme.Auto ? (
<AutoIcon />
) : theme === Theme.Light ? (
<LightIcon />
) : theme === Theme.Dark ? (
<DarkIcon />
) : null}
</div>
</div>
);
}
export function Chat(props: { export function Chat(props: {
showSideBar?: () => void; showSideBar?: () => void;
sideBarShowing?: boolean; sideBarShowing?: boolean;
@ -350,7 +417,7 @@ export function Chat(props: {
const [beforeInput, setBeforeInput] = useState(""); const [beforeInput, setBeforeInput] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler(); const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll } = useScrollToBottom(); const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
const [hitBottom, setHitBottom] = useState(false); const [hitBottom, setHitBottom] = useState(false);
const onChatBodyScroll = (e: HTMLElement) => { const onChatBodyScroll = (e: HTMLElement) => {
@ -683,6 +750,8 @@ export function Chat(props: {
if (!isMobileScreen()) return; if (!isMobileScreen()) return;
setUserInput(message.content); setUserInput(message.content);
}} }}
fontSize={fontSize}
parentRef={scrollRef}
/> />
</div> </div>
{!isUser && !message.preview && ( {!isUser && !message.preview && (
@ -700,6 +769,12 @@ export function Chat(props: {
<div className={styles["chat-input-panel"]}> <div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} /> <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<ChatActions
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
/>
<div className={styles["chat-input-panel-inner"]}> <div className={styles["chat-input-panel-inner"]}>
<textarea <textarea
ref={inputRef} ref={inputRef}

View File

@ -36,6 +36,7 @@
max-height: var(--full-height); max-height: var(--full-height);
border-radius: 0; border-radius: 0;
border: 0;
} }
} }
@ -230,6 +231,7 @@
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: 20px; padding: 20px;
padding-bottom: 40px;
position: relative; position: relative;
} }
@ -354,11 +356,16 @@
} }
.chat-input-panel { .chat-input-panel {
position: relative;
width: 100%; width: 100%;
padding: 20px; padding: 20px;
padding-top: 5px; padding-top: 10px;
box-sizing: border-box; box-sizing: border-box;
flex-direction: column; flex-direction: column;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-top: var(--border-in-light);
box-shadow: var(--card-shadow);
} }
@mixin single-line { @mixin single-line {

View File

@ -17,7 +17,7 @@ import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg"; import CloseIcon from "../icons/close.svg";
import { useChatStore } from "../store"; import { useChatStore } from "../store";
import { isMobileScreen } from "../utils"; import { getCSSVar, isMobileScreen } from "../utils";
import Locale from "../locales"; import Locale from "../locales";
import { Chat } from "./chat"; import { Chat } from "./chat";
@ -66,9 +66,7 @@ function useSwitchTheme() {
metaDescriptionDark?.setAttribute("content", "#151515"); metaDescriptionDark?.setAttribute("content", "#151515");
metaDescriptionLight?.setAttribute("content", "#fafafa"); metaDescriptionLight?.setAttribute("content", "#fafafa");
} else { } else {
const themeColor = getComputedStyle(document.body) const themeColor = getCSSVar("--themeColor");
.getPropertyValue("--theme-color")
.trim();
metaDescriptionDark?.setAttribute("content", themeColor); metaDescriptionDark?.setAttribute("content", themeColor);
metaDescriptionLight?.setAttribute("content", themeColor); metaDescriptionLight?.setAttribute("content", themeColor);
} }

View File

@ -47,7 +47,8 @@ const useLazyLoad = (ref: RefObject<Element>): boolean => {
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}, [ref]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return isIntersecting; return isIntersecting;
}; };
@ -57,18 +58,49 @@ export function Markdown(
content: string; content: string;
loading?: boolean; loading?: boolean;
fontSize?: number; fontSize?: number;
parentRef: RefObject<HTMLDivElement>;
} & React.DOMAttributes<HTMLDivElement>, } & React.DOMAttributes<HTMLDivElement>,
) { ) {
const mdRef = useRef(null); const mdRef = useRef<HTMLDivElement>(null);
const shouldRender = useLazyLoad(mdRef);
const shouldLoading = props.loading || !shouldRender; const parent = props.parentRef.current;
const md = mdRef.current;
const rendered = useRef(false);
const [counter, setCounter] = useState(0);
useEffect(() => {
// to triggr rerender
setCounter(counter + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.loading]);
const inView =
rendered.current ||
(() => {
if (parent && md) {
const parentBounds = parent.getBoundingClientRect();
const mdBounds = md.getBoundingClientRect();
const isInRange = (x: number) =>
x <= parentBounds.bottom && x >= parentBounds.top;
const inView = isInRange(mdBounds.top) || isInRange(mdBounds.bottom);
if (inView) {
rendered.current = true;
}
return inView;
}
})();
const shouldLoading = props.loading || !inView;
return ( return (
<div <div
className="markdown-body" className="markdown-body"
style={{ fontSize: `${props.fontSize ?? 14}px` }} style={{ fontSize: `${props.fontSize ?? 14}px` }}
{...props}
ref={mdRef} ref={mdRef}
onContextMenu={props.onContextMenu}
onDoubleClickCapture={props.onDoubleClickCapture}
> >
{shouldLoading ? ( {shouldLoading ? (
<LoadingIcon /> <LoadingIcon />

1
app/icons/auto.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="分组 1" style="stroke:#333333; stroke-width:1; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.333333333333333) rotate(0 2.333750009536743 2.6666666666666665)" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.533316666666666 2.6666666666666665)" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67 " /><path id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 9.333333333333332) rotate(0 6.533316666666666 2.6666666666666665)" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
app/icons/bottom.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8.002766666666666 2) rotate(0 0 4.649916666666667)" d="M0,9.3L0,0 " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 7.333333333333333) rotate(0 4 2)" d="M8,0L4,4L0,0 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 14) rotate(0 4 0)" d="M8,0L0,0 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 958 B

1
app/icons/dark.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.333333333333485) rotate(0 6.666666666666666 6.666666666666666)" d="M6.67,0L4.91,1.76L1.76,1.76L1.76,4.91L0,6.67L1.76,8.42L1.76,11.58L4.91,11.58L6.67,13.33L8.42,11.58L11.58,11.58L11.58,8.42L13.33,6.67L11.58,4.91L11.58,1.76L8.42,1.76L6.67,0Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.44771525016904) rotate(0 2.4732087011352872 2.442809041582063)" d="M4,0.55C2.17,-0.78 0,0.55 0,1.89C1.67,1.89 3.33,2.22 3.33,4.89C4.67,4.89 5.83,1.89 4,0.55Z " /></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
app/icons/light.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4.340166666666667 4.21550000000002) rotate(0 3.6666666666666665 3.666666666666666)" d="M0,3.67C0,5.69 1.64,7.33 3.67,7.33C5.69,7.33 7.33,5.69 7.33,3.67C7.33,1.64 5.69,0 3.67,0C1.64,0 0,1.64 0,3.67Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(12.166666666666666 12.1719333333333) rotate(0 0.4100499999999994 0.41240499999999997)" d="M0.82,0.82L0,0 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(3.0068366666666666 3.0654333333332033) rotate(0 0.3411483333333332 0.34309999999999974)" d="M0.68,0.69L0,0 " /><path id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 1.2155666666667457) rotate(0 0 0.5)" d="M0,1L0,0 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(13.333266666666667 8.21550000000002) rotate(0 0.6666666666666666 0)" d="M1.33,0L0,0 " /><path id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(12.5108 3.065499999999929) rotate(0 0.41123333333333295 0.41123333333333295)" d="M0,0.82L0.82,0 " /><path id="路径 7" fill-rule="evenodd" style="fill:#333333" transform="translate(5.673499999999999 5.5488333333332776) rotate(0 1.1666666666666665 2.333333333333333)" opacity="1" d="M2.33,0C1.04,0 0,1.04 0,2.33C0,3.62 1.04,4.67 2.33,4.67L2.33,0Z " /><path id="路径 8" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333966666666666 7.8821666666667625) rotate(0 0.6666666666666666 0)" d="M0,0L1.33,0 " /><path id="路径 9" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(3.348133333333333 12.3125) rotate(0 0.3421333333333335 0.3421266666666665)" d="M0,0.68L0.68,0 " /><path id="路径 10" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 13.548763333333454) rotate(0 0 0.6666666666666666)" d="M0,1.33L0,0 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,4 +1,6 @@
@mixin light { @mixin light {
--theme: light;
/* color */ /* color */
--white: white; --white: white;
--black: rgb(48, 48, 48); --black: rgb(48, 48, 48);
@ -18,6 +20,8 @@
} }
@mixin dark { @mixin dark {
--theme: dark;
/* color */ /* color */
--white: rgb(30, 30, 30); --white: rgb(30, 30, 30);
--black: rgb(187, 187, 187); --black: rgb(187, 187, 187);
@ -31,6 +35,10 @@
--border-in-light: 1px solid rgba(255, 255, 255, 0.192); --border-in-light: 1px solid rgba(255, 255, 255, 0.192);
--theme-color: var(--gray); --theme-color: var(--gray);
div:not(.no-dark) > svg {
filter: invert(0.5);
}
} }
.light { .light {
@ -282,10 +290,6 @@ pre {
.clickable { .clickable {
cursor: pointer; cursor: pointer;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
&:hover { &:hover {
filter: brightness(0.9); filter: brightness(0.9);
} }

View File

@ -120,3 +120,7 @@ export function autoGrowTextArea(dom: HTMLTextAreaElement) {
return rows; return rows;
} }
export function getCSSVar(varName: string) {
return getComputedStyle(document.body).getPropertyValue(varName).trim();
}