diff --git a/packages/builtin-tool-page-agent/src/client/executor/index.test.ts b/packages/builtin-tool-page-agent/src/client/executor/index.test.ts index 8c8ee41f5a..d885483bbb 100644 --- a/packages/builtin-tool-page-agent/src/client/executor/index.test.ts +++ b/packages/builtin-tool-page-agent/src/client/executor/index.test.ts @@ -13,9 +13,13 @@ describe('PageAgentExecutor', () => { beforeEach(() => { // Create mock runtime with all methods mockRuntime = { + applyServerSnapshot: vi.fn(), editTitle: vi.fn(), + getCurrentDocId: vi.fn(() => 'doc-123'), + getDebugSnapshot: vi.fn(() => ({})), getPageContent: vi.fn(), initPage: vi.fn(), + isReady: vi.fn(() => true), modifyNodes: vi.fn(), replaceText: vi.fn(), } as unknown as EditorRuntime; @@ -299,6 +303,42 @@ describe('PageAgentExecutor', () => { }); }); + describe('onAfterCall', () => { + it('should ignore read-only getPageContent state without a document snapshot', async () => { + await executor.onAfterCall({ + result: { + state: { + documentId: 'doc-123', + markdown: '# Title\n\nContent', + metadata: { title: 'Title' }, + xml: '

Title

', + }, + success: true, + }, + } as any); + + expect(mockRuntime.applyServerSnapshot).not.toHaveBeenCalled(); + }); + + it('should apply server snapshots when a mutating tool returns document content', async () => { + await executor.onAfterCall({ + result: { + state: { + documentContent: '# Updated', + documentId: 'doc-123', + }, + success: true, + }, + } as any); + + expect(mockRuntime.applyServerSnapshot).toHaveBeenCalledWith({ + content: '# Updated', + editorData: undefined, + title: undefined, + }); + }); + }); + describe('invoke method', () => { it('should invoke the correct method based on apiName', async () => { vi.mocked(mockRuntime.editTitle).mockResolvedValue({ diff --git a/packages/builtin-tool-page-agent/src/client/executor/index.ts b/packages/builtin-tool-page-agent/src/client/executor/index.ts index b2d6f4f1bd..610ccc83b2 100644 --- a/packages/builtin-tool-page-agent/src/client/executor/index.ts +++ b/packages/builtin-tool-page-agent/src/client/executor/index.ts @@ -163,6 +163,10 @@ class PageAgentExecutor extends BaseExecutor { state.documentEditorData && typeof state.documentEditorData === 'object' ? (state.documentEditorData as Record) : undefined; + const hasDocumentSnapshot = + typeof content === 'string' || typeof title === 'string' || !!editorData; + + if (!hasDocumentSnapshot) return; // Only push into the live editor when this runtime is bound to the same // document the server just wrote. Otherwise the snapshot would overwrite diff --git a/src/features/PageEditor/PageEditor.tsx b/src/features/PageEditor/PageEditor.tsx index dd3c5241f0..a3420fa026 100644 --- a/src/features/PageEditor/PageEditor.tsx +++ b/src/features/PageEditor/PageEditor.tsx @@ -3,8 +3,8 @@ import { DEFAULT_BLOCK_ANCHOR_PADDING, EditorProvider } from '@lobehub/editor/react'; import { Flexbox } from '@lobehub/ui'; import { createStyles, cssVar } from 'antd-style'; -import type { CSSProperties, FC, ReactNode } from 'react'; -import { memo } from 'react'; +import type { CSSProperties, FC, ReactNode, UIEvent } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import { CONVERSATION_MIN_WIDTH } from '@/const/layoutTokens'; import DiffAllToolbar from '@/features/EditorCanvas/DiffAllToolbar'; @@ -39,15 +39,37 @@ type PageEditorHeader = ReactNode | null; const WIDE_SCREEN_CONTAINER_PADDING = 16; const TABLE_BASE_BLEED = DEFAULT_BLOCK_ANCHOR_PADDING + WIDE_SCREEN_CONTAINER_PADDING; +const getMaxScrollTop = (node: HTMLElement) => Math.max(node.scrollHeight - node.clientHeight, 0); + +const shouldRestoreEditorScroll = ({ + isUserInteractingWithEditor, + maxScrollTop, + nextScrollTop, + previousScrollTop, +}: { + isUserInteractingWithEditor: boolean; + maxScrollTop: number; + nextScrollTop: number; + previousScrollTop: number; +}) => + previousScrollTop > 0 && + nextScrollTop === 0 && + maxScrollTop >= previousScrollTop && + !isUserInteractingWithEditor; + const styles = StyleSheet.create({ contentWrapper: { containerType: 'inline-size', display: 'flex', + flex: 1, + minHeight: 0, overflowY: 'auto', position: 'relative', }, editorContainer: { + minHeight: 0, minWidth: 0, + overflow: 'hidden', position: 'relative', }, editorContent: { @@ -111,6 +133,113 @@ const PageEditorCanvas = memo(({ header, fullWidthHeader ...styles.editorContent, '--lobe-pageeditor-table-bleed-inline': tableBleedInline, } as CSSProperties; + const resizeFrameRef = useRef(undefined); + const restoreScrollFrameRef = useRef(undefined); + const isRestoringScrollRef = useRef(false); + const isPointerInsideEditorPaneRef = useRef(false); + const lastEditorScrollTopRef = useRef(0); + const editorPaneRef = useRef(null); + const contentWrapperRef = useRef(null); + + const isUserInteractingWithEditor = useCallback(() => { + if (isPointerInsideEditorPaneRef.current) return true; + + const activeElement = document.activeElement; + return !!activeElement && !!editorPaneRef.current?.contains(activeElement); + }, []); + + const restoreEditorScrollPosition = useCallback(() => { + const node = contentWrapperRef.current; + if (!node || typeof window === 'undefined') return; + + const maxScrollTop = getMaxScrollTop(node); + const targetScrollTop = Math.min(lastEditorScrollTopRef.current, maxScrollTop); + + if (targetScrollTop <= 0 || node.scrollTop === targetScrollTop) return; + + isRestoringScrollRef.current = true; + node.scrollTop = targetScrollTop; + + window.requestAnimationFrame(() => { + isRestoringScrollRef.current = false; + }); + }, []); + + const scheduleRestoreEditorScrollPosition = useCallback(() => { + if (typeof window === 'undefined') return; + + if (restoreScrollFrameRef.current) { + window.cancelAnimationFrame(restoreScrollFrameRef.current); + } + + restoreScrollFrameRef.current = window.requestAnimationFrame(() => { + restoreScrollFrameRef.current = undefined; + restoreEditorScrollPosition(); + }); + }, [restoreEditorScrollPosition]); + + const handleEditorScroll = useCallback( + (event: UIEvent) => { + if (isRestoringScrollRef.current) return; + + const node = event.currentTarget; + const nextScrollTop = node.scrollTop; + const previousScrollTop = lastEditorScrollTopRef.current; + + if ( + shouldRestoreEditorScroll({ + isUserInteractingWithEditor: isUserInteractingWithEditor(), + maxScrollTop: getMaxScrollTop(node), + nextScrollTop, + previousScrollTop, + }) + ) { + scheduleRestoreEditorScrollPosition(); + return; + } + + lastEditorScrollTopRef.current = nextScrollTop; + }, + [isUserInteractingWithEditor, scheduleRestoreEditorScrollPosition], + ); + + const notifyEditorLayoutChange = useCallback(() => { + if (typeof window === 'undefined') return; + + if (resizeFrameRef.current) { + window.cancelAnimationFrame(resizeFrameRef.current); + } + + resizeFrameRef.current = window.requestAnimationFrame(() => { + resizeFrameRef.current = undefined; + window.dispatchEvent(new Event('resize')); + scheduleRestoreEditorScrollPosition(); + }); + }, [scheduleRestoreEditorScrollPosition]); + + useEffect(() => { + const node = editorPaneRef.current; + if (!node || typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(() => notifyEditorLayoutChange()); + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }, [notifyEditorLayoutChange]); + + useEffect( + () => () => { + if (resizeFrameRef.current && typeof window !== 'undefined') { + window.cancelAnimationFrame(resizeFrameRef.current); + } + if (restoreScrollFrameRef.current && typeof window !== 'undefined') { + window.cancelAnimationFrame(restoreScrollFrameRef.current); + } + }, + [], + ); // Register Files scope and save document hotkey useRegisterFilesHotkeys(); @@ -118,11 +247,30 @@ const PageEditorCanvas = memo(({ header, fullWidthHeader const headerSlot = header === undefined ?
: header; const editorPane = ( - + { + isPointerInsideEditorPaneRef.current = true; + }} + onPointerLeave={() => { + isPointerInsideEditorPaneRef.current = false; + }} + > {!fullWidthHeader && headerSlot} - + { if (!canEdit) return;