Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 109 additions & 21 deletions src/components/floating-input.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,132 @@
import { ChatInput } from '@jupyter/chat';
import { ChatInput, JlThemeProvider } from '@jupyter/chat';
import { Button } from '@jupyter/react-components';
import { IThemeManager } from '@jupyterlab/apputils';
import { classes, closeIcon, LabIcon } from '@jupyterlab/ui-components';
import React from 'react';

interface IFloatingInputProps extends ChatInput.IProps {
onClose: () => void;
updatePosition: () => void;
onDrag?: (
deltaX: number,
deltaY: number,
isStart?: boolean,
isEnd?: boolean
) => void;
/**
* The theme manager.
*/
themeManager?: IThemeManager | null;
}

export const FloatingInput: React.FC<IFloatingInputProps> = ({
model,
toolbarRegistry,
onClose,
onCancel
onCancel,
updatePosition,
onDrag,
themeManager
}) => {
const inputRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = React.useState(false);

// Focus on the input when rendered.
React.useEffect(() => {
// Focus on the input when rendered.
inputRef.current?.getElementsByTagName('textarea').item(0)?.focus();
}, []);

// Setup ResizeObserver to detect change in size.
let resizeObserver: ResizeObserver | null = null;

if (containerRef.current) {
resizeObserver = new ResizeObserver(() => {
if (!isDragging) {
updatePosition();
}
});

resizeObserver.observe(containerRef.current);
}

// Update the position after the first render.
updatePosition();

// Cleanup
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [updatePosition, isDragging]);

const handleMouseDown = (e: React.MouseEvent) => {
// Only start dragging if clicking on the header (not the close button)
if ((e.target as HTMLElement).closest('.floating-input-close')) {
return;
}

e.preventDefault();
e.stopPropagation();

setIsDragging(true);

// Signaler le début du drag
onDrag?.(0, 0, true);

const startX = e.clientX;
const startY = e.clientY;

const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;

// Call the drag handler from parent
onDrag?.(deltaX, deltaY, false);
};

const handleMouseUp = () => {
setIsDragging(false);

// Signaler la fin du drag pour nettoyer l'état
onDrag?.(0, 0, false, true); // Quatrième paramètre pour indiquer la fin

document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};

document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};

return (
<div className="floating-input-container">
<div className="floating-input-header">
<div className="floating-input-title">💬 Chat</div>
<Button className="floating-input-close" onClick={onClose}>
<LabIcon.resolveReact
display={'flex'}
icon={closeIcon}
iconClass={classes('jp-Icon')}
<JlThemeProvider themeManager={themeManager ?? null}>
<div
ref={containerRef}
className={`floating-input-container ${isDragging ? 'dragging' : ''}`}
>
<div
className="floating-input-header"
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
>
<div className="floating-input-title">💬 Chat</div>
<Button className="floating-input-close" onClick={onClose}>
<LabIcon.resolveReact
display={'flex'}
icon={closeIcon}
iconClass={classes('jp-Icon')}
/>
</Button>
</div>
<div ref={inputRef} className="floating-input-body">
<ChatInput
model={model}
toolbarRegistry={toolbarRegistry}
onCancel={onCancel}
/>
</Button>
</div>
<div ref={inputRef} className="floating-input-body">
<ChatInput
model={model}
toolbarRegistry={toolbarRegistry}
onCancel={onCancel}
/>
</div>
</div>
</div>
</JlThemeProvider>
);
};
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { IThemeManager } from '@jupyterlab/apputils';
import { INotebookTracker } from '@jupyterlab/notebook';
import { ISettingRegistry } from '@jupyterlab/settingregistry';

Expand All @@ -15,12 +16,13 @@ const plugin: JupyterFrontEndPlugin<IFloatingInputOptions> = {
id: 'jupyter-floating-chat:plugin',
description: 'A JupyterLab extension to add a floating chat.',
autoStart: true,
optional: [ISettingRegistry, INotebookTracker],
optional: [ISettingRegistry, INotebookTracker, IThemeManager],
provides: IFloatingInputOptions,
activate: (
app: JupyterFrontEnd,
settingRegistry: ISettingRegistry | null,
notebookTracker: INotebookTracker
notebookTracker: INotebookTracker,
themeManager: IThemeManager
): IFloatingInputOptions => {
console.log('JupyterLab extension jupyter-floating-chat is activated!');

Expand Down
89 changes: 81 additions & 8 deletions src/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
INotebookAttachment,
InputToolbarRegistry
} from '@jupyter/chat';
import { ReactWidget } from '@jupyterlab/apputils';
import { IThemeManager, ReactWidget } from '@jupyterlab/apputils';
import { Cell } from '@jupyterlab/cells';
import { INotebookTracker } from '@jupyterlab/notebook';
import { Message } from '@lumino/messaging';
Expand All @@ -21,6 +21,7 @@ export namespace FloatingInputWidget {
position?: { x: number; y: number };
target: HTMLElement | null;
targetType?: string;
themeManager?: IThemeManager;
}
}

