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, /** 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, } export function buildEditor(props: Props, ...args: IDomArgs) { 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, ); }