mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-20 01:02:22 +00:00
0aad09a4ed
Summary: Forms improvements and following new design - New headers - New UI - New right panel options Test Plan: Tests updated Reviewers: georgegevoian, dsagal Reviewed By: georgegevoian Subscribers: dsagal, paulfitz Differential Revision: https://phab.getgrist.com/D4158
210 lines
6.7 KiB
TypeScript
210 lines
6.7 KiB
TypeScript
import {allCommands} from 'app/client/components/commands';
|
|
import {BoxModel, parseBox} from 'app/client/components/Forms/Model';
|
|
import {buildMenu} from 'app/client/components/Forms/Menu';
|
|
import * as style from 'app/client/components/Forms/styles';
|
|
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
|
import {IconName} from 'app/client/ui2018/IconList';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
|
|
|
const testId = makeTestId('test-forms-');
|
|
const t = makeT('FormView.Editor');
|
|
|
|
interface Props {
|
|
box: BoxModel,
|
|
/** Should we show an overlay */
|
|
overlay?: Observable<boolean>,
|
|
/** Custom drag indicator slot */
|
|
drag?: HTMLElement,
|
|
/**
|
|
* Actual element to put into the editor. This is the main content of the editor.
|
|
*/
|
|
content: DomContents,
|
|
/**
|
|
* Click handler. If not provided, then clicking on the editor will select it.
|
|
*/
|
|
click?: (ev: MouseEvent, box: BoxModel) => void,
|
|
/**
|
|
* Custom remove icon. If null, then no drop icon is shown.
|
|
*/
|
|
removeIcon?: IconName|null,
|
|
/**
|
|
* Custom remove button rendered atop overlay.
|
|
*/
|
|
removeButton?: DomContents,
|
|
/**
|
|
* Tooltip for the remove button.
|
|
*/
|
|
removeTooltip?: string,
|
|
/**
|
|
* Position of the remove button. Defaults to inside.
|
|
*/
|
|
removePosition?: 'inside'|'right',
|
|
editMode?: Observable<boolean>,
|
|
}
|
|
|
|
export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
|
const owner: MultiHolder = new MultiHolder();
|
|
const {box, overlay} = props;
|
|
const view = box.view;
|
|
const dragHover = Observable.create(owner, false);
|
|
let element: HTMLElement;
|
|
|
|
// When element is selected, scroll it into view.
|
|
owner.autoDispose(view.selectedBox.addListener(selectedBox => {
|
|
if (selectedBox === box) {
|
|
element?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
|
|
}
|
|
}));
|
|
|
|
// Default remove icon, can be overriden by props.
|
|
const defaultRemoveButton = () => style.cssRemoveButton(
|
|
icon((props.removeIcon as any) ?? 'RemoveBig'),
|
|
dom.on('click', ev => {
|
|
stopEvent(ev);
|
|
box.view.selectedBox.set(box);
|
|
allCommands.deleteFields.run();
|
|
}),
|
|
props.removeButton === null ? null : hoverTooltip(props.removeTooltip ?? t('Delete')),
|
|
style.cssRemoveButton.cls('-right', props.removePosition === 'right'),
|
|
);
|
|
|
|
const onClick = (ev: MouseEvent) => {
|
|
// Only if the click was in this element.
|
|
const target = ev.target as HTMLElement;
|
|
if (!target.closest) { return; }
|
|
// Make sure that the closest editor is this one.
|
|
const closest = target.closest(`.${style.cssFieldEditor.className}`);
|
|
if (closest !== element) { return; }
|
|
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
props.click?.(ev, props.box);
|
|
|
|
// Mark this box as selected.
|
|
box.view.selectedBox.set(box);
|
|
};
|
|
|
|
const dragAbove = Observable.create(owner, false);
|
|
const dragBelow = Observable.create(owner, false);
|
|
const dragging = Observable.create(owner, false);
|
|
|
|
|
|
return element = style.cssFieldEditor(
|
|
testId('editor'),
|
|
|
|
style.cssFieldEditor.cls('-drag-above', use => use(dragAbove) && use(dragHover)),
|
|
style.cssFieldEditor.cls('-drag-below', use => use(dragBelow) && use(dragHover)),
|
|
|
|
props.drag ?? style.cssDragWrapper(style.cssDrag('DragDrop')),
|
|
style.cssFieldEditor.cls(`-${props.box.type}`),
|
|
|
|
// Turn on active like state when we clicked here.
|
|
style.cssFieldEditor.cls('-selected', box.selected),
|
|
style.cssFieldEditor.cls('-cut', box.cut),
|
|
testId('field-editor-selected', box.selected),
|
|
|
|
// Select on click.
|
|
dom.on('click', onClick),
|
|
|
|
// Attach context menu.
|
|
buildMenu({
|
|
box,
|
|
context: true,
|
|
}),
|
|
|
|
// And now drag and drop support.
|
|
{draggable: "true"},
|
|
|
|
// When started, we just put the box into the dataTransfer as a plain text.
|
|
// TODO: this might be very sofisticated in the future.
|
|
dom.on('dragstart', (ev) => {
|
|
// Prevent propagation, as we might be in a nested editor.
|
|
ev.stopPropagation();
|
|
ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON()));
|
|
ev.dataTransfer!.dropEffect = "move";
|
|
dragging.set(true);
|
|
}),
|
|
|
|
dom.on('dragover', (ev) => {
|
|
// As usual, prevent propagation.
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
ev.stopImmediatePropagation();
|
|
// Here we just change the style of the element.
|
|
ev.dataTransfer!.dropEffect = "move";
|
|
dragHover.set(true);
|
|
|
|
if (dragging.get() || props.box.type === 'Section') { return; }
|
|
|
|
const myHeight = element.offsetHeight;
|
|
const percentHeight = Math.round((ev.offsetY / myHeight) * 100);
|
|
|
|
// If we are in the top half, we want to animate ourselves and transform a little below.
|
|
if (percentHeight < 40) {
|
|
dragAbove.set(true);
|
|
dragBelow.set(false);
|
|
} else if (percentHeight > 60) {
|
|
dragAbove.set(false);
|
|
dragBelow.set(true);
|
|
} else {
|
|
dragAbove.set(false);
|
|
dragBelow.set(false);
|
|
}
|
|
}),
|
|
|
|
dom.on('dragleave', (ev) => {
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
// Just remove the style and stop propagation.
|
|
dragHover.set(false);
|
|
dragAbove.set(false);
|
|
dragBelow.set(false);
|
|
}),
|
|
|
|
dom.on('dragend', () => {
|
|
dragHover.set(false);
|
|
dragAbove.set(false);
|
|
dragBelow.set(false);
|
|
dragging.set(false);
|
|
}),
|
|
|
|
dom.on('drop', async (ev) => {
|
|
stopEvent(ev);
|
|
dragHover.set(false);
|
|
dragging.set(false);
|
|
dragAbove.set(false);
|
|
const wasBelow = dragBelow.get();
|
|
dragBelow.set(false);
|
|
|
|
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
|
|
// We need to remove it from the parent, so find it first.
|
|
const droppedId = dropped.id;
|
|
if (droppedId === box.id) { return; }
|
|
const droppedModel = box.root().get(droppedId);
|
|
// It might happen that parent is dropped into child, so we need to check for that.
|
|
if (droppedModel?.get(box.id)) { return; }
|
|
await box.save(async () => {
|
|
droppedModel?.removeSelf();
|
|
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
|
|
});
|
|
}),
|
|
|
|
style.cssFieldEditor.cls('-drag-hover', dragHover),
|
|
style.cssFieldEditorContent(
|
|
props.content,
|
|
style.cssDrop(),
|
|
),
|
|
testId(box.type),
|
|
testId('element'),
|
|
dom.maybe(overlay, () => style.cssSelectedOverlay()),
|
|
// Custom icons for removing.
|
|
props.removeIcon === null || props.removeButton ? null :
|
|
dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
|
|
props.removeButton ?? null,
|
|
...args,
|
|
);
|
|
}
|