From a59448ec4b3efcb734997c1cf6904f37ece01bb3 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Wed, 2 Jul 2025 18:15:23 +0800 Subject: [PATCH] feat(core): add a resizeable split view for ai chat (#12896) The visibility of preview panel is controlled by `showPreviewPanel` in `ChatPanel`, but there is no entrance to open it in this PR. ![CleanShot 2025-06-23 at 15 13 39](https://github.com/user-attachments/assets/fc0e9ecf-a64d-4a21-8e10-7e838cd9e985) ## Summary by CodeRabbit - **New Features** - Introduced a split-view layout in the chat panel, allowing users to view both the chat and a new preview panel side by side. - Added a draggable divider for resizing the chat and preview panels, with the divider position saved automatically for future sessions. - **Refactor** - Updated the chat panel interface to support the new split-view and preview panel functionality. --- .../src/blocksuite/ai/chat-panel/index.ts | 13 +- .../blocksuite/ai/chat-panel/split-view.ts | 253 ++++++++++++++++++ .../core/src/blocksuite/ai/effects.ts | 2 + 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/core/src/blocksuite/ai/chat-panel/split-view.ts diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts index dd64218997..95b3051753 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts @@ -102,6 +102,8 @@ export class ChatPanel extends SignalWatcher( private isSidebarOpen: Signal = signal(false); private sidebarWidth: Signal = signal(undefined); + @state() + accessor showPreviewPanel = false; private readonly initSession = async () => { if (this.session) { @@ -244,7 +246,7 @@ export class ChatPanel extends SignalWatcher( : nothing} `; - return html`
+ const left = html`
${keyed( this.doc.id, html`` )}
`; + + const right = html`
Preview Panel
`; + + return html` + `; } } diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/split-view.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/split-view.ts new file mode 100644 index 0000000000..f2de337651 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/split-view.ts @@ -0,0 +1,253 @@ +import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; +import { ShadowlessElement } from '@blocksuite/std'; +import { + css, + html, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +export class ChatPanelSplitView extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .ai-chat-panel-split-view { + --gap: 0px; + --drag-size: 10px; + display: flex; + align-items: stretch; + height: 100%; + } + .ai-chat-panel-split-view[data-dragging='true'] { + cursor: col-resize; + } + .ai-chat-panel-split-view-left, + .ai-chat-panel-split-view-right, + .ai-chat-panel-split-view-divider { + flex-shrink: 0; + flex-grow: 0; + } + .ai-chat-panel-split-view-left, + .ai-chat-panel-split-view-right { + transition: width 0.23s ease; + } + .ai-chat-panel-split-view[data-dragging='true'] + .ai-chat-panel-split-view-left, + .ai-chat-panel-split-view[data-dragging='true'] + .ai-chat-panel-split-view-right { + transition: none; + } + .ai-chat-panel-split-view-divider { + width: var(--gap); + position: relative; + border-left: 0.5px solid var(--affine-v2-layer-insideBorder-border); + } + .ai-chat-panel-split-view-divider[data-open='false'] { + width: 0; + visibility: hidden; + pointer-events: none; + } + .ai-chat-panel-split-view-divider-handle { + width: var(--drag-size); + height: 100%; + position: absolute; + top: 0; + left: calc((var(--drag-size) - var(--gap)) / 2 * -1); + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; + } + .ai-chat-panel-split-view-divider-handle::after { + content: ''; + width: 2px; + height: 100%; + background-color: var(--affine-v2-button-primary); + opacity: 0; + transition: + opacity 0.23s ease, + width 0.23s ease; + } + .ai-chat-panel-split-view[data-dragging='true'] + .ai-chat-panel-split-view-divider-handle::after { + width: 4px; + opacity: 1; + } + .ai-chat-panel-split-view-divider-handle:hover::after { + opacity: 1; + } + `; + + @property({ attribute: false }) + accessor minWidthPercent: number = 20; + + @property({ attribute: false }) + accessor open: boolean = false; + + @property({ attribute: false }) + accessor left: TemplateResult<1> | null = null; + + @property({ attribute: false }) + accessor right: TemplateResult<1> | null = null; + + @query('.ai-chat-panel-split-view-divider-handle') + private accessor _handle!: HTMLElement; + + @query('.ai-chat-panel-split-view-left') + private accessor _left!: HTMLElement; + + @query('.ai-chat-panel-split-view-right') + private accessor _right!: HTMLElement; + + @state() + accessor isDragging = false; + + @state() + accessor isTransitioning = false; + + private readonly _storeKey = 'chat-panel-split-view-size'; + + private _getInitialSize() { + try { + const last = localStorage.getItem(this._storeKey); + return last ? Number.parseInt(last) : 50; + } catch { + return 50; + } + } + + private _setInitialSize(size: number) { + try { + localStorage.setItem(this._storeKey, size.toString()); + } catch { + console.error('Failed to set initial size'); + } + } + + private _percent = this._getInitialSize(); + private _initialBox: DOMRect | null = null; + private _initialX: number | null = null; + private _initialPercent: number | null = null; + private _rafId: number | null = null; + + private _onDragStart(x: number) { + this.isDragging = true; + this._initialBox = this.getBoundingClientRect(); + this._initialX = x; + this._initialPercent = this._percent; + } + private _onDragMove(x: number) { + const offset = x - (this._initialX || 0); + const offsetPercent = (offset / (this._initialBox?.width || 1)) * 100; + + this._percent = Math.max( + this.minWidthPercent, + Math.min( + 100 - this.minWidthPercent, + Number(((this._initialPercent || 0) + offsetPercent).toFixed(0)) + ) + ); + this._updateSize(); + } + private _onDragEnd() { + this.isDragging = false; + this._setInitialSize(this._percent); + } + + private _updateSize() { + if (this._rafId) { + cancelAnimationFrame(this._rafId); + } + this._rafId = requestAnimationFrame(() => { + if (this.open && this._left && this._right) { + const leftPercent = this._percent; + const rightPercent = 100 - leftPercent; + this._left.style.width = `${leftPercent}%`; + this._right.style.width = `${rightPercent}%`; + } + + if (!this.open && this._left) { + this._left.style.width = '100%'; + } + }); + } + + override firstUpdated(changed: PropertyValues) { + super.firstUpdated(changed); + if (this._left) { + this.disposables.addFromEvent(this._left, 'transitionstart', () => { + this.isTransitioning = true; + }); + this.disposables.addFromEvent(this._left, 'transitionend', () => { + this.isTransitioning = false; + }); + } + + if (this._handle) { + // mouse + let onMouseMove = (e: MouseEvent) => { + this._onDragMove(e.clientX); + }; + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + this._onDragEnd(); + }; + this.disposables.addFromEvent(this._handle, 'mousedown', e => { + e.stopPropagation(); + e.preventDefault(); + this._onDragStart(e.clientX); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + // touch + let onTouchMove = (e: TouchEvent) => { + this._onDragMove(e.touches[0].clientX); + }; + const onTouchEnd = () => { + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + this._onDragEnd(); + }; + this.disposables.addFromEvent(this._handle, 'touchstart', e => { + e.stopPropagation(); + e.preventDefault(); + this._onDragStart(e.touches[0].clientX); + document.addEventListener('touchmove', onTouchMove); + document.addEventListener('touchend', onTouchEnd); + }); + } + } + + override updated(changed: PropertyValues) { + super.updated(changed); + if (changed.has('open')) { + this._updateSize(); + } + } + + override render() { + return html`
+
${this.left}
+
+
+
+ ${this.open || this.isTransitioning + ? html`
${this.right}
` + : nothing} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-panel-split-view': ChatPanelSplitView; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index a136b3e91e..4e7839f3aa 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -23,6 +23,7 @@ import { AILoading } from './chat-panel/ai-loading'; import { ChatMessageAction } from './chat-panel/message/action'; import { ChatMessageAssistant } from './chat-panel/message/assistant'; import { ChatMessageUser } from './chat-panel/message/user'; +import { ChatPanelSplitView } from './chat-panel/split-view'; import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover'; import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover'; import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips'; @@ -184,4 +185,5 @@ export function registerAIEffects() { ); customElements.define('transcription-block', LitTranscriptionBlock); + customElements.define('chat-panel-split-view', ChatPanelSplitView); }