From e196aefcd3381801d291aa576fb9c6f5d6434f06 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 14 Nov 2024 14:46:35 +0700 Subject: [PATCH] feat: new UI code block and Enable copying of code blocks or plain text mid-stream (#4010) * feat: improvement ui codeblock * chore: update ui code block * chore: finalize ui code block and latex * chore: fix jest testing and cleanup unused deps --- web/containers/ListContainer/index.tsx | 5 + web/jest.config.js | 5 + web/mock/empty-mock.tsx | 2 + web/package.json | 26 +- .../ChatInput/RichTextEditor.tsx | 5 +- .../SimpleTextMessage/index.tsx | 278 ++++++++++++------ web/styles/components/code-block.scss | 78 ++++- web/styles/components/message.scss | 8 - web/utils/codeLanguageExtension.ts | 34 +++ 9 files changed, 326 insertions(+), 115 deletions(-) create mode 100644 web/mock/empty-mock.tsx create mode 100644 web/utils/codeLanguageExtension.ts diff --git a/web/containers/ListContainer/index.tsx b/web/containers/ListContainer/index.tsx index bd650e315..2b720fb43 100644 --- a/web/containers/ListContainer/index.tsx +++ b/web/containers/ListContainer/index.tsx @@ -25,6 +25,11 @@ const ListContainer = ({ children }: Props) => { isUserManuallyScrollingUp.current = false } } + + if (isUserManuallyScrollingUp.current === true) { + event.preventDefault() + event.stopPropagation() + } prevScrollTop.current = currentScrollTop }, []) diff --git a/web/jest.config.js b/web/jest.config.js index 12ed39b20..f78007532 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -13,6 +13,11 @@ const config = { moduleNameMapper: { // ... '^@/(.*)$': '/$1', + 'react-markdown': '/mock/empty-mock.tsx', + 'rehype-highlight': '/mock/empty-mock.tsx', + 'rehype-katex': '/mock/empty-mock.tsx', + 'rehype-raw': '/mock/empty-mock.tsx', + 'remark-math': '/mock/empty-mock.tsx', }, // Add more setup options before each test is run // setupFilesAfterEnv: ['/jest.setup.ts'], diff --git a/web/mock/empty-mock.tsx b/web/mock/empty-mock.tsx new file mode 100644 index 000000000..dd7f322f2 --- /dev/null +++ b/web/mock/empty-mock.tsx @@ -0,0 +1,2 @@ +const EmptyMock = {} +export default EmptyMock diff --git a/web/package.json b/web/package.json index d3ee82a33..7665d354c 100644 --- a/web/package.json +++ b/web/package.json @@ -14,13 +14,10 @@ "test": "jest" }, "dependencies": { - "@heroicons/react": "^2.0.18", - "@hookform/resolvers": "^3.3.2", "@janhq/core": "link:./core", "@janhq/joi": "link:./joi", "autoprefixer": "10.4.16", "class-variance-authority": "^0.7.0", - "csstype": "^3.0.10", "framer-motion": "^10.16.4", "highlight.js": "^11.9.0", "jotai": "^2.6.0", @@ -28,8 +25,6 @@ "lodash": "^4.17.21", "lucide-react": "^0.291.0", "marked": "^9.1.2", - "marked-highlight": "^2.0.6", - "marked-katex-extension": "^5.0.2", "next": "14.2.3", "next-themes": "^0.2.1", "postcss": "8.4.31", @@ -39,22 +34,25 @@ "react-circular-progressbar": "^2.1.0", "react-dom": "18.2.0", "react-dropzone": "14.2.3", - "react-hook-form": "^7.47.0", "react-hot-toast": "^2.4.1", "react-icons": "^4.12.0", - "react-scroll-to-bottom": "^4.2.0", + "react-markdown": "^9.0.1", "react-toastify": "^9.1.3", + "rehype-highlight": "^7.0.1", + "rehype-highlight-code-lines": "^1.0.4", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-math": "^6.0.0", "sass": "^1.69.4", + "slate": "latest", + "slate-dom": "0.111.0", + "slate-history": "0.110.3", + "slate-react": "0.110.3", "tailwind-merge": "^2.0.0", "tailwindcss": "3.3.5", "ulidx": "^2.3.0", "use-debounce": "^10.0.0", - "uuid": "^9.0.1", - "zod": "^3.22.4", - "slate": "latest", - "slate-dom": "0.111.0", - "slate-react": "0.110.3", - "slate-history": "0.110.3" + "uuid": "^9.0.1" }, "devDependencies": { "@next/eslint-plugin-next": "^14.0.1", @@ -65,7 +63,7 @@ "@types/react": "18.2.34", "@types/react-dom": "18.2.14", "@types/react-icons": "^3.0.0", - "@types/react-scroll-to-bottom": "^4.2.4", + "@types/react-syntax-highlighter": "^15.5.13", "@types/uuid": "^9.0.6", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", diff --git a/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx b/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx index 70fecb8a9..0d477d78d 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { MessageStatus } from '@janhq/core' import hljs from 'highlight.js' - import { useAtom, useAtomValue } from 'jotai' + import { BaseEditor, createEditor, Editor, Transforms } from 'slate' import { withHistory } from 'slate-history' // Import withHistory import { @@ -270,7 +270,8 @@ const RichTextEditor = ({ textareaRef.current.style.height = activeSettingInputBox ? '100px' : '40px' - textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' + textareaRef.current.style.height = + textareaRef.current.scrollHeight + 2 + 'px' textareaRef.current?.scrollTo({ top: textareaRef.current.scrollHeight, behavior: 'instant', diff --git a/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/index.tsx b/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/index.tsx index 12bcf7a4d..126512115 100644 --- a/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/index.tsx @@ -1,4 +1,9 @@ -import React, { useEffect, useRef, useState } from 'react' +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { useEffect, useState } from 'react' + +import Markdown from 'react-markdown' import { ChatCompletionRole, @@ -8,14 +13,15 @@ import { } from '@janhq/core' import { Tooltip } from '@janhq/joi' -import hljs from 'highlight.js' import { useAtomValue } from 'jotai' import { FolderOpenIcon } from 'lucide-react' -import { Marked, Renderer } from 'marked' -import { markedHighlight } from 'marked-highlight' -import markedKatex from 'marked-katex-extension' - +import rehypeHighlight from 'rehype-highlight' +import rehypeHighlightCodeLines from 'rehype-highlight-code-lines' +import rehypeKatex from 'rehype-katex' +import rehypeRaw from 'rehype-raw' +import remarkMath from 'remark-math' +import 'katex/dist/katex.min.css' import { twMerge } from 'tailwind-merge' import LogoMark from '@/containers/Brand/Logo/Mark' @@ -23,6 +29,7 @@ import LogoMark from '@/containers/Brand/Logo/Mark' import { useClipboard } from '@/hooks/useClipboard' import { usePath } from '@/hooks/usePath' +import { getLanguageFromExtension } from '@/utils/codeLanguageExtension' import { toGibibytes } from '@/utils/converter' import { displayDate } from '@/utils/datetime' @@ -53,88 +60,181 @@ const SimpleTextMessage: React.FC = (props) => { const clipboard = useClipboard({ timeout: 1000 }) - function escapeHtml(html: string): string { - return html - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') + function extractCodeLines(node: { children: { children: any[] }[] }) { + const codeLines: any[] = [] + + // Helper function to extract text recursively from children + function getTextFromNode(node: { + type: string + value: any + children: any[] + }): string { + if (node.type === 'text') { + return node.value + } else if (node.children) { + return node.children.map(getTextFromNode).join('') + } + return '' + } + + // Traverse each line in the block + node.children[0].children.forEach( + (lineNode: { + type: string + tagName: string + value: any + children: any[] + }) => { + if (lineNode.type === 'element' && lineNode.tagName === 'span') { + const lineContent = getTextFromNode(lineNode) + codeLines.push(lineContent) + } + } + ) + + // Join the lines with newline characters for proper formatting + return codeLines.join('\n') + } + function wrapCodeBlocksWithoutVisit() { + return (tree: { children: any[] }) => { + tree.children = tree.children.map((node) => { + if (node.tagName === 'pre' && node.children[0]?.tagName === 'code') { + const language = node.children[0].properties.className?.[1]?.replace( + 'language-', + '' + ) + + if (!language) return node + + return { + type: 'element', + tagName: 'div', + properties: { + className: ['code-block-wrapper'], + }, + children: [ + { + type: 'element', + tagName: 'div', + properties: { + className: [ + 'code-block', + 'group/item', + 'relative', + 'my-4', + 'overflow-auto', + ], + }, + children: [ + { + type: 'element', + tagName: 'div', + properties: { + className: + 'code-header bg-[hsla(var(--app-code-block))] flex justify-between items-center py-2 px-3 border-b border-[hsla(var(--app-border))] rounded-t-lg', + }, + children: [ + { + type: 'element', + tagName: 'span', + properties: { + className: 'text-xs font-medium text-gray-300', + }, + children: [ + { + type: 'text', + value: language + ? `${getLanguageFromExtension(language)}` + : 'No file name', + }, + ], + }, + { + type: 'element', + tagName: 'button', + properties: { + className: + 'copy-button ml-auto flex items-center gap-1 text-xs font-medium text-gray-400 hover:text-gray-600 focus:outline-none', + onClick: (event: Event) => { + clipboard.copy(extractCodeLines(node)) + + const button = event.currentTarget as HTMLElement + button.innerHTML = ` + + Copied + ` + + setTimeout(() => { + button.innerHTML = ` + + Copy + ` + }, 2000) + }, + }, + children: [ + { + type: 'element', + tagName: 'svg', + properties: { + xmlns: 'http://www.w3.org/2000/svg', + width: '16', + height: '16', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: '2', + strokeLinecap: 'round', + strokeLinejoin: 'round', + className: + 'lucide lucide-copy pointer-events-none text-gray-400', + }, + children: [ + { + type: 'element', + tagName: 'rect', + properties: { + width: '14', + height: '14', + x: '8', + y: '8', + rx: '2', + ry: '2', + }, + children: [], + }, + { + type: 'element', + tagName: 'path', + properties: { + d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2', + }, + children: [], + }, + ], + }, + { type: 'text', value: 'Copy' }, + ], + }, + ], + }, + node, + ], + }, + ], + } + } + return node + }) + } } - const marked: Marked = new Marked( - markedHighlight({ - langPrefix: 'hljs', - highlight(code, lang) { - if (lang === undefined || lang === '') { - return hljs.highlight(code, { language: 'plaintext' }).value - } - try { - return hljs.highlight(code, { language: lang }).value - } catch (err) { - return hljs.highlight(code, { language: 'javascript' }).value - } - }, - }), - { - renderer: { - html: (html: string) => { - return escapeHtml(html) // Escape any HTML - }, - link: (href, title, text) => { - return Renderer.prototype.link - ?.apply(this, [href, title, text]) - .replace(' - -
-              ${code}
-            
- - ` - }, - }, - } - ) - - marked.use(markedKatex({ throwOnError: false })) - const { onViewFile, onViewFileContainer } = usePath() - const parsedText = marked.parse(text) const [tokenCount, setTokenCount] = useState(0) const [lastTimestamp, setLastTimestamp] = useState() const [tokenSpeed, setTokenSpeed] = useState(0) const messages = useAtomValue(getCurrentChatMessagesAtom) - const codeBlockCopyEvent = useRef((e: Event) => { - const target: HTMLElement = e.target as HTMLElement - if (typeof target.className !== 'string') return null - - const isCopyActionClassName = target?.className.includes('copy-action') - - if (isCopyActionClassName) { - const content = target?.parentNode?.querySelector('code')?.innerText ?? '' - clipboard.copy(content) - } - }) - - useEffect(() => { - document.addEventListener('click', codeBlockCopyEvent.current) - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - document.removeEventListener('click', codeBlockCopyEvent.current) - } - }, []) - useEffect(() => { if (props.status !== MessageStatus.Pending) { return @@ -285,8 +385,22 @@ const SimpleTextMessage: React.FC = (props) => { className={twMerge( 'message max-width-[100%] flex flex-col gap-y-2 overflow-auto break-all leading-relaxed ' )} - dangerouslySetInnerHTML={{ __html: parsedText }} - /> + dir="ltr" + > + + {text} + + )} diff --git a/web/styles/components/code-block.scss b/web/styles/components/code-block.scss index fed56884b..e739e4e24 100644 --- a/web/styles/components/code-block.scss +++ b/web/styles/components/code-block.scss @@ -55,22 +55,25 @@ .hljs { overflow: auto; display: block; - width: auto; - background: hsla(var(--app-code-block)); - color: #f8f8f2; padding: 16px; font-size: 14px; - word-wrap: normal; + border-bottom-left-radius: 0.4rem; + border-bottom-right-radius: 0.4rem; + color: #f8f8f2; +} + +pre { + background: hsla(var(--app-code-block)); + overflow: auto; + padding: 8px 16px; border-radius: 0.4rem; - margin-top: 1rem; - margin-bottom: 1rem; - white-space: normal; } pre > code { - display: block; text-indent: 0; white-space: pre; - max-width: 10vw; + font-size: 14px; + overflow: auto; + color: #f8f8f2; } .hljs-emphasis { @@ -81,6 +84,14 @@ pre > code { font-weight: bold; } +.code-block { + pre { + padding: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + } +} + @media screen and (-ms-high-contrast: active) { .hljs-addition, .hljs-attribute, @@ -105,3 +116,52 @@ pre > code { font-weight: bold; } } + +.code-block-wrapper { + white-space: nowrap; +} + +.code-line { + // padding-left: 12px; + padding-right: 12px; + margin-left: -12px; + margin-right: -12px; + border-left: 4px solid transparent; +} + +div.code-line:empty { + height: 21.5938px; +} + +span.code-line { + // min-width: 100%; + white-space: pre; + display: inline-block; + max-width: 10vw; +} + +.code-line.inserted { + background-color: var(--color-inserted-line); +} + +.code-line.deleted { + background-color: var(--color-deleted-line); +} + +.highlighted-code-line { + background-color: var(--color-highlighted-line); + border-left: 4px solid var(--color-highlighted-line-indicator); +} + +.numbered-code-line::before { + content: attr(data-line-number); + + margin-left: -8px; + margin-right: 16px; + width: 1rem; + font-size: 12px; + color: var(--color-text-weak); + text-align: right; + + display: inline-block; +} diff --git a/web/styles/components/message.scss b/web/styles/components/message.scss index 0bc0ab6eb..9736e96f8 100644 --- a/web/styles/components/message.scss +++ b/web/styles/components/message.scss @@ -27,11 +27,3 @@ @apply inline-flex flex-col border-s-4 border-[hsla(var(--primary-bg))] bg-[hsla(var(--primary-bg-soft))] px-4 py-2; } } - -.code-block { - white-space: normal; -} - -pre { - max-width: 95vw; -} diff --git a/web/utils/codeLanguageExtension.ts b/web/utils/codeLanguageExtension.ts new file mode 100644 index 000000000..cdabac015 --- /dev/null +++ b/web/utils/codeLanguageExtension.ts @@ -0,0 +1,34 @@ +// Utility function using switch-case for extension to language mapping +export function getLanguageFromExtension(extension: string): string { + switch (extension.toLowerCase()) { + case 'ts': + case 'tsx': + return 'typescript' + case 'js': + case 'jsx': + return 'javascript' + case 'py': + return 'python' + case 'java': + return 'java' + case 'rb': + return 'ruby' + case 'cs': + return 'csharp' + case 'md': + return 'markdown' + case 'yaml': + case 'yml': + return 'yaml' + case 'sh': + return 'bash' + case 'rs': + return 'rust' + case 'kt': + return 'kotlin' + case 'swift': + return 'swift' + default: + return extension + } +}