Skip to content

Commit 13208ed

Browse files
authored
0.1.5 update (#33)
* Fix context menu overflow * add copy button for code blocks * Fix pending generation scroll * update version and change log
1 parent ac96699 commit 13208ed

7 files changed

Lines changed: 166 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
# 0.1.5
2+
3+
## What's new
4+
5+
* Add copy button for code blocks
6+
7+
## Fixes
8+
9+
* Fix context menu out of view issue
10+
* Fix cannot scroll when waiting for first generation issue
11+
12+
## Changes
13+
114
# 0.1.4
215

316
## What's new

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tinywebui-webapp",
3-
"version": "0.1.0",
3+
"version": "0.1.5",
44
"private": true,
55
"type": "module",
66
"scripts": {

src/app/auth/sign-up/page.tsx

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,7 @@ import { Input } from "@/components/ui/input";
77
import { Modal } from "@/components/ui/modal";
88
import { Eye, EyeOff, Mail, Lock, CheckCircle, ArrowRight } from "lucide-react";
99
import { getRegistrationString } from "@/sdk/registration"
10-
11-
async function copyToClipboard(text: string) {
12-
if (navigator.clipboard) {
13-
await navigator.clipboard.writeText(text);
14-
} else {
15-
const textarea = document.createElement("textarea");
16-
textarea.value = text;
17-
textarea.setAttribute("readonly", "");
18-
textarea.style.position = "fixed";
19-
textarea.style.left = "-9999px";
20-
textarea.style.top = "0";
21-
22-
document.body.appendChild(textarea);
23-
24-
const selection = document.getSelection();
25-
const originalRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
26-
27-
textarea.select();
28-
textarea.setSelectionRange(0, textarea.value.length);
29-
30-
const successful = document.execCommand("copy");
31-
32-
document.body.removeChild(textarea);
33-
34-
if (selection) {
35-
selection.removeAllRanges();
36-
if (originalRange) {
37-
selection.addRange(originalRange);
38-
}
39-
}
40-
41-
if (!successful) {
42-
throw new Error("Copy command failed");
43-
}
44-
}
45-
}
10+
import { copyToClipboard } from "@/lib/utils";
4611

4712
export default function SignUp() {
4813
const router = useRouter();

src/app/chat/chat.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function Chat({
4343
const initialUserMessageHandled = useRef(false);
4444
const initializationCalled = useRef(false);
4545
const generatingCounter = useRef(0);
46+
const initialGenerationScrollDone = useRef(false);
4647
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
4748
const bottomRef = useRef<HTMLDivElement | null>(null);
4849

@@ -52,21 +53,26 @@ export function Chat({
5253
return;
5354
}
5455
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
55-
setUserDetachedFromBottom(distanceFromBottom > 30);
56+
setUserDetachedFromBottom(distanceFromBottom > 20);
5657
}, []);
5758

5859
useEffect(() => {
5960
if (!generating || !bottomRef.current) {
6061
return;
6162
}
62-
if (pendingAssistantMessage?.content.length === 1 && pendingAssistantMessage.content[0].data === '') {
63-
/** The user just input text. Jump to the bottom */
63+
const isInitialAssistantMessage = pendingAssistantMessage?.content.length === 1 && pendingAssistantMessage.content[0].data === '';
64+
if (!initialGenerationScrollDone.current && pendingAssistantMessage) {
6465
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
65-
} else if (!userDetachedFromBottom) {
66-
/** Generating and the user does not leave the bottom */
66+
initialGenerationScrollDone.current = true;
67+
return;
68+
}
69+
if (userDetachedFromBottom) {
70+
return;
71+
}
72+
if (isInitialAssistantMessage || pendingAssistantMessage) {
6773
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
6874
}
69-
}, [pendingUserMessage, pendingAssistantMessage, generating, userDetachedFromBottom]);
75+
}, [pendingAssistantMessage, generating, userDetachedFromBottom]);
7076

7177
const generateChatTitleAsync = useCallback(async (chatId: string, message: ServerTypes.Message) => {
7278
const modelId = titleGenerationModelId ?? selectedModelId;
@@ -139,6 +145,7 @@ export function Chat({
139145
]
140146
};
141147
const userMessageTimestamp = Date.now();
148+
initialGenerationScrollDone.current = false;
142149
setPendingAssistantMessage(assistantMessage);
143150
/**
144151
* This step should start even on mismatch to ensure a concise chat history

src/app/chat/side.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useRef, useCallback, useState, MouseEvent } from "react";
3+
import { useEffect, useRef, useCallback, useState, MouseEvent, useLayoutEffect } from "react";
44
import { PanelLeftClose } from "lucide-react";
55
import { Logo } from "@/components/custom/logo";
66
import { Button } from "@/components/ui/button";
@@ -71,6 +71,7 @@ export function Side({
7171
const [renameSubmitting, setRenameSubmitting] = useState(false);
7272
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
7373
const [actionError, setActionError] = useState<string | undefined>(undefined);
74+
const contextMenuRef = useRef<HTMLDivElement | null>(null);
7475

7576
const triggerUpdate = useCallback(async () => {
7677
if (isLoading) return; // avoid parallel loads
@@ -203,6 +204,21 @@ export function Side({
203204
setActionError(undefined);
204205
}, []);
205206

207+
/** Ensure the context menu stays within the viewport bounds */
208+
useLayoutEffect(() => {
209+
if (!contextMenu || !contextMenuRef.current) return;
210+
const rect = contextMenuRef.current.getBoundingClientRect();
211+
const padding = 8; // small offset to avoid sticking to the edges
212+
const maxX = Math.max(padding, window.innerWidth - rect.width - padding);
213+
const maxY = Math.max(padding, window.innerHeight - rect.height - padding);
214+
const clampedX = Math.max(padding, Math.min(contextMenu.x, maxX));
215+
const clampedY = Math.max(padding, Math.min(contextMenu.y, maxY));
216+
217+
if (clampedX !== contextMenu.x || clampedY !== contextMenu.y) {
218+
setContextMenu(prev => (prev ? { ...prev, x: clampedX, y: clampedY } : prev));
219+
}
220+
}, [contextMenu]);
221+
206222
const confirmRenameAsync = useCallback(async () => {
207223
if (!renameDialog.chatId) {
208224
return;
@@ -324,6 +340,7 @@ export function Side({
324340
>
325341
<div
326342
className="absolute min-w-[170px] overflow-hidden rounded-md border border-border bg-background text-sm shadow-lg"
343+
ref={contextMenuRef}
327344
style={{ top: contextMenu.y, left: contextMenu.x }}
328345
onClick={event => event.stopPropagation()}
329346
>

src/components/custom/markdown-renderer.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
"use client";
2+
3+
import React, { type DetailedHTMLProps, type HTMLAttributes, useEffect, useMemo, useRef, useState } from "react";
4+
import { Copy, Check } from "lucide-react";
15
import ReactMarkdown from 'react-markdown';
26
import remarkGfm from 'remark-gfm';
37
import remarkBreaks from 'remark-breaks';
48
import remarkMath from 'remark-math';
59
import rehypePrism from 'rehype-prism-plus';
610
import rehypeKatex from 'rehype-katex';
711

12+
import { cn, copyToClipboard } from "@/lib/utils";
13+
814
import './github-markdown.css';
915
import './prism-ghcolors-auto.css';
1016
import "katex/dist/katex.min.css";
@@ -24,6 +30,83 @@ function NormalizeMathTags(input: string): string {
2430
);
2531
}
2632

33+
function extractText(node: React.ReactNode): string {
34+
return React.Children.toArray(node)
35+
.map((child) => {
36+
if (typeof child === "string" || typeof child === "number") {
37+
return String(child);
38+
}
39+
if (React.isValidElement<{ children?: React.ReactNode }>(child) && child.props?.children) {
40+
return extractText(child.props.children);
41+
}
42+
return "";
43+
})
44+
.join("");
45+
}
46+
47+
function CodeBlock({
48+
inline,
49+
className,
50+
children,
51+
...props
52+
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & { inline?: boolean }) {
53+
const [copied, setCopied] = useState(false);
54+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
55+
const isInline = inline || !className || !className.includes("language-");
56+
const rest = props;
57+
const codeText = useMemo(() => extractText(children).replace(/\n$/, ""), [children]);
58+
59+
useEffect(() => {
60+
return () => {
61+
if (timeoutRef.current) {
62+
clearTimeout(timeoutRef.current);
63+
}
64+
};
65+
}, []);
66+
67+
if (isInline) {
68+
return (
69+
<code className={className} {...rest}>
70+
{children}
71+
</code>
72+
);
73+
}
74+
75+
const handleCopy = async () => {
76+
try {
77+
await copyToClipboard(codeText);
78+
setCopied(true);
79+
if (timeoutRef.current) {
80+
clearTimeout(timeoutRef.current);
81+
}
82+
timeoutRef.current = setTimeout(() => setCopied(false), 1200);
83+
} catch (error) {
84+
console.error("Failed to copy code block", error);
85+
}
86+
};
87+
88+
return (
89+
<div className="relative group">
90+
<button
91+
type="button"
92+
aria-label={copied ? "Copied" : "Copy code"}
93+
onClick={handleCopy}
94+
className={cn(
95+
"absolute right-2 top-2 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
96+
copied ? "text-green-600 scale-105 animate-pulse" : "hover:text-foreground hover:-translate-y-0.5",
97+
codeText ? "opacity-100" : "hidden"
98+
)}
99+
>
100+
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
101+
<span className="sr-only">{copied ? "Copied" : "Copy code"}</span>
102+
</button>
103+
<pre className={className}>
104+
<code className={className} {...rest}>{children}</code>
105+
</pre>
106+
</div>
107+
);
108+
}
109+
27110
export default function MarkdownRenderer({ content }: { content: string }) {
28111
return (
29112
<div
@@ -36,6 +119,7 @@ export default function MarkdownRenderer({ content }: { content: string }) {
36119
components={{
37120
ul: (props) => <ul className="list-disc" {...props} />,
38121
ol: (props) => <ol className="list-decimal" {...props} />,
122+
code: CodeBlock,
39123
}}
40124
>
41125
{NormalizeMathTags(content)}

src/lib/utils.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,39 @@ import { twMerge } from "tailwind-merge"
44
export function cn(...inputs: ClassValue[]) {
55
return twMerge(clsx(inputs))
66
}
7+
8+
export async function copyToClipboard(text: string) {
9+
if (navigator.clipboard) {
10+
await navigator.clipboard.writeText(text);
11+
} else {
12+
const textarea = document.createElement("textarea");
13+
textarea.value = text;
14+
textarea.setAttribute("readonly", "");
15+
textarea.style.position = "fixed";
16+
textarea.style.left = "-9999px";
17+
textarea.style.top = "0";
18+
19+
document.body.appendChild(textarea);
20+
21+
const selection = document.getSelection();
22+
const originalRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
23+
24+
textarea.select();
25+
textarea.setSelectionRange(0, textarea.value.length);
26+
27+
const successful = document.execCommand("copy");
28+
29+
document.body.removeChild(textarea);
30+
31+
if (selection) {
32+
selection.removeAllRanges();
33+
if (originalRange) {
34+
selection.addRange(originalRange);
35+
}
36+
}
37+
38+
if (!successful) {
39+
throw new Error("Copy command failed");
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)