Compare commits

...

2 Commits

Author SHA1 Message Date
yutengjing 5b19ca3990 💄 style: extend artifact code background 2026-06-12 11:29:19 +08:00
yutengjing c8b5e337c0 🐛 fix: keep artifact code panel scrolled 2026-06-12 11:12:34 +08:00
4 changed files with 439 additions and 4 deletions
@@ -20,8 +20,19 @@ const mockArtifactState = vi.hoisted(() => ({
}));
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, style }: { children: ReactNode; style?: CSSProperties }) => (
<div data-testid={style?.overflow === 'auto' ? 'artifact-scroll-container' : undefined}>
Flexbox: ({
children,
className,
style,
}: {
children: ReactNode;
className?: string;
style?: CSSProperties;
}) => (
<div
className={className}
data-testid={style?.overflow === 'auto' ? 'artifact-scroll-container' : undefined}
>
{children}
</div>
),
@@ -44,6 +55,23 @@ vi.mock('@lobehub/ui', () => ({
),
}));
vi.mock('antd-style', () => ({
createStaticStyles: (
factory: (helpers: {
css: (strings: TemplateStringsArray, ...values: string[]) => string;
cssVar: Record<string, string>;
}) => Record<string, string>,
) =>
factory({
css: (strings, ...values) =>
strings.reduce((result, string, index) => result + string + (values[index] || ''), ''),
cssVar: {
borderRadius: 'var(--lobe-border-radius)',
colorFillQuaternary: 'var(--lobe-color-fill-quaternary)',
},
}),
}));
vi.mock('@/store/chat', () => ({
useChatStore: Object.assign(
(selector: (state: Record<PropertyKey, unknown>) => unknown) => {
@@ -101,6 +129,25 @@ describe('ArtifactsUI', () => {
expect(mockArtifactState.setState).not.toHaveBeenCalled();
});
it('extends the code surface background through the scroll container', () => {
render(<ArtifactsUI />);
const scrollContainer = screen.getByTestId('artifact-scroll-container');
expect(scrollContainer.className).toContain('background: var(--lobe-color-fill-quaternary)');
expect(scrollContainer.className).toContain('border-radius: var(--lobe-border-radius)');
expect(scrollContainer.className).toContain("[data-code-type='highlighter']");
expect(scrollContainer.className).toContain('background: transparent !important');
});
it('keeps streaming source animation enabled while the artifact tag is still open', () => {
mockArtifactState.isMessageGenerating = false;
render(<ArtifactsUI />);
expect(screen.getByTestId('artifact-code')).toHaveAttribute('data-animated', 'true');
});
it('renders the final preview after the artifact tag closes', () => {
mockArtifactState.artifactContent = '<!doctype html><html><body>Done</body></html>';
mockArtifactState.isArtifactTagClosed = true;
+35 -2
View File
@@ -1,4 +1,5 @@
import { Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, useEffect, useMemo } from 'react';
import { useChatStore } from '@/store/chat';
@@ -7,10 +8,29 @@ import { ArtifactDisplayMode } from '@/store/chat/slices/portal/initialState';
import { ArtifactType } from '@/types/artifact';
import Renderer from './Renderer';
import { useArtifactCodeAutoScroll } from './useArtifactCodeAutoScroll';
const styles = createStaticStyles(({ css, cssVar }) => ({
codeScroll: css`
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorFillQuaternary};
[data-code-type='highlighter'] {
min-height: 100%;
background: transparent;
}
pre.shiki {
overflow: visible !important;
background: transparent !important;
}
`,
}));
const ArtifactsUI = memo(() => {
const [
messageId,
artifactIdentifier,
displayMode,
isMessageGenerating,
artifactType,
@@ -23,6 +43,7 @@ const ArtifactsUI = memo(() => {
return [
messageId,
identifier,
s.portalArtifactDisplayMode,
messageStateSelectors.isMessageGenerating(messageId)(s),
chatPortalSelectors.artifactType(s),
@@ -64,8 +85,14 @@ const ArtifactsUI = memo(() => {
artifactType === ArtifactType.Code ||
!isArtifactTagClosed ||
displayMode === ArtifactDisplayMode.Code;
const isStreamingCode = isMessageGenerating && showCode && !isArtifactTagClosed;
const isStreamingCode = showCode && !isArtifactTagClosed;
const isStreamingArtifact = isMessageGenerating && !isArtifactTagClosed;
const { handleScroll: handleCodeScroll, ref: codeScrollRef } =
useArtifactCodeAutoScroll<HTMLDivElement>({
content: artifactContent,
enabled: isStreamingCode,
resetKey: `${messageId}:${artifactIdentifier}`,
});
// make sure the message and id is valid
if (!messageId) return;
@@ -80,7 +107,13 @@ const ArtifactsUI = memo(() => {
style={{ overflow: 'hidden' }}
>
{showCode ? (
<Flexbox flex={1} style={{ minHeight: 0, overflow: 'auto' }}>
<Flexbox
className={styles.codeScroll}
flex={1}
ref={codeScrollRef}
style={{ minHeight: 0, overflow: 'auto' }}
onScroll={handleCodeScroll}
>
<Highlighter
animated={isStreamingCode}
language={language || 'txt'}
@@ -0,0 +1,229 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useArtifactCodeAutoScroll } from './useArtifactCodeAutoScroll';
describe('useArtifactCodeAutoScroll', () => {
let rafCallbacks: { callback: FrameRequestCallback; id: number }[] = [];
class MockMutationObserver {
static latest: MockMutationObserver | null = null;
private active = true;
private callback: MutationCallback;
constructor(callback: MutationCallback) {
this.callback = callback;
MockMutationObserver.latest = this;
}
disconnect() {
this.active = false;
}
observe() {}
takeRecords() {
return [];
}
trigger(records: MutationRecord[] = []) {
if (!this.active) return;
this.callback(records, this as unknown as MutationObserver);
}
}
class MockResizeObserver {
static latest: MockResizeObserver | null = null;
private active = true;
private callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.latest = this;
}
disconnect() {
this.active = false;
}
observe() {}
unobserve() {}
trigger() {
if (!this.active) return;
this.callback([], this as unknown as ResizeObserver);
}
}
beforeEach(() => {
let id = 0;
rafCallbacks = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
const next = { callback, id: ++id };
rafCallbacks.push(next);
return next.id;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((cancelledId) => {
rafCallbacks = rafCallbacks.filter(({ id }) => id !== cancelledId);
});
vi.stubGlobal('MutationObserver', MockMutationObserver);
vi.stubGlobal('ResizeObserver', MockResizeObserver);
MockMutationObserver.latest = null;
MockResizeObserver.latest = null;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const flushRAF = () => {
const callbacks = [...rafCallbacks];
rafCallbacks = [];
callbacks.forEach(({ callback }) => callback(performance.now()));
};
const createContainer = ({ clientHeight = 400, scrollHeight = 1000, scrollTop = 600 } = {}) => {
const container = document.createElement('div');
Object.defineProperties(container, {
clientHeight: { configurable: true, value: clientHeight },
scrollHeight: { configurable: true, value: scrollHeight },
scrollTop: { configurable: true, value: scrollTop, writable: true },
});
return container;
};
const setScrollMetrics = (
container: HTMLElement,
{
clientHeight,
scrollHeight,
scrollTop,
}: { clientHeight: number; scrollHeight: number; scrollTop: number },
) => {
Object.defineProperties(container, {
clientHeight: { configurable: true, value: clientHeight },
scrollHeight: { configurable: true, value: scrollHeight },
scrollTop: { configurable: true, value: scrollTop, writable: true },
});
};
it('scrolls to the bottom when streaming content changes', () => {
const { result, rerender } = renderHook(
({ content }) =>
useArtifactCodeAutoScroll<HTMLDivElement>({
content,
enabled: true,
resetKey: 'message-1:artifact-1',
}),
{ initialProps: { content: 'initial' } },
);
const container = createContainer();
(result.current.ref as { current: HTMLDivElement | null }).current = container;
rerender({ content: 'updated' });
act(() => {
flushRAF();
flushRAF();
flushRAF();
});
expect(container.scrollTop).toBe(container.scrollHeight);
});
it('scrolls after the highlighter mutates the rendered DOM asynchronously', () => {
const { result, rerender } = renderHook(
({ enabled }) =>
useArtifactCodeAutoScroll<HTMLDivElement>({
content: 'streaming',
enabled,
resetKey: 'message-1:artifact-1',
}),
{ initialProps: { enabled: false } },
);
const container = createContainer();
(result.current.ref as { current: HTMLDivElement | null }).current = container;
rerender({ enabled: true });
setScrollMetrics(container, { clientHeight: 400, scrollHeight: 1300, scrollTop: 600 });
MockMutationObserver.latest?.trigger();
act(() => {
flushRAF();
flushRAF();
flushRAF();
});
expect(container.scrollTop).toBe(1300);
});
it('does not scroll after the user scrolls away from the bottom', () => {
const { result } = renderHook(() =>
useArtifactCodeAutoScroll<HTMLDivElement>({
content: 'streaming',
enabled: true,
resetKey: 'message-1:artifact-1',
}),
);
const container = createContainer({ scrollTop: 100 });
(result.current.ref as { current: HTMLDivElement | null }).current = container;
act(() => {
result.current.handleScroll();
});
setScrollMetrics(container, { clientHeight: 400, scrollHeight: 1300, scrollTop: 100 });
MockMutationObserver.latest?.trigger();
act(() => {
flushRAF();
flushRAF();
flushRAF();
});
expect(container.scrollTop).toBe(100);
});
it('resumes sticking to the bottom when the artifact changes', () => {
const { result, rerender } = renderHook(
({ resetKey }) =>
useArtifactCodeAutoScroll<HTMLDivElement>({
content: 'streaming',
enabled: true,
resetKey,
}),
{ initialProps: { resetKey: 'message-1:artifact-1' } },
);
const container = createContainer({ scrollTop: 100 });
(result.current.ref as { current: HTMLDivElement | null }).current = container;
act(() => {
result.current.handleScroll();
});
rerender({ resetKey: 'message-2:artifact-2' });
act(() => {
flushRAF();
flushRAF();
flushRAF();
});
expect(container.scrollTop).toBe(container.scrollHeight);
});
});
@@ -0,0 +1,126 @@
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
interface UseArtifactCodeAutoScrollOptions {
content: string;
enabled: boolean;
resetKey: string;
threshold?: number;
}
interface UseArtifactCodeAutoScrollReturn<T extends HTMLElement> {
handleScroll: () => void;
ref: RefObject<T | null>;
}
const DEFAULT_THRESHOLD = 24;
export const useArtifactCodeAutoScroll = <T extends HTMLElement = HTMLDivElement>({
content,
enabled,
resetKey,
threshold = DEFAULT_THRESHOLD,
}: UseArtifactCodeAutoScrollOptions): UseArtifactCodeAutoScrollReturn<T> => {
const ref = useRef<T | null>(null);
const isAutoScrollingRef = useRef(false);
const shouldStickToBottomRef = useRef(true);
const rafIdsRef = useRef<number[]>([]);
const cancelScheduledScroll = useCallback(() => {
for (const id of rafIdsRef.current) {
cancelAnimationFrame(id);
}
rafIdsRef.current = [];
isAutoScrollingRef.current = false;
}, []);
const scrollToBottom = useCallback(() => {
const container = ref.current;
if (!enabled || !container || !shouldStickToBottomRef.current) return;
isAutoScrollingRef.current = true;
container.scrollTop = container.scrollHeight;
const id = requestAnimationFrame(() => {
rafIdsRef.current = rafIdsRef.current.filter((item) => item !== id);
isAutoScrollingRef.current = false;
});
rafIdsRef.current.push(id);
}, [enabled]);
const scheduleScrollToBottom = useCallback(() => {
if (!enabled || !ref.current || !shouldStickToBottomRef.current) return;
cancelScheduledScroll();
const firstId = requestAnimationFrame(() => {
rafIdsRef.current = rafIdsRef.current.filter((item) => item !== firstId);
scrollToBottom();
const secondId = requestAnimationFrame(() => {
rafIdsRef.current = rafIdsRef.current.filter((item) => item !== secondId);
scrollToBottom();
});
rafIdsRef.current.push(secondId);
});
rafIdsRef.current.push(firstId);
}, [cancelScheduledScroll, enabled, scrollToBottom]);
const handleScroll = useCallback(() => {
if (isAutoScrollingRef.current) return;
const container = ref.current;
if (!container) return;
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
shouldStickToBottomRef.current = distanceToBottom <= threshold;
}, [threshold]);
useLayoutEffect(() => {
scheduleScrollToBottom();
}, [content, scheduleScrollToBottom]);
useEffect(() => {
shouldStickToBottomRef.current = true;
scheduleScrollToBottom();
}, [resetKey, scheduleScrollToBottom]);
useEffect(() => {
const container = ref.current;
if (!enabled || !container) return;
const observerCallback = () => scheduleScrollToBottom();
const resizeObserver =
typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(observerCallback);
resizeObserver?.observe(container);
for (const child of Array.from(container.children)) {
resizeObserver?.observe(child);
}
const mutationObserver =
typeof MutationObserver === 'undefined'
? undefined
: new MutationObserver((records) => {
for (const record of records) {
for (const node of Array.from(record.addedNodes)) {
if (node instanceof Element) resizeObserver?.observe(node);
}
}
observerCallback();
});
mutationObserver?.observe(container, { childList: true, characterData: true, subtree: true });
return () => {
mutationObserver?.disconnect();
resizeObserver?.disconnect();
cancelScheduledScroll();
};
}, [cancelScheduledScroll, enabled, scheduleScrollToBottom]);
useEffect(() => cancelScheduledScroll, [cancelScheduledScroll]);
return {
handleScroll,
ref,
};
};