Summary: The feature is behind a flag GRIST_FORMULA_ASSISTANT (must be "true"). But can be enabled in the developer console by invoking GRIST_FORMULA_ASSISTANT.set(true). Keys can be overriden in the document settings page. Test Plan: For now just a stub test that checks if this feature is disabled by default. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3815pull/472/head
parent
33c08057ad
commit
d29770511c
@ -0,0 +1,330 @@
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {isNarrowScreen, isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {Disposable, dom, DomArg, DomContents, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const POPUP_PADDING_PX = 16;
|
||||
|
||||
const testId = makeTestId('test-floating-popup-');
|
||||
|
||||
export interface PopupOptions {
|
||||
title?: () => DomContents;
|
||||
content?: () => DomContents;
|
||||
onClose?: () => void;
|
||||
closeButton?: boolean;
|
||||
autoHeight?: boolean;
|
||||
}
|
||||
|
||||
export class FloatingPopup extends Disposable {
|
||||
protected _isMinimized = Observable.create(this, false);
|
||||
private _popupElement: HTMLElement | null = null;
|
||||
|
||||
private _clientX: number;
|
||||
private _clientY: number;
|
||||
|
||||
constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) {
|
||||
super();
|
||||
|
||||
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._closePopup();
|
||||
});
|
||||
}
|
||||
|
||||
public showPopup() {
|
||||
this._popupElement = this._buildPopup();
|
||||
document.body.appendChild(this._popupElement);
|
||||
const topPaddingPx = getTopPopupPaddingPx();
|
||||
const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX;
|
||||
const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx;
|
||||
this._popupElement.style.left = `${initialLeft}px`;
|
||||
this._popupElement.style.top = `${initialTop}px`;
|
||||
}
|
||||
|
||||
protected _closePopup() {
|
||||
if (!this._popupElement) { return; }
|
||||
document.body.removeChild(this._popupElement);
|
||||
dom.domDispose(this._popupElement);
|
||||
this._popupElement = null;
|
||||
}
|
||||
|
||||
protected _buildTitle(): DomContents {
|
||||
return this._options.title?.() ?? null;
|
||||
}
|
||||
|
||||
protected _buildContent(): DomContents {
|
||||
return this._options.content?.() ?? null;
|
||||
}
|
||||
|
||||
protected _buildArgs(): any {
|
||||
return this._args;
|
||||
}
|
||||
|
||||
private _handleMouseDown(ev: MouseEvent) {
|
||||
if (ev.button !== 0) { return; } // Only handle left-click.
|
||||
this._clientX = ev.clientX;
|
||||
this._clientY = ev.clientY;
|
||||
document.addEventListener('mousemove', this._handleMouseMove);
|
||||
document.addEventListener('mouseup', this._handleMouseUp);
|
||||
}
|
||||
|
||||
private _handleTouchStart(ev: TouchEvent) {
|
||||
this._clientX = ev.touches[0].clientX;
|
||||
this._clientY = ev.touches[0].clientY;
|
||||
document.addEventListener('touchmove', this._handleTouchMove);
|
||||
document.addEventListener('touchend', this._handleTouchEnd);
|
||||
}
|
||||
|
||||
private _handleMouseMove({clientX, clientY}: MouseEvent) {
|
||||
this._handleMove(clientX, clientY);
|
||||
}
|
||||
|
||||
private _handleTouchMove({touches}: TouchEvent) {
|
||||
this._handleMove(touches[0].clientX, touches[0].clientY);
|
||||
}
|
||||
|
||||
private _handleMove(clientX: number, clientY: number) {
|
||||
const deltaX = clientX - this._clientX;
|
||||
const deltaY = clientY - this._clientY;
|
||||
let newLeft = this._popupElement!.offsetLeft + deltaX;
|
||||
let newTop = this._popupElement!.offsetTop + deltaY;
|
||||
|
||||
const topPaddingPx = getTopPopupPaddingPx();
|
||||
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
||||
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
||||
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
||||
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_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`;
|
||||
this._clientX = clientX;
|
||||
this._clientY = clientY;
|
||||
}
|
||||
|
||||
private _handleMouseUp() {
|
||||
document.removeEventListener('mousemove', this._handleMouseMove);
|
||||
document.removeEventListener('mouseup', this._handleMouseUp);
|
||||
document.body.removeEventListener('mouseleave', this._handleMouseUp);
|
||||
}
|
||||
|
||||
private _handleTouchEnd() {
|
||||
document.removeEventListener('touchmove', this._handleTouchMove);
|
||||
document.removeEventListener('touchend', this._handleTouchEnd);
|
||||
document.body.removeEventListener('touchcancel', this._handleTouchEnd);
|
||||
}
|
||||
|
||||
private _handleWindowResize() {
|
||||
this._repositionPopup();
|
||||
}
|
||||
|
||||
private _repositionPopup() {
|
||||
let newLeft = this._popupElement!.offsetLeft;
|
||||
let newTop = this._popupElement!.offsetTop;
|
||||
|
||||
const topPaddingPx = getTopPopupPaddingPx();
|
||||
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
||||
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
||||
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
||||
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_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 _buildPopup() {
|
||||
const body = cssPopup(
|
||||
{tabIndex: '-1'},
|
||||
cssPopup.cls('-auto', this._options.autoHeight ?? false),
|
||||
cssPopupHeader(
|
||||
dom.domComputed(this._isMinimized, isMinimized => {
|
||||
return [
|
||||
// Copy buttons on the left side of the header, to automatically
|
||||
// center the title.
|
||||
cssPopupButtons(
|
||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||
icon('CrossSmall'),
|
||||
),
|
||||
cssPopupHeaderButton(
|
||||
icon('Maximize')
|
||||
),
|
||||
dom.style('visibility', 'hidden'),
|
||||
),
|
||||
cssPopupTitle(
|
||||
cssPopupTitleText(this._buildTitle()),
|
||||
testId('title'),
|
||||
),
|
||||
cssPopupButtons(
|
||||
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||
icon('CrossSmall'),
|
||||
dom.on('click', () => {
|
||||
this._options.onClose?.() ?? this._closePopup();
|
||||
}),
|
||||
testId('close'),
|
||||
),
|
||||
cssPopupHeaderButton(
|
||||
isMinimized ? icon('Maximize'): icon('Minimize'),
|
||||
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
|
||||
dom.on('click', () => {
|
||||
this._isMinimized.set(!this._isMinimized.get());
|
||||
this._repositionPopup();
|
||||
}),
|
||||
testId('minimize-maximize'),
|
||||
),
|
||||
)
|
||||
];
|
||||
}),
|
||||
dom.on('mousedown', this._handleMouseDown),
|
||||
dom.on('touchstart', this._handleTouchStart),
|
||||
testId('header'),
|
||||
),
|
||||
dom.maybe(use => !use(this._isMinimized), () => this._buildContent()),
|
||||
() => { 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;
|
||||
}
|
||||
}
|
||||
|
||||
function getTopPopupPaddingPx(): number {
|
||||
// On mobile, we need additional padding to avoid blocking the top and bottom bars.
|
||||
return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0);
|
||||
}
|
||||
|
||||
const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
||||
const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`;
|
||||
const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
||||
|
||||
const cssPopup = styled('div', `
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid ${theme.accentBorder};
|
||||
border-radius: 5px;
|
||||
z-index: 999;
|
||||
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;
|
||||
}
|
||||
|
||||
&-minimized:not(&-mobile) {
|
||||
max-height: ${POPUP_HEIGHT};
|
||||
}
|
||||
|
||||
&-minimized&-mobile {
|
||||
max-height: ${POPUP_HEIGHT_MOBILE};
|
||||
}
|
||||
|
||||
&-auto {
|
||||
height: auto;
|
||||
max-height: ${POPUP_HEIGHT};
|
||||
}
|
||||
|
||||
&-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: 30px;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
&: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;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
Loading…
Reference in new issue