mirror of
https://github.com/Yidadaa/ChatGPT-Next-Web.git
synced 2025-09-05 15:06:53 +08:00
feat: seperate chat page
This commit is contained in:
83
app/hooks/useDragSideBar.ts
Normal file
83
app/hooks/useDragSideBar.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
NARROW_SIDEBAR_WIDTH,
|
||||
} from "@/app/constant";
|
||||
import { useAppConfig } from "../store/config";
|
||||
import { useEffect, useRef } from "react";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
|
||||
export default function useDragSideBar() {
|
||||
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
||||
|
||||
const config = useAppConfig();
|
||||
const startX = useRef(0);
|
||||
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||
const lastUpdateTime = useRef(Date.now());
|
||||
|
||||
const toggleSideBar = () => {
|
||||
config.update((config) => {
|
||||
if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
|
||||
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
} else {
|
||||
config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onDragStart = (e: MouseEvent) => {
|
||||
// Remembers the initial width each time the mouse is pressed
|
||||
startX.current = e.clientX;
|
||||
startDragWidth.current = config.sidebarWidth;
|
||||
const dragStartTime = Date.now();
|
||||
|
||||
const handleDragMove = (e: MouseEvent) => {
|
||||
if (Date.now() < lastUpdateTime.current + 20) {
|
||||
return;
|
||||
}
|
||||
lastUpdateTime.current = Date.now();
|
||||
const d = e.clientX - startX.current;
|
||||
const nextWidth = limit(startDragWidth.current + d);
|
||||
config.update((config) => {
|
||||
if (nextWidth < MIN_SIDEBAR_WIDTH) {
|
||||
config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
|
||||
} else {
|
||||
config.sidebarWidth = nextWidth;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
// In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
|
||||
window.removeEventListener("pointermove", handleDragMove);
|
||||
window.removeEventListener("pointerup", handleDragEnd);
|
||||
|
||||
// if user click the drag icon, should toggle the sidebar
|
||||
const shouldFireClick = Date.now() - dragStartTime < 300;
|
||||
if (shouldFireClick) {
|
||||
toggleSideBar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleDragMove);
|
||||
window.addEventListener("pointerup", handleDragEnd);
|
||||
};
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const shouldNarrow =
|
||||
!isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
|
||||
|
||||
useEffect(() => {
|
||||
const barWidth = shouldNarrow
|
||||
? NARROW_SIDEBAR_WIDTH
|
||||
: limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
|
||||
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
|
||||
}, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
|
||||
|
||||
return {
|
||||
onDragStart,
|
||||
shouldNarrow,
|
||||
};
|
||||
}
|
21
app/hooks/useHotKey.ts
Normal file
21
app/hooks/useHotKey.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useChatStore } from "../store/chat";
|
||||
|
||||
export default function useHotKey() {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey) {
|
||||
if (e.key === "ArrowUp") {
|
||||
chatStore.nextSession(-1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
chatStore.nextSession(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
});
|
||||
}
|
12
app/hooks/useMobileScreen.ts
Normal file
12
app/hooks/useMobileScreen.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useWindowSize } from "../utils";
|
||||
|
||||
export const MOBILE_MAX_WIDTH = 600;
|
||||
|
||||
export default function useMobileScreen() {
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const isMobile = width <= MOBILE_MAX_WIDTH;
|
||||
|
||||
return isMobile;
|
||||
}
|
72
app/hooks/usePaste.ts
Normal file
72
app/hooks/usePaste.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { compressImage, isVisionModel } from "@/app/utils";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useChatStore } from "../store/chat";
|
||||
|
||||
interface UseUploadImageOptions {
|
||||
setUploading?: (v: boolean) => void;
|
||||
emitImages?: (imgs: string[]) => void;
|
||||
}
|
||||
|
||||
export default function usePaste(
|
||||
attachImages: string[],
|
||||
options: UseUploadImageOptions,
|
||||
) {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const attachImagesRef = useRef<string[]>([]);
|
||||
const optionsRef = useRef<UseUploadImageOptions>({});
|
||||
const chatStoreRef = useRef<typeof chatStore | undefined>();
|
||||
|
||||
attachImagesRef.current = attachImages;
|
||||
optionsRef.current = options;
|
||||
chatStoreRef.current = chatStore;
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const { setUploading, emitImages } = optionsRef.current;
|
||||
const currentModel =
|
||||
chatStoreRef.current?.currentSession().mask.modelConfig.model;
|
||||
if (currentModel && !isVisionModel(currentModel)) {
|
||||
return;
|
||||
}
|
||||
const items = (event.clipboardData || window.clipboardData).items;
|
||||
for (const item of items) {
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
event.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const images: string[] = [];
|
||||
images.push(...attachImages);
|
||||
images.push(
|
||||
...(await new Promise<string[]>((res, rej) => {
|
||||
setUploading?.(true);
|
||||
const imagesData: string[] = [];
|
||||
compressImage(file, 256 * 1024)
|
||||
.then((dataUrl) => {
|
||||
imagesData.push(dataUrl);
|
||||
setUploading?.(false);
|
||||
res(imagesData);
|
||||
})
|
||||
.catch((e) => {
|
||||
setUploading?.(false);
|
||||
rej(e);
|
||||
});
|
||||
})),
|
||||
);
|
||||
const imagesLength = images.length;
|
||||
|
||||
if (imagesLength > 3) {
|
||||
images.splice(3, imagesLength - 3);
|
||||
}
|
||||
emitImages?.(images);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handlePaste,
|
||||
};
|
||||
}
|
33
app/hooks/useScrollToBottom.ts
Normal file
33
app/hooks/useScrollToBottom.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
|
||||
export default function useScrollToBottom(
|
||||
scrollRef: RefObject<HTMLDivElement>,
|
||||
detach: boolean = false,
|
||||
) {
|
||||
// for auto-scroll
|
||||
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
function scrollDomToBottom() {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
setAutoScroll(true);
|
||||
dom.scrollTo(0, dom.scrollHeight);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// auto scroll
|
||||
useEffect(() => {
|
||||
if (autoScroll && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
}
|
49
app/hooks/useSubmitHandler.ts
Normal file
49
app/hooks/useSubmitHandler.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { SubmitKey, useAppConfig } from "../store/config";
|
||||
|
||||
export default function useSubmitHandler() {
|
||||
const config = useAppConfig();
|
||||
const submitKey = config.submitKey;
|
||||
const isComposing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onCompositionStart = () => {
|
||||
isComposing.current = true;
|
||||
};
|
||||
const onCompositionEnd = () => {
|
||||
isComposing.current = false;
|
||||
};
|
||||
|
||||
window.addEventListener("compositionstart", onCompositionStart);
|
||||
window.addEventListener("compositionend", onCompositionEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("compositionstart", onCompositionStart);
|
||||
window.removeEventListener("compositionend", onCompositionEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Fix Chinese input method "Enter" on Safari
|
||||
if (e.keyCode == 229) return false;
|
||||
if (e.key !== "Enter") return false;
|
||||
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
|
||||
return false;
|
||||
return (
|
||||
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
|
||||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
|
||||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
|
||||
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
|
||||
(config.submitKey === SubmitKey.Enter &&
|
||||
!e.altKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
submitKey,
|
||||
shouldSubmit,
|
||||
};
|
||||
}
|
69
app/hooks/useUploadImage.ts
Normal file
69
app/hooks/useUploadImage.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { compressImage } from "@/app/utils";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
interface UseUploadImageOptions {
|
||||
setUploading?: (v: boolean) => void;
|
||||
emitImages?: (imgs: string[]) => void;
|
||||
}
|
||||
|
||||
export default function useUploadImage(
|
||||
attachImages: string[],
|
||||
options: UseUploadImageOptions,
|
||||
) {
|
||||
const attachImagesRef = useRef<string[]>([]);
|
||||
const optionsRef = useRef<UseUploadImageOptions>({});
|
||||
|
||||
attachImagesRef.current = attachImages;
|
||||
optionsRef.current = options;
|
||||
|
||||
const uploadImage = useCallback(async function uploadImage() {
|
||||
const images: string[] = [];
|
||||
images.push(...attachImagesRef.current);
|
||||
|
||||
const { setUploading, emitImages } = optionsRef.current;
|
||||
|
||||
images.push(
|
||||
...(await new Promise<string[]>((res, rej) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept =
|
||||
"image/png, image/jpeg, image/webp, image/heic, image/heif";
|
||||
fileInput.multiple = true;
|
||||
fileInput.onchange = (event: any) => {
|
||||
setUploading?.(true);
|
||||
const files = event.target.files;
|
||||
const imagesData: string[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = event.target.files[i];
|
||||
compressImage(file, 256 * 1024)
|
||||
.then((dataUrl) => {
|
||||
imagesData.push(dataUrl);
|
||||
if (
|
||||
imagesData.length === 3 ||
|
||||
imagesData.length === files.length
|
||||
) {
|
||||
setUploading?.(false);
|
||||
res(imagesData);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setUploading?.(false);
|
||||
rej(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
})),
|
||||
);
|
||||
|
||||
const imagesLength = images.length;
|
||||
if (imagesLength > 3) {
|
||||
images.splice(3, imagesLength - 3);
|
||||
}
|
||||
emitImages?.(images);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploadImage,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user