mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
ea8a59c5e9
Summary: Implements the latest design of the Formula AI Assistant. Also switches out brace to the latest build of ace. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3949
550 lines
17 KiB
TypeScript
550 lines
17 KiB
TypeScript
import {makeT} from 'app/client/lib/localization';
|
|
import {documentCursor} from 'app/client/lib/popupUtils';
|
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
|
import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
|
|
import {IconName} from 'app/client/ui2018/IconList';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {Disposable, dom, DomContents, DomElementArg,
|
|
IDisposable, makeTestId, Observable, styled} from 'grainjs';
|
|
|
|
const POPUP_INITIAL_PADDING_PX = 16;
|
|
const POPUP_DEFAULT_MIN_HEIGHT = 300;
|
|
const POPUP_MAX_HEIGHT = 711;
|
|
const POPUP_HEADER_HEIGHT = 30;
|
|
|
|
const t = makeT('FloatingPopup');
|
|
|
|
const testId = makeTestId('test-floating-popup-');
|
|
|
|
export const FLOATING_POPUP_TOOLTIP_KEY = 'floatingPopupTooltip';
|
|
|
|
export interface PopupOptions {
|
|
title?: () => DomContents;
|
|
content?: () => DomContents;
|
|
onClose?: () => void;
|
|
closeButton?: boolean;
|
|
closeButtonIcon?: IconName;
|
|
closeButtonHover?: () => DomContents;
|
|
minimizable?: boolean;
|
|
autoHeight?: boolean;
|
|
/** Minimum height in pixels. */
|
|
minHeight?: number;
|
|
/** Defaults to false. */
|
|
stopClickPropagationOnMove?: boolean;
|
|
initialPosition?: [left: number, top: number];
|
|
args?: DomElementArg[];
|
|
}
|
|
|
|
export class FloatingPopup extends Disposable {
|
|
protected _isMinimized = Observable.create(this, false);
|
|
private _closable = this._options.closeButton ?? false;
|
|
private _minimizable = this._options.minimizable ?? false;
|
|
private _minHeight = this._options.minHeight ?? POPUP_DEFAULT_MIN_HEIGHT;
|
|
private _isFinishingMove = false;
|
|
private _popupElement: HTMLElement | null = null;
|
|
private _popupMinimizeButtonElement: HTMLElement | null = null;
|
|
|
|
private _startX: number;
|
|
private _startY: number;
|
|
private _initialTop: number;
|
|
private _initialBottom: number;
|
|
private _initialLeft: number;
|
|
private _resize = false;
|
|
private _cursorGrab: IDisposable|null = null;
|
|
|
|
constructor(protected _options: PopupOptions = {}) {
|
|
super();
|
|
|
|
if (_options.stopClickPropagationOnMove){
|
|
// weasel.js registers a 'click' listener that closes any open popups that
|
|
// are outside the click target. We capture the click event here, stopping
|
|
// propagation in a few scenarios where closing popups is undesirable.
|
|
window.addEventListener('click', (ev) => {
|
|
if (this._isFinishingMove) {
|
|
ev.stopPropagation();
|
|
this._isFinishingMove = false;
|
|
return;
|
|
}
|
|
|
|
if (this._popupMinimizeButtonElement?.contains(ev.target as Node)) {
|
|
ev.stopPropagation();
|
|
this._minimizeOrMaximize();
|
|
return;
|
|
}
|
|
}, {capture: true});
|
|
}
|
|
|
|
this._handleMouseDown = this._handleMouseDown.bind(this);
|
|
this._handleMouseMove = this._handleMouseMove.bind(this);
|
|
this._handleMouseUp = this._handleMouseUp.bind(this);
|
|
this._handleTouchStart = this._handleTouchStart.bind(this);
|
|
this._handleTouchMove = this._handleTouchMove.bind(this);
|
|
this._handleTouchEnd = this._handleTouchEnd.bind(this);
|
|
this._handleWindowResize = this._handleWindowResize.bind(this);
|
|
|
|
this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup()));
|
|
|
|
this.onDispose(() => {
|
|
this._disposePopup();
|
|
this._cursorGrab?.dispose();
|
|
});
|
|
}
|
|
|
|
public showPopup() {
|
|
this._popupElement = this._buildPopup();
|
|
document.body.appendChild(this._popupElement);
|
|
|
|
const {initialPosition} = this._options;
|
|
if (initialPosition) {
|
|
this._setPosition(initialPosition);
|
|
this._repositionPopup();
|
|
} else {
|
|
const left = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_INITIAL_PADDING_PX;
|
|
const top = document.body.offsetHeight - this._popupElement.offsetHeight - getTopPopupPaddingPx();
|
|
this._setPosition([left, top]);
|
|
}
|
|
}
|
|
|
|
protected _closePopup() {
|
|
if (!this._closable) { return; }
|
|
|
|
this._disposePopup();
|
|
}
|
|
|
|
protected _buildTitle(): DomContents {
|
|
return this._options.title?.() ?? null;
|
|
}
|
|
|
|
protected _buildContent(): DomContents {
|
|
return this._options.content?.() ?? null;
|
|
}
|
|
|
|
protected _buildArgs(): any {
|
|
return this._options.args ?? [];
|
|
}
|
|
|
|
private _disposePopup() {
|
|
if (!this._popupElement) { return; }
|
|
|
|
document.body.removeChild(this._popupElement);
|
|
dom.domDispose(this._popupElement);
|
|
this._popupElement = null;
|
|
}
|
|
|
|
private _setPosition([left, top]: [left: number, top: number]) {
|
|
if (!this._popupElement) { return; }
|
|
|
|
this._popupElement.style.left = `${left}px`;
|
|
this._popupElement.style.top = `${top}px`;
|
|
}
|
|
|
|
private _rememberPosition() {
|
|
this._initialLeft = this._popupElement!.offsetLeft;
|
|
this._initialTop = this._popupElement!.offsetTop;
|
|
this._initialBottom = this._popupElement!.offsetTop + this._popupElement!.offsetHeight;
|
|
}
|
|
|
|
private _handleMouseDown(ev: MouseEvent) {
|
|
if (ev.button !== 0) { return; } // Only handle left-click.
|
|
this._startX = ev.clientX;
|
|
this._startY = ev.clientY;
|
|
this._rememberPosition();
|
|
document.addEventListener('mousemove', this._handleMouseMove);
|
|
document.addEventListener('mouseup', this._handleMouseUp);
|
|
this._forceCursor();
|
|
}
|
|
|
|
private _handleTouchStart(ev: TouchEvent) {
|
|
this._startX = ev.touches[0].clientX;
|
|
this._startY = ev.touches[0].clientY;
|
|
this._rememberPosition();
|
|
document.addEventListener('touchmove', this._handleTouchMove);
|
|
document.addEventListener('touchend', this._handleTouchEnd);
|
|
|
|
this._resize = false;
|
|
this._forceCursor();
|
|
}
|
|
|
|
private _handleTouchMove({touches}: TouchEvent) {
|
|
this._handleMouseMove(touches[0]);
|
|
}
|
|
|
|
private _handleMouseMove({clientX, clientY}: MouseEvent | Touch) {
|
|
if (this._resize) {
|
|
this._handleResize(clientY);
|
|
} else {
|
|
this._handleMove(clientX, clientY);
|
|
}
|
|
}
|
|
|
|
private _handleResize(clientY: number) {
|
|
const deltaY = clientY - this._startY;
|
|
if (this._resize && !isNarrowScreen()) {
|
|
// First calculate the boundaries for the new top.
|
|
|
|
// First just how much we can resize the popup.
|
|
let minTop = this._initialBottom - POPUP_MAX_HEIGHT;
|
|
let maxTop = this._initialBottom - this._minHeight;
|
|
|
|
// Now how far we can move top (leave at least some padding for mobile).
|
|
minTop = Math.max(minTop, getTopPopupPaddingPx());
|
|
// And bottom (we want the header to be visible)
|
|
maxTop = Math.min(document.body.offsetHeight - POPUP_HEADER_HEIGHT - 2, maxTop);
|
|
|
|
// Now get new top from those boundaries.
|
|
const newTop = Math.max(minTop, Math.min(maxTop, this._initialTop + deltaY));
|
|
// And calculate the new height.
|
|
const newHeight = this._initialBottom - newTop;
|
|
this._popupElement!.style.top = `${newTop}px`;
|
|
this._popupElement!.style.setProperty('--height', `${newHeight}px`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private _handleMove(clientX: number, clientY: number) {
|
|
// Last change in position (from last move).
|
|
const deltaX = clientX - this._startX;
|
|
const deltaY = clientY - this._startY;
|
|
|
|
// Available space where we can put the popup (anchored at top left corner).
|
|
const viewPort = {
|
|
right: document.body.offsetWidth,
|
|
bottom: document.body.offsetHeight,
|
|
top: getTopPopupPaddingPx(),
|
|
left: 0,
|
|
};
|
|
|
|
// Allow some extra space, where we can still move the popup outside the viewport.
|
|
viewPort.right += this._popupElement!.offsetWidth - (POPUP_HEADER_HEIGHT + 2) * 4;
|
|
viewPort.left -= this._popupElement!.offsetWidth - (POPUP_HEADER_HEIGHT + 2) * 4;
|
|
viewPort.bottom += this._popupElement!.offsetHeight - POPUP_HEADER_HEIGHT - 2; // 2px border top
|
|
|
|
let newLeft = this._initialLeft + deltaX;
|
|
let newTop = this._initialTop + deltaY;
|
|
const newRight = (val?: number) => {
|
|
if (val !== undefined) { newLeft = val - this._popupElement!.offsetWidth; }
|
|
return newLeft + this._popupElement!.offsetWidth;
|
|
};
|
|
const newBottom = (val?: number) => {
|
|
if (val !== undefined) { newTop = val - this._popupElement!.offsetHeight; }
|
|
return newTop + this._popupElement!.offsetHeight;
|
|
};
|
|
|
|
// Calculate new position in the padding area.
|
|
if (newLeft < viewPort.left) { newLeft = viewPort.left; }
|
|
if (newRight() > viewPort.right) { newRight(viewPort.right); }
|
|
if (newTop < viewPort.top) { newTop = viewPort.top; }
|
|
if (newBottom() > viewPort.bottom) { newBottom(viewPort.bottom); }
|
|
|
|
this._popupElement!.style.left = `${newLeft}px`;
|
|
this._popupElement!.style.top = `${newTop}px`;
|
|
}
|
|
|
|
private _handleMouseUp() {
|
|
this._isFinishingMove = true;
|
|
document.removeEventListener('mousemove', this._handleMouseMove);
|
|
document.removeEventListener('mouseup', this._handleMouseUp);
|
|
document.body.removeEventListener('mouseleave', this._handleMouseUp);
|
|
this._handleMouseEnd();
|
|
}
|
|
|
|
private _handleTouchEnd() {
|
|
document.removeEventListener('touchmove', this._handleTouchMove);
|
|
document.removeEventListener('touchend', this._handleTouchEnd);
|
|
document.body.removeEventListener('touchcancel', this._handleTouchEnd);
|
|
this._handleMouseEnd();
|
|
}
|
|
|
|
private _handleMouseEnd() {
|
|
this._resize = false;
|
|
this._cursorGrab?.dispose();
|
|
}
|
|
|
|
private _handleWindowResize() {
|
|
this._repositionPopup();
|
|
}
|
|
|
|
private _repositionPopup() {
|
|
let newLeft = this._popupElement!.offsetLeft;
|
|
let newTop = this._popupElement!.offsetTop;
|
|
|
|
const topPaddingPx = getTopPopupPaddingPx();
|
|
if (newLeft - POPUP_INITIAL_PADDING_PX < 0) { newLeft = POPUP_INITIAL_PADDING_PX; }
|
|
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
|
if (newLeft + POPUP_INITIAL_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
|
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_INITIAL_PADDING_PX;
|
|
}
|
|
if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {
|
|
newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx;
|
|
}
|
|
|
|
this._popupElement!.style.left = `${newLeft}px`;
|
|
this._popupElement!.style.top = `${newTop}px`;
|
|
}
|
|
|
|
private _minimizeOrMaximize() {
|
|
if (!this._minimizable) { return; }
|
|
|
|
this._isMinimized.set(!this._isMinimized.get());
|
|
this._repositionPopup();
|
|
}
|
|
|
|
private _buildPopup() {
|
|
const body = cssPopup(
|
|
{tabIndex: '-1'},
|
|
cssPopup.cls('-auto', this._options.autoHeight ?? false),
|
|
dom.style('min-height', `${this._minHeight}px`),
|
|
cssPopupHeader(
|
|
cssBottomHandle(testId('move-handle')),
|
|
dom.maybe(use => !use(this._isMinimized), () => {
|
|
return cssResizeTopLayer(
|
|
cssTopHandle(testId('resize-handle')),
|
|
dom.on('mousedown', () => this._resize = true),
|
|
dom.on('dblclick', (e) => {
|
|
e.stopImmediatePropagation();
|
|
this._popupElement?.style.setProperty('--height', `${POPUP_MAX_HEIGHT}px`);
|
|
this._repositionPopup();
|
|
})
|
|
);
|
|
}),
|
|
dom.domComputed(this._isMinimized, isMinimized => {
|
|
return [
|
|
// Copy buttons on the left side of the header, to automatically
|
|
// center the title.
|
|
cssPopupButtons(
|
|
cssPopupHeaderButton(
|
|
icon('Maximize'),
|
|
dom.show(this._minimizable),
|
|
),
|
|
cssPopupHeaderButton(
|
|
icon('CrossBig'),
|
|
dom.show(this._closable),
|
|
),
|
|
dom.style('visibility', 'hidden'),
|
|
),
|
|
cssPopupTitle(
|
|
cssPopupTitleText(this._buildTitle()),
|
|
testId('title'),
|
|
),
|
|
cssPopupButtons(
|
|
this._popupMinimizeButtonElement = cssPopupHeaderButton(
|
|
isMinimized ? icon('Maximize'): icon('Minimize'),
|
|
hoverTooltip(isMinimized ? t('Maximize') : t('Minimize'), {
|
|
key: FLOATING_POPUP_TOOLTIP_KEY,
|
|
}),
|
|
dom.on('click', () => this._minimizeOrMaximize()),
|
|
dom.show(this._minimizable),
|
|
testId('minimize-maximize'),
|
|
),
|
|
cssPopupHeaderButton(
|
|
icon(this._options.closeButtonIcon ?? 'CrossBig'),
|
|
this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover(), {
|
|
key: FLOATING_POPUP_TOOLTIP_KEY,
|
|
}),
|
|
dom.on('click', () => {
|
|
this._options.onClose?.() ?? this._closePopup();
|
|
}),
|
|
dom.show(this._closable),
|
|
testId('close'),
|
|
),
|
|
// Disable dragging when a button in the header is clicked.
|
|
dom.on('mousedown', ev => ev.stopPropagation()),
|
|
dom.on('touchstart', ev => ev.stopPropagation()),
|
|
)
|
|
];
|
|
}),
|
|
dom.on('mousedown', this._handleMouseDown),
|
|
dom.on('touchstart', this._handleTouchStart),
|
|
dom.on('dblclick', () => this._minimizeOrMaximize()),
|
|
testId('header'),
|
|
),
|
|
cssPopupContent(
|
|
this._buildContent(),
|
|
cssPopupContent.cls('-minimized', this._isMinimized),
|
|
),
|
|
() => { window.addEventListener('resize', this._handleWindowResize); },
|
|
dom.onDispose(() => {
|
|
document.removeEventListener('mousemove', this._handleMouseMove);
|
|
document.removeEventListener('mouseup', this._handleMouseUp);
|
|
document.removeEventListener('touchmove', this._handleTouchMove);
|
|
document.removeEventListener('touchend', this._handleTouchEnd);
|
|
window.removeEventListener('resize', this._handleWindowResize);
|
|
}),
|
|
cssPopup.cls('-minimized', this._isMinimized),
|
|
cssPopup.cls('-mobile', isNarrowScreenObs()),
|
|
testId('window'),
|
|
this._buildArgs()
|
|
);
|
|
|
|
// For auto-height popups, we need to reposition the popup when the content changes.
|
|
// It is important for auto-grow and to prevent popup from going off-screen.
|
|
if (this._options.autoHeight) {
|
|
const observer = new MutationObserver(() => {
|
|
this._repositionPopup();
|
|
});
|
|
observer.observe(body, {childList: true, subtree: true});
|
|
dom.update(body,
|
|
dom.onDispose(() => observer.disconnect())
|
|
);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
private _forceCursor() {
|
|
this._cursorGrab?.dispose();
|
|
const type = this._resize ? 'ns-resize' : 'grabbing';
|
|
this._cursorGrab = documentCursor(type);
|
|
}
|
|
}
|
|
|
|
|
|
function getTopPopupPaddingPx(): number {
|
|
// On mobile, we need additional padding to avoid blocking the top and bottom bars.
|
|
return POPUP_INITIAL_PADDING_PX + (isNarrowScreen() ? 50 : 0);
|
|
}
|
|
|
|
const POPUP_HEIGHT = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`;
|
|
const POPUP_HEIGHT_MOBILE = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px) - (2 * 50px)))`;
|
|
|
|
export const FLOATING_POPUP_MAX_WIDTH_PX = 436;
|
|
const POPUP_WIDTH = `min(${FLOATING_POPUP_MAX_WIDTH_PX}px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`;
|
|
|
|
const cssPopup = styled('div.floating-popup', `
|
|
position: fixed;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 2px solid ${theme.accentBorder};
|
|
border-radius: 5px;
|
|
z-index: ${vars.floatingPopupZIndex};
|
|
--height: ${POPUP_MAX_HEIGHT}px;
|
|
height: ${POPUP_HEIGHT};
|
|
width: ${POPUP_WIDTH};
|
|
background-color: ${theme.popupBg};
|
|
box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};
|
|
outline: unset;
|
|
|
|
&-mobile {
|
|
height: ${POPUP_HEIGHT_MOBILE};
|
|
}
|
|
|
|
&-minimized {
|
|
max-width: 225px;
|
|
height: unset;
|
|
min-height: unset;
|
|
}
|
|
|
|
&-minimized:not(&-mobile) {
|
|
max-height: ${POPUP_HEIGHT};
|
|
}
|
|
|
|
&-minimized&-mobile {
|
|
max-height: ${POPUP_HEIGHT_MOBILE};
|
|
}
|
|
|
|
&-auto {
|
|
height: auto;
|
|
max-height: ${POPUP_HEIGHT};
|
|
min-height: unset;
|
|
}
|
|
|
|
&-auto&-mobile {
|
|
max-height: ${POPUP_HEIGHT_MOBILE};
|
|
}
|
|
`);
|
|
|
|
const cssPopupHeader = styled('div', `
|
|
color: ${theme.tutorialsPopupHeaderFg};
|
|
--icon-color: ${theme.tutorialsPopupHeaderFg};
|
|
background-color: ${theme.accentBorder};
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
cursor: grab;
|
|
padding-left: 4px;
|
|
padding-right: 4px;
|
|
height: ${POPUP_HEADER_HEIGHT}px;
|
|
user-select: none;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
position: relative;
|
|
isolation: isolate;
|
|
&:active {
|
|
cursor: grabbing;
|
|
}
|
|
`);
|
|
|
|
const cssPopupButtons = styled('div', `
|
|
display: flex;
|
|
column-gap: 8px;
|
|
align-items: center;
|
|
`);
|
|
|
|
const cssPopupTitle = styled('div', `
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
`);
|
|
|
|
const cssPopupTitleText = styled('div', `
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
`);
|
|
|
|
export const cssPopupBody = styled('div', `
|
|
flex-grow: 1;
|
|
padding: 24px;
|
|
overflow: auto;
|
|
`);
|
|
|
|
const cssPopupHeaderButton = styled('div', `
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
z-index: 1000;
|
|
|
|
&:hover {
|
|
background-color: ${theme.hover};
|
|
}
|
|
`);
|
|
|
|
const cssResizeTopLayer = styled('div', `
|
|
position: absolute;
|
|
top: -6px;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 28px;
|
|
z-index: 500;
|
|
cursor: ns-resize;
|
|
`);
|
|
|
|
const cssTopHandle = styled('div', `
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 1px;
|
|
height: 1px;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
visibility: hidden;
|
|
`);
|
|
|
|
const cssBottomHandle = styled(cssTopHandle, `
|
|
top: unset;
|
|
bottom: 0;
|
|
left: 0;
|
|
`);
|
|
|
|
const cssPopupContent = styled('div', `
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-grow: 1;
|
|
overflow: hidden;
|
|
|
|
&-minimized {
|
|
display: none;
|
|
}
|
|
`);
|