Expand All @@ -31,7 +32,8 @@ export class FloatingInputWidget extends ReactWidget {
this._toolbarRegistry =
options.toolbarRegistry ?? InputToolbarRegistry.defaultToolbarRegistry();
this._toolbarRegistry.hide('attach');
this._position = options.position;
this._position = options.position ? { ...options.position } : undefined;
this._themeManager = options.themeManager;

// Keep the original send function to restore it on dispose.
this._originalSend = this._chatModel.input.send;
Expand Down Expand Up @@ -75,6 +77,7 @@ export class FloatingInputWidget extends ReactWidget {
}

this.addClass('floating-input-widget');
this.addClass('jp-ThemedContainer');
this.id = 'floating-input-widget';
}

Expand All @@ -84,6 +87,9 @@ export class FloatingInputWidget extends ReactWidget {
model={this._chatModel.input}
toolbarRegistry={this._toolbarRegistry}
onClose={() => this.dispose()}
updatePosition={this.updatePosition}
onDrag={this.handleDrag}
themeManager={this._themeManager}
/>
);
}
Expand All @@ -96,12 +102,43 @@ export class FloatingInputWidget extends ReactWidget {
Widget.detach(this);
}

protected onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
handleDrag = (
deltaX: number,
deltaY: number,
isStart = false,
isEnd = false
) => {
// Clean the start position on drop.
if (isEnd) {
this._dragStartPosition = undefined;
return;
}

this.node.style.position = 'fixed';
this.node.style.zIndex = '1000';
// Get the widget position on drag start.
if (isStart || !this._dragStartPosition) {
const rect = this.node.getBoundingClientRect();
this._dragStartPosition = { x: rect.left, y: rect.top };
return; // Do not apply move at init.
}

const newX = this._dragStartPosition.x + deltaX;
const newY = this._dragStartPosition.y + deltaY;

// Constrain the widget position to the window.
const rect = this.node.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;

const constrainedX = Math.max(0, Math.min(newX, maxX));
const constrainedY = Math.max(0, Math.min(newY, maxY));

this._position = { x: constrainedX, y: constrainedY };

this.node.style.left = `${constrainedX}px`;
this.node.style.top = `${constrainedY}px`;
};

updatePosition = () => {
if (this._position) {
let { x, y } = this._position;

Expand All @@ -119,11 +156,45 @@ export class FloatingInputWidget extends ReactWidget {
this.node.style.right = '20px';
this.node.style.bottom = '20px';
}
};

protected onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);

this.node.style.position = 'fixed';
this.node.style.zIndex = '1000';

this.updatePosition();
document.addEventListener('click', this._onDocumentClick.bind(this));
}

private _onDocumentClick(event: Event): void {
if (!this.node.contains(event.target as Node)) {
if (this.isDisposed) {
return;
}

const target = event.target as HTMLElement;

// Check if the target is still in the DOM.
if (!document.contains(target)) {
return;
}

// Check if it's a MUI Portal element (Popper, Menu, etc.), which can be attached
// to the body and not to the widget (for example the title of a button).
const isMuiPortal =
target.closest('[data-mui-portal]') !== null ||
target.closest('.MuiPopper-root') !== null ||
target.closest('.MuiPopover-root') !== null ||
target.closest('.MuiTooltip-popper') !== null ||
target.closest('.MuiDialog-root') !== null ||
target.closest('[role="presentation"]') !== null;

if (isMuiPortal) {
return;
}

if (!this.node.contains(target)) {
this.dispose();
}
}
Expand All @@ -132,7 +203,7 @@ export class FloatingInputWidget extends ReactWidget {
if (this.isDisposed) {
return;
}
// remove the event listener.
// Remove the event listener.
document.removeEventListener('click', this._onDocumentClick.bind(this));

// Clean the chat input.
Expand All @@ -148,4 +219,6 @@ export class FloatingInputWidget extends ReactWidget {
private _toolbarRegistry: IInputToolbarRegistry;
private _position?: { x: number; y: number };
private _originalSend: (content: string) => void;
private _themeManager?: IThemeManager;
private _dragStartPosition?: { x: number; y: number };
}
23 changes: 23 additions & 0 deletions style/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
background: var(--jp-toolbar-background);
border-bottom: 1px solid var(--jp-border-color1);
border-radius: 8px 8px 0 0;
user-select: none;
}

.floating-input-title {
Expand Down Expand Up @@ -60,3 +61,25 @@
.floating-input-body .jp-chat-input {
flex-grow: 1;
}

.floating-input-container.dragging {
user-select: none;
cursor: grabbing !important;
}

.floating-input-container:not(.dragging) .floating-input-header {
cursor: grab;
}

.floating-input-container.dragging .floating-input-header {
cursor: grabbing;
}

/* prevent selecting text during drag */
.floating-input-container.dragging * {
pointer-events: none;
}

.floating-input-container.dragging .floating-input-close {
pointer-events: auto;
}
Loading