This commit is contained in:
Red J Adaya
2024-07-03 14:32:55 -07:00
committed by GitHub
parent 75c9e211d9
commit 1f973b3fdc
16 changed files with 1046 additions and 80 deletions
+6
View File
@@ -5,6 +5,7 @@ import { CodeEdit } from "@/app/view/codeedit";
import { PlotView } from "@/app/view/plotview";
import { PreviewView } from "@/app/view/preview";
import { TerminalView } from "@/app/view/term/term";
import { WaveAi } from "@/app/view/waveai";
import { WebView } from "@/app/view/webview";
import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems";
@@ -378,6 +379,9 @@ function blockViewToIcon(view: string): string {
if (view == "web") {
return "globe";
}
if (view == "waveai") {
return "sparkles";
}
return null;
}
@@ -451,6 +455,8 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => {
blockElem = <CodeEdit key={blockId} text={null} filename={null} />;
} else if (blockData.view === "web") {
blockElem = <WebView key={blockId} parentRef={blockRef} initialUrl={blockData.meta.url} />;
} else if (blockData.view === "waveai") {
blockElem = <WaveAi key={blockId} parentRef={blockRef} />;
}
return (
<BlockFrame
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.copy-button {
padding: 5px 5px;
.fa-check {
color: var(--app-success-color);
}
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { clsx } from "clsx";
import * as React from "react";
import { Button } from "./button";
import "./copybutton.less";
type CopyButtonProps = {
title: string;
className?: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
const CopyButton = ({ title, className, onClick }: CopyButtonProps) => {
const [isCopied, setIsCopied] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (isCopied) {
return;
}
setIsCopied(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
timeoutRef.current = null;
}, 2000);
if (onClick) {
onClick(e);
}
};
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<Button onClick={handleOnClick} className={clsx("copy-button secondary ghost", className)} title={title}>
{isCopied ? <i className="fa-sharp fa-solid fa-check"></i> : <i className="fa-sharp fa-solid fa-copy"></i>}
</Button>
);
};
export { CopyButton };
+65 -17
View File
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
.markdown {
color: var(--main-text-color);
color: var(--app-text-color);
font-family: var(--markdown-font);
font-size: 14px;
overflow-wrap: break-word;
@@ -12,13 +12,13 @@
}
.title {
color: var(--main-text-color);
color: var(--app-text-color);
margin-top: 16px;
margin-bottom: 8px;
}
strong {
color: var(--main-text-color);
color: var(--app-text-color);
}
a {
@@ -27,7 +27,7 @@
table {
tr th {
color: var(--main-text-color);
color: var(--app-text-color);
}
}
@@ -45,30 +45,74 @@
blockquote {
margin: 4px 10px 4px 10px;
border-radius: 3px;
background-color: var(--panel-bg-color);
background-color: var(--markdown-bg-color);
padding: 2px 4px 2px 6px;
}
pre {
background-color: var(--panel-bg-color);
margin: 4px 10px 4px 10px;
padding: 0.7em;
pre.codeblock {
background-color: var(--markdown-bg-color);
margin: 4px 10px;
padding: 0.4em 0.7em;
border-radius: 4px;
position: relative;
code {
background-color: transparent;
padding: 0;
line-height: normal;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
overflow: hidden;
}
.codeblock-actions {
display: none;
position: absolute;
top: 4px;
right: 4px;
border-radius: 4px;
align-items: center;
justify-content: flex-end;
padding-left: 4px;
padding-right: 4px;
background-color: var(--line-actions-bg-color);
backdrop-filter: blur(8px);
i {
color: var(--line-actions-inactive-color);
margin-left: 4px;
&:first-child {
margin-left: 0px;
}
&:hover {
color: var(--line-actions-active-color);
}
&.fa-check {
color: var(--app-success-color);
}
&.fa-square-terminal {
cursor: pointer;
}
}
}
&:hover .codeblock-actions {
display: flex;
}
}
code {
font: var(--fixed-font);
color: var(--main-text-color);
color: var(--app-text-color);
background-color: var(--markdown-bg-color);
font-family: var(--termfontfamily);
border-radius: 4px;
background-color: var(--panel-bg-color);
padding: 0.15em 0.5em;
line-height: 1.5;
}
pre.selected {
outline: 2px solid var(--markdown-outline-color);
}
.title {
@@ -100,6 +144,10 @@
}
}
.markdown.content {
line-height: 1.5;
}
.markdown > *:first-child {
margin-top: 0 !important;
}
+80 -21
View File
@@ -1,43 +1,102 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { CopyButton } from "@/app/element/copybutton";
import { clsx } from "clsx";
import React from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import "./markdown.less";
function LinkRenderer(props: any): any {
let newUrl = "https://extern?" + encodeURIComponent(props.href);
const Link = ({ href, children }: { href: string; children: React.ReactNode }) => {
const newUrl = "https://extern?" + encodeURIComponent(href);
return (
<a href={newUrl} target="_blank" rel={"noopener"}>
{props.children}
<a href={newUrl} target="_blank" rel="noopener">
{children}
</a>
);
}
};
function HeaderRenderer(props: any, hnum: number): any {
return <div className={clsx("title", "is-" + hnum)}>{props.children}</div>;
}
const Header = ({ children, hnum }: { children: React.ReactNode; hnum: number }) => {
return <div className={clsx("title", `is-${hnum}`)}>{children}</div>;
};
function Markdown(props: { text: string; style?: any; extraClassName?: string; codeSelect?: boolean }) {
let text = props.text;
let markdownComponents = {
a: LinkRenderer,
h1: (props) => HeaderRenderer(props, 1),
h2: (props) => HeaderRenderer(props, 2),
h3: (props) => HeaderRenderer(props, 3),
h4: (props) => HeaderRenderer(props, 4),
h5: (props) => HeaderRenderer(props, 5),
h6: (props) => HeaderRenderer(props, 6),
const Code = ({ children }: { children: React.ReactNode }) => {
return <code>{children}</code>;
};
type CodeBlockProps = {
children: React.ReactNode;
onClickExecute?: (cmd: string) => void;
};
const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {
const getTextContent = (children: any): string => {
if (typeof children === "string") {
return children;
} else if (Array.isArray(children)) {
return children.map(getTextContent).join("");
} else if (children.props && children.props.children) {
return getTextContent(children.props.children);
}
return "";
};
const handleCopy = async (e: React.MouseEvent) => {
let textToCopy = getTextContent(children);
textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline
await navigator.clipboard.writeText(textToCopy);
};
const handleExecute = (e: React.MouseEvent) => {
let textToCopy = getTextContent(children);
textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline
if (onClickExecute) {
onClickExecute(textToCopy);
return;
}
};
return (
<div className={clsx("markdown", props.extraClassName)} style={props.style}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
<pre className="codeblock">
{children}
<div className="codeblock-actions">
<CopyButton className="copy-button" onClick={handleCopy} title="Copy" />
{onClickExecute && <i className="fa-regular fa-square-terminal" onClick={handleExecute}></i>}
</div>
</pre>
);
};
type MarkdownProps = {
text: string;
style?: React.CSSProperties;
className?: string;
onClickExecute?: (cmd: string) => void;
};
const Markdown = ({ text, style, className, onClickExecute }: MarkdownProps) => {
const markdownComponents = {
a: Link,
h1: (props: any) => <Header {...props} hnum={1} />,
h2: (props: any) => <Header {...props} hnum={2} />,
h3: (props: any) => <Header {...props} hnum={3} />,
h4: (props: any) => <Header {...props} hnum={4} />,
h5: (props: any) => <Header {...props} hnum={5} />,
h6: (props: any) => <Header {...props} hnum={6} />,
code: Code,
pre: (props: any) => <CodeBlock {...props} onClickExecute={onClickExecute} />,
};
return (
<div className={clsx("markdown content", className)} style={style}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
};
export { Markdown };
+43
View File
@@ -0,0 +1,43 @@
@dot-width: 11px;
@dot-color: var(--app-success-color);
@speed: 1.5s;
.typing {
position: relative;
height: @dot-width;
span {
content: "";
animation: blink @speed infinite;
animation-fill-mode: both;
height: @dot-width;
width: @dot-width;
background: @dot-color;
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
&:nth-child(2) {
animation-delay: 0.2s;
margin-left: @dot-width * 1.5;
}
&:nth-child(3) {
animation-delay: 0.4s;
margin-left: @dot-width * 3;
}
}
}
@keyframes blink {
0% {
opacity: 0.1;
}
20% {
opacity: 1;
}
100% {
opacity: 0.1;
}
}
+18
View File
@@ -0,0 +1,18 @@
import { clsx } from "clsx";
import "./typingindicator.less";
type TypingIndicatorProps = {
className?: string;
};
const TypingIndicator = ({ className }: TypingIndicatorProps) => {
return (
<div className={clsx("typing", className)}>
<span></span>
<span></span>
<span></span>
</div>
);
};
export { TypingIndicator };
-39
View File
@@ -1,39 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { getApi } from "./global";
class NavigateModelType {
handlers: Map<string, () => void> = new Map(); // id -> handler
urls: string[] = [];
constructor() {
getApi().onNavigate(this.handleNavigate.bind(this));
getApi().onIframeNavigate(this.handleIframeNavigate.bind(this));
}
handleContextMenuClick(e: any, id: string): void {
let handler = this.handlers.get(id);
if (handler) {
handler();
}
}
handleNavigate(url: string): void {
console.log("Navigate to", url);
this.urls.push(url);
}
handleIframeNavigate(url: string): void {
console.log("Iframe navigate to", url);
this.urls.push(url);
}
getUrls(): string[] {
return this.urls;
}
}
const NavigateModel = new NavigateModelType();
export { NavigateModel, NavigateModelType };
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { atom, useAtom } from "jotai";
import { v4 as uuidv4 } from "uuid";
interface ChatMessageType {
id: string;
user: string;
text: string;
isAssistant: boolean;
error?: string;
}
const defaultMessage: ChatMessageType = {
id: uuidv4(),
user: "assistant",
text: `<p>Hello, how may I help you with this command?<br>
(Cmd-Shift-Space: open/close, Ctrl+L: clear chat buffer, Up/Down: select code blocks, Enter: to copy a selected code block to the command input)</p>`,
isAssistant: true,
};
const messagesAtom = atom<ChatMessageType[]>([defaultMessage]);
const addMessageAtom = atom(null, (get, set, message: ChatMessageType) => {
const messages = get(messagesAtom);
set(messagesAtom, [...messages, message]);
});
const updateLastMessageAtom = atom(null, (get, set, text: string) => {
const messages = get(messagesAtom);
const lastMessage = messages[messages.length - 1];
if (lastMessage.isAssistant && !lastMessage.error) {
const updatedMessage = { ...lastMessage, text: lastMessage.text + text };
set(messagesAtom, [...messages.slice(0, -1), updatedMessage]);
}
});
const simulateAssistantResponseAtom = atom(null, (get, set, userMessage: ChatMessageType) => {
const responseText = `Here is an example of a simple bash script:
\`\`\`bash
#!/bin/bash
# This is a comment
echo "Hello, World!"
\`\`\`
You can run this script by saving it to a file, for example, \`hello.sh\`, and then running \`chmod +x hello.sh\` to make it executable. Finally, run it with \`./hello.sh\`.`;
const typingMessage: ChatMessageType = {
id: uuidv4(),
user: "assistant",
text: "",
isAssistant: true,
};
// Add a typing indicator
set(addMessageAtom, typingMessage);
setTimeout(() => {
const parts = responseText.split(" ");
let currentPart = 0;
const intervalId = setInterval(() => {
if (currentPart < parts.length) {
const part = parts[currentPart] + " ";
set(updateLastMessageAtom, part);
currentPart++;
} else {
clearInterval(intervalId);
}
}, 100);
}, 1500);
});
const useWaveAi = () => {
const [messages] = useAtom(messagesAtom);
const [, addMessage] = useAtom(addMessageAtom);
const [, simulateResponse] = useAtom(simulateAssistantResponseAtom);
const sendMessage = (text: string, user: string = "user") => {
const newMessage: ChatMessageType = {
id: uuidv4(),
user,
text,
isAssistant: false,
};
addMessage(newMessage);
simulateResponse(newMessage);
};
return {
messages,
sendMessage,
};
};
export { useWaveAi };
export type { ChatMessageType };
+25
View File
@@ -2,6 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
:root {
/* base fonts */
--markdown-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji";
--main-text-color: #f7f7f7;
--title-font-size: 18px;
--secondary-text-color: rgb(195, 200, 194);
@@ -31,8 +35,29 @@
--zindex-termstickers: 20;
/* scrollbar colors */
--app-text-primary-color: rgb(255, 255, 255);
--scrollbar-background-color: var(--app-bg-color);
--scrollbar-thumb-color: rgba(255, 255, 255, 0.3);
--scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5);
--scrollbar-thumb-active-color: rgba(255, 255, 255, 0.6);
/* global colors */
--app-text-color: rgb(211, 215, 207);
--app-border-color: rgb(51, 51, 51);
--app-panel-bg-color-dev: rgb(21, 23, 48);
--app-panel-bg-color: rgba(21, 23, 21, 1);
--app-accent-color: rgb(88, 193, 66);
/* global status colors */
--app-success-color: rgb(78, 154, 6);
/* form element colors */
--form-element-primary-color: var(--app-accent-color);
/* markdown colors */
--markdown-bg-color: rgb(35, 35, 35);
--markdown-outline-color: var(--form-element-primary-color);
/* cmdinput colors */
--cmdinput-text-error-color: var(--term-red);
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.waveai {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
width: 100%;
> .scrollable {
flex-flow: column nowrap;
flex: 1;
margin-bottom: 0;
overflow-y: auto;
.chat-window {
display: flex;
// margin-bottom: 5px;
flex-direction: column;
height: 100%;
// This is the filler that will push the chat messages to the bottom until the chat window is full
.filler {
flex: 1 1 auto;
}
> * {
cursor: default;
user-select: none;
}
}
.chat-msg {
padding: 10px;
display: flex;
align-items: flex-start;
.chat-msg-header {
display: flex;
margin-bottom: 2px;
i {
margin-top: 2px;
margin-right: 0.5em;
}
}
}
.chat-msg-assistant {
color: var(--app-text-color);
pre {
white-space: pre-wrap;
word-break: break-word;
max-width: 100%;
overflow-x: auto;
margin-left: 0;
}
}
.chat-msg-error {
color: var(--cmdinput-text-error);
font-family: var(--markdown-font);
font-size: 14px;
}
.typing-indicator {
margin-top: 4px;
}
}
.waveai-input-wrapper {
padding: 16px 15px 7px;
flex: 0 0 auto;
border-top: 1px solid var(--app-border-color);
.waveai-input {
color: var(--app-text-primary-color);
background-color: var(--cmdinput-textarea-bg);
resize: none;
width: 100%;
border: transparent;
outline: none;
overflow: auto;
overflow-wrap: anywhere;
font-family: var(--termfontfamily);
font-weight: normal;
line-height: var(--termlineheight);
height: 21px;
}
}
}
+387
View File
@@ -0,0 +1,387 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Markdown } from "@/app/element/markdown";
import { TypingIndicator } from "@/app/element/typingindicator";
import { getApi } from "@/app/store/global";
import { ChatMessageType, useWaveAi } from "@/app/store/waveai";
import type { OverlayScrollbars } from "overlayscrollbars";
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import tinycolor from "tinycolor2";
import "./waveai.less";
const outline = "2px solid var(--markdown-outline-color)";
interface ChatItemProps {
chatItem: ChatMessageType;
itemCount: number;
}
const ChatItem = ({ chatItem, itemCount }: ChatItemProps) => {
const { isAssistant, text, error } = chatItem;
const senderClassName = isAssistant ? "chat-msg-assistant" : "chat-msg-user";
const msgClassName = `chat-msg ${senderClassName}`;
const cssVar = getApi().isDev ? "--app-panel-bg-color-dev" : "--app-panel-bg-color";
const panelBgColor = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
const color = tinycolor(panelBgColor);
const newColor = color.isValid() ? tinycolor(panelBgColor).darken(6).toString() : "none";
const backgroundColor = itemCount % 2 === 0 ? "none" : newColor;
const renderError = (err: string): React.JSX.Element => <div className="chat-msg-error">{err}</div>;
const renderContent = (): React.JSX.Element => {
if (isAssistant) {
if (error) {
return renderError(error);
}
return text ? (
<>
<div className="chat-msg-header">
<i className="fa-sharp fa-solid fa-sparkles"></i>
</div>
<Markdown text={text} />
</>
) : (
<>
<div className="chat-msg-header">
<i className="fa-sharp fa-solid fa-sparkles"></i>
</div>
<TypingIndicator className="typing-indicator" />
</>
);
}
return (
<>
<div className="chat-msg-header">
<i className="fa-sharp fa-solid fa-user"></i>
</div>
<Markdown className="msg-text" text={text} />
</>
);
};
return (
<div className={msgClassName} style={{ backgroundColor }}>
{renderContent()}
</div>
);
};
interface ChatWindowProps {
chatWindowRef: React.RefObject<HTMLDivElement>;
messages: ChatMessageType[];
}
const ChatWindow = forwardRef<OverlayScrollbarsComponentRef, ChatWindowProps>(({ chatWindowRef, messages }, ref) => {
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef);
useEffect(() => {
if (osRef.current && osRef.current.osInstance()) {
const { viewport } = osRef.current.osInstance().elements();
viewport.scrollTo({
behavior: "auto",
top: chatWindowRef.current?.scrollHeight || 0,
});
}
}, [messages]);
useEffect(() => {
return () => {
if (osRef.current && osRef.current.osInstance()) {
osRef.current.osInstance().destroy();
}
};
}, []);
const handleScrollbarInitialized = (instance: OverlayScrollbars) => {
const { viewport } = instance.elements();
viewport.scrollTo({
behavior: "auto",
top: chatWindowRef.current?.scrollHeight || 0,
});
};
return (
<OverlayScrollbarsComponent
ref={osRef}
className="scrollable"
options={{ scrollbars: { autoHide: "leave" } }}
events={{ initialized: handleScrollbarInitialized }}
>
<div ref={chatWindowRef} className="chat-window">
<div className="filler"></div>
{messages.map((chitem, idx) => (
<ChatItem key={idx} chatItem={chitem} itemCount={idx + 1} />
))}
</div>
</OverlayScrollbarsComponent>
);
});
interface ChatInputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onMouseDown: (e: React.MouseEvent<HTMLTextAreaElement>) => void;
termFontSize: number;
}
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
({ value, onChange, onKeyDown, onMouseDown, termFontSize }, ref) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement);
useEffect(() => {
if (textAreaRef.current) {
textAreaRef.current.focus();
}
}, []);
const adjustTextAreaHeight = () => {
if (textAreaRef.current == null) {
return;
}
// Adjust the height of the textarea to fit the text
const textAreaMaxLines = 100;
const textAreaLineHeight = termFontSize * 1.5;
const textAreaMinHeight = textAreaLineHeight;
const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines;
textAreaRef.current.style.height = "1px";
const scrollHeight = textAreaRef.current.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight);
textAreaRef.current.style.height = newHeight + "px";
};
useEffect(() => {
adjustTextAreaHeight();
}, [value]);
return (
<textarea
ref={textAreaRef}
autoComplete="off"
autoCorrect="off"
className="waveai-input"
onMouseDown={onMouseDown} // When the user clicks on the textarea
onChange={onChange}
onKeyDown={onKeyDown}
style={{ fontSize: termFontSize }}
placeholder="Send a Message..."
value={value}
></textarea>
);
}
);
interface WaveAiProps {
parentRef: React.MutableRefObject<HTMLDivElement>;
}
const WaveAi = React.memo(({ parentRef }: WaveAiProps) => {
const { messages, sendMessage } = useWaveAi();
const waveaiRef = useRef<HTMLDivElement>(null);
const chatWindowRef = useRef<HTMLDivElement>(null);
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState("");
const [waveAiHeight, setWaveAiHeight] = useState(0);
const [selectedBlockIdx, setSelectedBlockIdx] = useState<number | null>(null);
const termFontSize: number = 14;
useEffect(() => {
const parentElement = parentRef.current;
setWaveAiHeight(parentElement?.getBoundingClientRect().height);
// Use ResizeObserver to observe changes in the height of parentRef
const handleResize = () => {
const webviewHeight = parentElement?.getBoundingClientRect().height;
setWaveAiHeight(webviewHeight);
};
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
if (entry.target === parentElement) {
handleResize();
}
}
});
resizeObserver.observe(parentElement);
return () => {
resizeObserver.disconnect();
};
}, []);
const submit = (messageStr: string) => {
sendMessage(messageStr);
};
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
};
const updatePreTagOutline = (clickedPre?: HTMLElement | null) => {
const pres = chatWindowRef.current?.querySelectorAll("pre");
if (!pres) return;
pres.forEach((preElement, idx) => {
if (preElement === clickedPre) {
setSelectedBlockIdx(idx);
} else {
preElement.style.outline = "none";
}
});
if (clickedPre) {
clickedPre.style.outline = outline;
}
};
useEffect(() => {
if (selectedBlockIdx !== null) {
const pres = chatWindowRef.current?.querySelectorAll("pre");
if (pres && pres[selectedBlockIdx]) {
pres[selectedBlockIdx].style.outline = outline;
}
}
}, [selectedBlockIdx]);
const handleTextAreaMouseDown = () => {
updatePreTagOutline();
setSelectedBlockIdx(null);
};
const handleEnterKeyPressed = () => {
submit(value);
setValue("");
setSelectedBlockIdx(null);
};
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
inputRef.current?.focus();
const target = event.target as HTMLElement;
if (
target.closest(".copy-button") ||
target.closest(".fa-square-terminal") ||
target.closest(".waveai-input")
) {
return;
}
const pre = target.closest("pre");
updatePreTagOutline(pre);
};
const updateScrollTop = () => {
const pres = chatWindowRef.current?.querySelectorAll("pre");
if (!pres || selectedBlockIdx === null) return;
const block = pres[selectedBlockIdx];
if (!block || !osRef.current?.osInstance()) return;
const { viewport, scrollOffsetElement } = osRef.current?.osInstance().elements();
const chatWindowTop = scrollOffsetElement.scrollTop;
const chatWindowHeight = chatWindowRef.current.clientHeight;
const chatWindowBottom = chatWindowTop + chatWindowHeight;
const elemTop = block.offsetTop;
const elemBottom = elemTop + block.offsetHeight;
const elementIsInView = elemBottom <= chatWindowBottom && elemTop >= chatWindowTop;
if (!elementIsInView) {
let scrollPosition;
if (elemBottom > chatWindowBottom) {
scrollPosition = elemTop - chatWindowHeight + block.offsetHeight + 15;
} else if (elemTop < chatWindowTop) {
scrollPosition = elemTop - 15;
}
viewport.scrollTo({
behavior: "auto",
top: scrollPosition,
});
}
};
const shouldSelectCodeBlock = (key: "ArrowUp" | "ArrowDown") => {
const textarea = inputRef.current;
const cursorPosition = textarea?.selectionStart || 0;
const textBeforeCursor = textarea?.value.slice(0, cursorPosition) || "";
return (
(textBeforeCursor.indexOf("\n") === -1 && cursorPosition === 0 && key === "ArrowUp") ||
selectedBlockIdx !== null
);
};
const handleArrowUpPressed = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (shouldSelectCodeBlock("ArrowUp")) {
e.preventDefault();
const pres = chatWindowRef.current?.querySelectorAll("pre");
let blockIndex = selectedBlockIdx;
if (!pres) return;
if (blockIndex === null) {
setSelectedBlockIdx(pres.length - 1);
} else if (blockIndex > 0) {
blockIndex--;
setSelectedBlockIdx(blockIndex);
}
updateScrollTop();
}
};
const handleArrowDownPressed = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (shouldSelectCodeBlock("ArrowDown")) {
e.preventDefault();
const pres = chatWindowRef.current?.querySelectorAll("pre");
let blockIndex = selectedBlockIdx;
if (!pres) return;
if (blockIndex === null) return;
if (blockIndex < pres.length - 1 && blockIndex >= 0) {
setSelectedBlockIdx(++blockIndex);
updateScrollTop();
} else {
inputRef.current.focus();
setSelectedBlockIdx(null);
}
updateScrollTop();
}
};
const handleTextAreaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleEnterKeyPressed();
} else if (e.key === "ArrowUp") {
handleArrowUpPressed(e);
} else if (e.key === "ArrowDown") {
handleArrowDownPressed(e);
}
};
return (
<div ref={waveaiRef} className="waveai" onClick={handleContainerClick} style={{ height: waveAiHeight - 27 }}>
<ChatWindow ref={osRef} chatWindowRef={chatWindowRef} messages={messages} />
<div className="waveai-input-wrapper">
<ChatInput
ref={inputRef}
value={value}
onChange={handleTextAreaChange}
onKeyDown={handleTextAreaKeyDown}
onMouseDown={handleTextAreaMouseDown}
termFontSize={termFontSize}
/>
</div>
</div>
);
});
export { WaveAi };
+3 -3
View File
@@ -4,7 +4,7 @@
import { Button } from "@/app/element/button";
import { getApi } from "@/app/store/global";
import { WebviewTag } from "electron";
import React, { useEffect, useRef, useState } from "react";
import React, { memo, useEffect, useRef, useState } from "react";
import "./webview.less";
@@ -13,7 +13,7 @@ interface WebViewProps {
initialUrl: string;
}
const WebView = ({ parentRef, initialUrl }: WebViewProps) => {
const WebView = memo(({ parentRef, initialUrl }: WebViewProps) => {
const [url, setUrl] = useState(initialUrl);
const [inputUrl, setInputUrl] = useState(initialUrl); // Separate state for the input field
const [webViewHeight, setWebViewHeight] = useState(0);
@@ -313,6 +313,6 @@ const WebView = ({ parentRef, initialUrl }: WebViewProps) => {
></webview>
</div>
);
};
});
export { WebView };