mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 06:16:59 +08:00
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.  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -102,6 +102,8 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
|
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
|
||||||
|
|
||||||
private sidebarWidth: Signal<number | undefined> = signal(undefined);
|
private sidebarWidth: Signal<number | undefined> = signal(undefined);
|
||||||
|
@state()
|
||||||
|
accessor showPreviewPanel = false;
|
||||||
|
|
||||||
private readonly initSession = async () => {
|
private readonly initSession = async () => {
|
||||||
if (this.session) {
|
if (this.session) {
|
||||||
@@ -244,7 +246,7 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
: nothing}
|
: nothing}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return html`<div class="chat-panel-container" style=${style}>
|
const left = html`<div class="chat-panel-container" style=${style}>
|
||||||
${keyed(
|
${keyed(
|
||||||
this.doc.id,
|
this.doc.id,
|
||||||
html`<ai-chat-content
|
html`<ai-chat-content
|
||||||
@@ -266,6 +268,15 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
></ai-chat-content>`
|
></ai-chat-content>`
|
||||||
)}
|
)}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
const right = html`<div>Preview Panel</div>`;
|
||||||
|
|
||||||
|
return html`<chat-panel-split-view
|
||||||
|
.left=${left}
|
||||||
|
.right=${right}
|
||||||
|
.open=${this.showPreviewPanel}
|
||||||
|
>
|
||||||
|
</chat-panel-split-view>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`<div
|
||||||
|
class="ai-chat-panel-split-view"
|
||||||
|
data-open=${this.open}
|
||||||
|
data-dragging=${this.isDragging}
|
||||||
|
>
|
||||||
|
<div class="ai-chat-panel-split-view-left">${this.left}</div>
|
||||||
|
<div class="ai-chat-panel-split-view-divider">
|
||||||
|
<div class="ai-chat-panel-split-view-divider-handle"></div>
|
||||||
|
</div>
|
||||||
|
${this.open || this.isTransitioning
|
||||||
|
? html` <div class="ai-chat-panel-split-view-right">${this.right}</div>`
|
||||||
|
: nothing}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'chat-panel-split-view': ChatPanelSplitView;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { AILoading } from './chat-panel/ai-loading';
|
|||||||
import { ChatMessageAction } from './chat-panel/message/action';
|
import { ChatMessageAction } from './chat-panel/message/action';
|
||||||
import { ChatMessageAssistant } from './chat-panel/message/assistant';
|
import { ChatMessageAssistant } from './chat-panel/message/assistant';
|
||||||
import { ChatMessageUser } from './chat-panel/message/user';
|
import { ChatMessageUser } from './chat-panel/message/user';
|
||||||
|
import { ChatPanelSplitView } from './chat-panel/split-view';
|
||||||
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
|
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
|
||||||
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
|
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
|
||||||
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
|
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
|
||||||
@@ -184,4 +185,5 @@ export function registerAIEffects() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
customElements.define('transcription-block', LitTranscriptionBlock);
|
customElements.define('transcription-block', LitTranscriptionBlock);
|
||||||
|
customElements.define('chat-panel-split-view', ChatPanelSplitView);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user