mirror of
https://github.com/codex-team/editor.js
synced 2024-06-09 01:12:28 +02:00
Merge 8d6acaf066
into 77eb320203
This commit is contained in:
commit
14ec0bfba5
|
@ -17,7 +17,7 @@
|
|||
"build": "vite build",
|
||||
"lint": "eslint src/ --ext .ts && yarn lint:tests",
|
||||
"lint:errors": "eslint src/ --ext .ts --quiet",
|
||||
"lint:fix": "eslint src/ --ext .ts --fix",
|
||||
"lint:fix": "eslint src/ --ext .ts --fix && eslint test/ --ext .ts --fix",
|
||||
"lint:tests": "eslint test/ --ext .ts",
|
||||
"pull_tools": "git submodule update --init --recursive",
|
||||
"_tools:checkout": "git submodule foreach \"git checkout master || git checkout main\"",
|
||||
|
|
|
@ -87,6 +87,15 @@ export enum BlockToolAPI {
|
|||
ON_PASTE = 'onPaste',
|
||||
}
|
||||
|
||||
/**
|
||||
* Available block drop zones position w.r.t. focused block.
|
||||
*/
|
||||
export enum BlockDropZonePosition {
|
||||
Top = 'top',
|
||||
Bottom = 'bottom',
|
||||
// @todo - Left, Right could be added in the future
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of events used in Block
|
||||
*/
|
||||
|
@ -114,6 +123,8 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
focused: 'ce-block--focused',
|
||||
selected: 'ce-block--selected',
|
||||
dropTarget: 'ce-block--drop-target',
|
||||
dropTargetTop: 'ce-block--drop-target-top',
|
||||
dropTargetBottom: 'ce-block--drop-target-bottom',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -498,12 +509,41 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Toggle drop target state
|
||||
* Set the drop zone position and update the style to drop zone target style.
|
||||
*
|
||||
* @param {boolean} state - 'true' if block is drop target, false otherwise
|
||||
* @param {boolean | BlockDropZonePosition} state - 'false' if block is not a drop zone or
|
||||
* position of drop zone.
|
||||
*/
|
||||
public set dropTarget(state) {
|
||||
this.holder.classList.toggle(Block.CSS.dropTarget, state);
|
||||
public set dropZonePosition(state: boolean | BlockDropZonePosition) {
|
||||
if (!state || this.selected) {
|
||||
/**
|
||||
* If state is undefined or block is selected for drag
|
||||
* then remove the drop target style
|
||||
*/
|
||||
this.holder.classList.remove(Block.CSS.dropTarget, Block.CSS.dropTargetTop, Block.CSS.dropTargetBottom);
|
||||
} else {
|
||||
/**
|
||||
* Otherwise, toggle the block's drop target and drop zone position.
|
||||
*/
|
||||
this.holder.classList.toggle(Block.CSS.dropTarget, !!state);
|
||||
this.holder.classList.toggle(Block.CSS.dropTargetTop, state === BlockDropZonePosition.Top);
|
||||
this.holder.classList.toggle(Block.CSS.dropTargetBottom, state === BlockDropZonePosition.Bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Block's drop zone position or false if block is not a drop zone.
|
||||
*
|
||||
* @returns {BlockDropZonePosition | boolean}
|
||||
*/
|
||||
public get dropZonePosition(): boolean | BlockDropZonePosition {
|
||||
if (this.holder.classList.contains(Block.CSS.dropTargetTop)) {
|
||||
return BlockDropZonePosition.Top;
|
||||
} else if (this.holder.classList.contains(Block.CSS.dropTargetBottom)) {
|
||||
return BlockDropZonePosition.Bottom;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,6 +5,7 @@ import Module from '../__module';
|
|||
import * as _ from '../utils';
|
||||
import SelectionUtils from '../selection';
|
||||
import Flipper from '../flipper';
|
||||
import { BlockDropZonePosition } from '../block';
|
||||
import type Block from '../block';
|
||||
import { areBlocksMergeable } from '../utils/blocks';
|
||||
|
||||
|
@ -148,25 +149,42 @@ export default class BlockEvents extends Module {
|
|||
}
|
||||
|
||||
/**
|
||||
* Add drop target styles
|
||||
* All drag enter on block
|
||||
* - use to clear previous drop target zone style.
|
||||
*
|
||||
* @param {DragEvent} event - drag over event
|
||||
*/
|
||||
public dragEnter(event: DragEvent): void {
|
||||
const { BlockManager } = this.Editor;
|
||||
const block = BlockManager.getBlockByChildNode(event.target as Node);
|
||||
|
||||
/**
|
||||
* Scroll to make element inside the viewport.
|
||||
*/
|
||||
_.scrollToView(block.holder);
|
||||
|
||||
/**
|
||||
* Clear previous drop target zone for every block.
|
||||
*/
|
||||
BlockManager.clearDropZonePosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* All drag over on block.
|
||||
* - Check the position of drag and suggest drop zone accordingly.
|
||||
*
|
||||
* @param {DragEvent} event - drag over event
|
||||
*/
|
||||
public dragOver(event: DragEvent): void {
|
||||
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
|
||||
const rect = block.holder.getBoundingClientRect();
|
||||
|
||||
block.dropTarget = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove drop target style
|
||||
*
|
||||
* @param {DragEvent} event - drag leave event
|
||||
*/
|
||||
public dragLeave(event: DragEvent): void {
|
||||
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
|
||||
|
||||
block.dropTarget = false;
|
||||
/**
|
||||
* Add style for target drop zone position.
|
||||
*/
|
||||
block.dropZonePosition = (rect.top + rect.height / 2 >= event.clientY) ?
|
||||
BlockDropZonePosition.Top :
|
||||
BlockDropZonePosition.Bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -511,7 +529,7 @@ export default class BlockEvents extends Module {
|
|||
if (this.Editor.BlockManager.currentBlock) {
|
||||
this.Editor.BlockManager.currentBlock.updateCurrentInput();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
}, 20)();
|
||||
}
|
||||
|
||||
|
@ -570,7 +588,7 @@ export default class BlockEvents extends Module {
|
|||
if (this.Editor.BlockManager.currentBlock) {
|
||||
this.Editor.BlockManager.currentBlock.updateCurrentInput();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
}, 20)();
|
||||
}
|
||||
|
||||
|
|
|
@ -680,6 +680,15 @@ export default class BlockManager extends Module {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove drop zone positions from all Blocks.
|
||||
*/
|
||||
public clearDropZonePosition(): void {
|
||||
this.blocks.forEach((block) => {
|
||||
block.dropZonePosition = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) Find first-level Block from passed child Node
|
||||
* 2) Mark it as current
|
||||
|
@ -919,12 +928,12 @@ export default class BlockManager extends Module {
|
|||
BlockEvents.keyup(event);
|
||||
});
|
||||
|
||||
this.readOnlyMutableListeners.on(block.holder, 'dragover', (event: DragEvent) => {
|
||||
BlockEvents.dragOver(event);
|
||||
this.readOnlyMutableListeners.on(block.holder, 'dragenter', (event: DragEvent) => {
|
||||
BlockEvents.dragEnter(event);
|
||||
});
|
||||
|
||||
this.readOnlyMutableListeners.on(block.holder, 'dragleave', (event: DragEvent) => {
|
||||
BlockEvents.dragLeave(event);
|
||||
this.readOnlyMutableListeners.on(block.holder, 'dragover', (event: DragEvent) => {
|
||||
BlockEvents.dragOver(event);
|
||||
});
|
||||
|
||||
block.on('didMutated', (affectedBlock: Block) => {
|
||||
|
|
|
@ -225,7 +225,7 @@ export default class BlockSelection extends Module {
|
|||
* @param {boolean} restoreSelection - if true, restore saved selection
|
||||
*/
|
||||
public clearSelection(reason?: Event, restoreSelection = false): void {
|
||||
const { BlockManager, Caret, RectangleSelection } = this.Editor;
|
||||
const { BlockManager, Caret, RectangleSelection, Toolbar } = this.Editor;
|
||||
|
||||
this.needToSelectAll = false;
|
||||
this.nativeInputSelected = false;
|
||||
|
@ -234,6 +234,16 @@ export default class BlockSelection extends Module {
|
|||
const isKeyboard = reason && (reason instanceof KeyboardEvent);
|
||||
const isPrintableKey = isKeyboard && _.isPrintableKey((reason as KeyboardEvent).keyCode);
|
||||
|
||||
/**
|
||||
* Don't clear the selection during multiple element dragging.
|
||||
*/
|
||||
const isMouse = reason && (reason instanceof MouseEvent);
|
||||
const isClickedOnSettingsToggler = isMouse && Toolbar.nodes.settingsToggler.contains(reason.target as HTMLElement);
|
||||
|
||||
if (isMouse && isClickedOnSettingsToggler) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If reason caused clear of the selection was printable key and any block is selected,
|
||||
* remove selected blocks and insert pressed key
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import SelectionUtils from '../selection';
|
||||
|
||||
import Block, { BlockDropZonePosition } from '../block';
|
||||
import * as _ from '../utils';
|
||||
import $ from '../dom';
|
||||
import Module from '../__module';
|
||||
/**
|
||||
*
|
||||
|
@ -13,6 +15,15 @@ export default class DragNDrop extends Module {
|
|||
*/
|
||||
private isStartedAtEditor = false;
|
||||
|
||||
/**
|
||||
* Flag that identifies if the drag event is started at the editor.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
public get isDragStarted(): boolean {
|
||||
return this.isStartedAtEditor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle read-only state
|
||||
*
|
||||
|
@ -36,14 +47,23 @@ export default class DragNDrop extends Module {
|
|||
* Add drag events listeners to editor zone
|
||||
*/
|
||||
private enableModuleBindings(): void {
|
||||
const { UI } = this.Editor;
|
||||
const { UI, BlockManager } = this.Editor;
|
||||
|
||||
this.readOnlyMutableListeners.on(UI.nodes.holder, 'drop', async (dropEvent: DragEvent) => {
|
||||
await this.processDrop(dropEvent);
|
||||
}, true);
|
||||
|
||||
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragstart', () => {
|
||||
this.processDragStart();
|
||||
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragstart', (dragStartEvent: DragEvent) => {
|
||||
this.processDragStart(dragStartEvent);
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear drop targets if drop effect is none.
|
||||
*/
|
||||
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragend', (dragEndEvent: DragEvent) => {
|
||||
if (dragEndEvent.dataTransfer.dropEffect === 'none') {
|
||||
BlockManager.clearDropZonePosition();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -71,13 +91,17 @@ export default class DragNDrop extends Module {
|
|||
BlockManager,
|
||||
Caret,
|
||||
Paste,
|
||||
BlockSelection,
|
||||
} = this.Editor;
|
||||
|
||||
dropEvent.preventDefault();
|
||||
|
||||
BlockManager.blocks.forEach((block) => {
|
||||
block.dropTarget = false;
|
||||
});
|
||||
/**
|
||||
* If we are dropping a block, process it and return.
|
||||
*/
|
||||
if (this.isStartedAtEditor && BlockSelection.anyBlockSelected) {
|
||||
this.processBlockDrop(dropEvent);
|
||||
}
|
||||
|
||||
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed && this.isStartedAtEditor) {
|
||||
document.execCommand('delete');
|
||||
|
@ -89,28 +113,117 @@ export default class DragNDrop extends Module {
|
|||
* Try to set current block by drop target.
|
||||
* If drop target is not part of the Block, set last Block as current.
|
||||
*/
|
||||
const targetBlock = BlockManager.setCurrentBlockByChildNode(dropEvent.target as Node);
|
||||
const firstLevelBlock = (dropEvent.target as HTMLElement).closest(`.${Block.CSS.wrapper}`);
|
||||
let targetBlock = BlockManager.blocks.find((block) => block.holder === firstLevelBlock);
|
||||
|
||||
let shouldMoveToFirst = false;
|
||||
|
||||
if (targetBlock) {
|
||||
this.Editor.Caret.setToBlock(targetBlock, Caret.positions.END);
|
||||
if (targetBlock.dropZonePosition === BlockDropZonePosition.Top) {
|
||||
const currentIndex = BlockManager.getBlockIndex(targetBlock);
|
||||
let targetIndex;
|
||||
if (currentIndex > 0) {
|
||||
targetIndex = currentIndex - 1;
|
||||
}
|
||||
else {
|
||||
// Paste the block at the end of first block.
|
||||
targetIndex = 0;
|
||||
// then swap the first block with second block.
|
||||
shouldMoveToFirst = true;
|
||||
}
|
||||
targetBlock = BlockManager.getBlockByIndex(targetIndex);
|
||||
}
|
||||
Caret.setToBlock(targetBlock, Caret.positions.END);
|
||||
} else {
|
||||
const lastBlock = BlockManager.setCurrentBlockByChildNode(BlockManager.lastBlock.holder);
|
||||
const firstLevelBlock = (BlockManager.lastBlock.holder as HTMLElement).closest(`.${Block.CSS.wrapper}`);
|
||||
let lastBlock = BlockManager.blocks.find((block) => block.holder === firstLevelBlock);
|
||||
|
||||
this.Editor.Caret.setToBlock(lastBlock, Caret.positions.END);
|
||||
Caret.setToBlock(lastBlock, Caret.positions.END);
|
||||
}
|
||||
|
||||
// Clear drop zones.
|
||||
BlockManager.clearDropZonePosition();
|
||||
// Clear the selection.
|
||||
BlockSelection.clearSelection();
|
||||
|
||||
await Paste.processDataTransfer(dropEvent.dataTransfer, true);
|
||||
|
||||
// swapping of the first block with second block.
|
||||
if (shouldMoveToFirst) {
|
||||
BlockManager.move(1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag start event
|
||||
* Process block drop event.
|
||||
*
|
||||
* @param dropEvent {DragEvent} - drop event
|
||||
*/
|
||||
private processDragStart(): void {
|
||||
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed) {
|
||||
this.isStartedAtEditor = true;
|
||||
}
|
||||
private processBlockDrop(dropEvent: DragEvent): void {
|
||||
const { BlockManager, BlockSelection } = this.Editor;
|
||||
|
||||
this.Editor.InlineToolbar.close();
|
||||
/**
|
||||
* Remove drag image from DOM.
|
||||
*/
|
||||
this.removeDragImage();
|
||||
|
||||
const selectedBlocks = BlockSelection.selectedBlocks;
|
||||
|
||||
const firstLevelBlock = (dropEvent.target as HTMLElement).closest(`.${Block.CSS.wrapper}`);
|
||||
const targetBlock = BlockManager.blocks.find((block) => block.holder === firstLevelBlock);
|
||||
|
||||
if (!targetBlock) {
|
||||
// This means that we are trying to drop a block without references.
|
||||
return;
|
||||
}
|
||||
const targetIndex = BlockManager.getBlockIndex(targetBlock);
|
||||
|
||||
// we are dragging a set of blocks
|
||||
const currentStartIndex = BlockManager.getBlockIndex(selectedBlocks[0]);
|
||||
|
||||
selectedBlocks.forEach((block, i) => {
|
||||
const blockIndex = BlockManager.getBlockIndex(block);
|
||||
|
||||
let toIndex;
|
||||
|
||||
/**
|
||||
* Calculate the index where the block should be moved to.
|
||||
*/
|
||||
if (targetBlock.dropZonePosition === BlockDropZonePosition.Top) {
|
||||
if (targetIndex > currentStartIndex) {
|
||||
toIndex = targetIndex - 1;
|
||||
} else {
|
||||
toIndex = targetIndex + i;
|
||||
}
|
||||
} else if (targetBlock.dropZonePosition === BlockDropZonePosition.Bottom) {
|
||||
if (targetIndex > currentStartIndex) {
|
||||
toIndex = targetIndex;
|
||||
} else {
|
||||
toIndex = targetIndex + 1 + i;
|
||||
}
|
||||
}
|
||||
BlockManager.move(toIndex, blockIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag start event by setting drag image.
|
||||
*
|
||||
* @param dragStartEvent - drag start event
|
||||
*/
|
||||
private processDragStart(dragStartEvent: DragEvent): void {
|
||||
const { BlockSelection } = this.Editor;
|
||||
|
||||
/**
|
||||
* If we are dragging a block, set the flag to true.
|
||||
*/
|
||||
this.isStartedAtEditor = true;
|
||||
|
||||
const selectedBlocks = BlockSelection.selectedBlocks;
|
||||
|
||||
const dragImage = this.createDragImage(selectedBlocks);
|
||||
|
||||
dragStartEvent.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -119,4 +232,40 @@ export default class DragNDrop extends Module {
|
|||
private processDragOver(dragEvent: DragEvent): void {
|
||||
dragEvent.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create drag image for drag-n-drop and add to Editor holder.
|
||||
*
|
||||
* @param blocks {Block[]} - blocks to create drag image for.
|
||||
* @returns {HTMLElement} - drag image.
|
||||
*/
|
||||
private createDragImage(blocks: Block[]): HTMLElement {
|
||||
const { UI } = this.Editor;
|
||||
|
||||
/**
|
||||
* Create a drag image with all blocks content.
|
||||
*/
|
||||
const dragImage: HTMLElement = $.make('div');
|
||||
|
||||
dragImage.id = `drag-image-${_.generateId()}`;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-1000px';
|
||||
|
||||
const clones = blocks.map(block => block.holder.querySelector(`.${Block.CSS.content}`).cloneNode(true));
|
||||
|
||||
dragImage.append(...clones);
|
||||
|
||||
UI.nodes.holder.appendChild(dragImage);
|
||||
|
||||
return dragImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove drag image from Editor holder.
|
||||
*/
|
||||
private removeDragImage(): void {
|
||||
const { UI } = this.Editor;
|
||||
|
||||
UI.nodes.holder.querySelector('[id^="drag-image-"]')?.remove();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,7 +161,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
open: () => void;
|
||||
toggle: () => void;
|
||||
hasFocus: () => boolean | undefined;
|
||||
} {
|
||||
} {
|
||||
return {
|
||||
opened: this.toolboxInstance?.opened,
|
||||
close: () => {
|
||||
|
@ -171,7 +171,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
/**
|
||||
* If Toolbox is not initialized yet, do nothing
|
||||
*/
|
||||
if (this.toolboxInstance === null) {
|
||||
if (this.toolboxInstance === null) {
|
||||
_.log('toolbox.open() called before initialization is finished', 'warn');
|
||||
|
||||
return;
|
||||
|
@ -188,7 +188,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
/**
|
||||
* If Toolbox is not initialized yet, do nothing
|
||||
*/
|
||||
if (this.toolboxInstance === null) {
|
||||
if (this.toolboxInstance === null) {
|
||||
_.log('toolbox.toggle() called before initialization is finished', 'warn');
|
||||
|
||||
return;
|
||||
|
@ -251,7 +251,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
/**
|
||||
* Some UI elements creates inside requestIdleCallback, so the can be not ready yet
|
||||
*/
|
||||
if (this.toolboxInstance === null) {
|
||||
if (this.toolboxInstance === null) {
|
||||
_.log('Can\'t open Toolbar since Editor initialization is not finished yet', 'warn');
|
||||
|
||||
return;
|
||||
|
@ -345,7 +345,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
} else {
|
||||
this.blockActions.hide();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
}, 50)();
|
||||
}
|
||||
|
||||
|
@ -408,6 +408,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
*/
|
||||
this.nodes.settingsToggler = $.make('span', this.CSS.settingsToggler, {
|
||||
innerHTML: IconMenu,
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
$.append(this.nodes.actions, this.nodes.settingsToggler);
|
||||
|
@ -496,9 +497,37 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
/**
|
||||
* Settings toggler
|
||||
*
|
||||
* mousedown is used because on click selection is lost in Safari and FF
|
||||
* dargstart is used to select the current block/s to hide
|
||||
* the tooltip and close Inilne toolbar for dragging.
|
||||
*/
|
||||
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'mousedown', (e) => {
|
||||
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'dragstart', () => {
|
||||
const { BlockManager, BlockSettings, BlockSelection } = this.Editor;
|
||||
|
||||
/** Close components */
|
||||
this.tooltip.hide(true);
|
||||
this.blockActions.hide();
|
||||
this.toolboxInstance.close();
|
||||
BlockSettings.close();
|
||||
|
||||
BlockManager.currentBlock = this.hoveredBlock;
|
||||
BlockSelection.selectBlockByIndex(BlockManager.currentBlockIndex);
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* Settings toggler
|
||||
*
|
||||
* dargend is used to move the select block toolbar setting to dropped position.
|
||||
*/
|
||||
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'dragend', () => {
|
||||
this.moveAndOpen(this.Editor.BlockManager.currentBlock);
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* Settings toggler
|
||||
*
|
||||
* mouseup is used because on click selection is lost in Safari and FF
|
||||
*/
|
||||
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'mouseup', (e) => {
|
||||
/**
|
||||
* Stop propagation to prevent block selection clearance
|
||||
*
|
||||
|
@ -526,9 +555,11 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
*/
|
||||
this.eventsDispatcher.on(BlockHovered, (data) => {
|
||||
/**
|
||||
* Do not move toolbar if Block Settings or Toolbox opened
|
||||
* Do not move toolbar if Block Settings or Toolbox opened or Drag started.
|
||||
*/
|
||||
if (this.Editor.BlockSettings.opened || this.toolboxInstance?.opened) {
|
||||
if (this.Editor.BlockSettings.opened ||
|
||||
this.toolboxInstance?.opened ||
|
||||
this.Editor.DragNDrop.isDragStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -355,13 +355,6 @@ export default class UI extends Module<UINodes> {
|
|||
this.readOnlyMutableListeners.on(this.nodes.redactor, 'mousemove', _.throttle((event: MouseEvent | TouchEvent) => {
|
||||
const hoveredBlock = (event.target as Element).closest('.ce-block');
|
||||
|
||||
/**
|
||||
* Do not trigger 'block-hovered' for cross-block selection
|
||||
*/
|
||||
if (this.Editor.BlockSelection.anyBlockSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hoveredBlock) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -146,7 +146,7 @@ function _log(
|
|||
} else {
|
||||
console[type](msg, ...argsToPass);
|
||||
}
|
||||
} catch (ignored) {}
|
||||
} catch (ignored) { }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -316,9 +316,9 @@ export function isPrintableKey(keyCode: number): boolean {
|
|||
export async function sequence(
|
||||
chains: ChainData[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
success: (data: object) => void = (): void => {},
|
||||
success: (data: object) => void = (): void => { },
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
fallback: (data: object) => void = (): void => {}
|
||||
fallback: (data: object) => void = (): void => { }
|
||||
): Promise<void> {
|
||||
/**
|
||||
* Decorator
|
||||
|
@ -450,7 +450,7 @@ export function debounce(func: (...args: unknown[]) => void, wait?: number, imme
|
|||
* but if you'd like to disable the execution on the leading edge, pass
|
||||
* `{leading: false}`. To disable execution on the trailing edge, ditto.
|
||||
*/
|
||||
export function throttle(func, wait, options: {leading?: boolean; trailing?: boolean} = undefined): () => void {
|
||||
export function throttle(func, wait, options: { leading?: boolean; trailing?: boolean } = undefined): () => void {
|
||||
let context, args, result;
|
||||
let timeout = null;
|
||||
let previous = 0;
|
||||
|
@ -530,7 +530,7 @@ export function copyTextToClipboard(text): void {
|
|||
/**
|
||||
* Returns object with os name as key and boolean as value. Shows current user OS
|
||||
*/
|
||||
export function getUserOS(): {[key: string]: boolean} {
|
||||
export function getUserOS(): { [key: string]: boolean } {
|
||||
const OS = {
|
||||
win: false,
|
||||
mac: false,
|
||||
|
@ -788,3 +788,35 @@ export function equals(var1: unknown, var2: unknown): boolean {
|
|||
|
||||
return var1 === var2;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scrolls the viewport to bring the specified element into view.
|
||||
*
|
||||
* @param {HTMLElement} elem - The element to scroll to.
|
||||
*/
|
||||
export function scrollToView(elem: HTMLElement):void {
|
||||
// Get the target element and its bounding rectangle
|
||||
const targetRect = elem.getBoundingClientRect();
|
||||
|
||||
// Get the size of the viewport
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
// Check if the target element is within the viewport
|
||||
const isTargetInViewport = (
|
||||
targetRect.top >= 0 &&
|
||||
targetRect.left >= 0 &&
|
||||
targetRect.bottom <= viewportHeight &&
|
||||
targetRect.right <= viewportWidth
|
||||
);
|
||||
|
||||
// Scroll the page if the target element is not within the viewport
|
||||
if (!isTargetInViewport) {
|
||||
window.scrollTo({
|
||||
top: targetRect.top,
|
||||
left: targetRect.left,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
|
@ -57,6 +57,7 @@
|
|||
border-width: 1px 1px 0 0;
|
||||
transform-origin: right;
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
|
@ -73,6 +74,17 @@
|
|||
#fff 1px,
|
||||
#fff 6px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--drop-target-top &__content {
|
||||
&:before {
|
||||
top: 0%;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
478
test/cypress/tests/dragnDrop.cy.ts
Normal file
478
test/cypress/tests/dragnDrop.cy.ts
Normal file
|
@ -0,0 +1,478 @@
|
|||
import Image from '@editorjs/simple-image';
|
||||
import * as _ from '../../../src/components/utils';
|
||||
import type EditorJS from '../../../../types/index';
|
||||
|
||||
|
||||
describe('Drag and drop the block of Editor', function () {
|
||||
beforeEach(function () {
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
image: Image,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Block 0',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Block 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Block 2',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (this.editorInstance) {
|
||||
this.editorInstance.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Create URI
|
||||
const base64Image = "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
|
||||
const uri = 'data:image/png;base64,' + base64Image;
|
||||
|
||||
// Define the file to be dropped
|
||||
const fileName = 'codex2x.png';
|
||||
const fileType = 'image/png';
|
||||
|
||||
|
||||
// Convert base64 to Blob
|
||||
const blob = Cypress.Blob.base64StringToBlob(base64Image);
|
||||
|
||||
const file = new File([blob], fileName, { type: fileType });
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// add the file to the DataTransfer object
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
/**
|
||||
* @todo check with dropping file other than the image.
|
||||
*/
|
||||
it('should drop image before the block', function () {
|
||||
|
||||
// Test by dropping the image.
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(1)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = -20;
|
||||
// Dragover on target block little bit above the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(1)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.trigger('drop', { dataTransfer });
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
// In Edge test are performed slower, so we need to
|
||||
// increase timeout to wait until image is loaded on the page
|
||||
.get('div.ce-block')
|
||||
.eq(1)
|
||||
.find('img', { timeout: 10000 })
|
||||
.should('have.attr', 'src', uri);
|
||||
});
|
||||
|
||||
it('should drop image after the block', function () {
|
||||
|
||||
// Test by dropping the image.
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(1)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = 20;
|
||||
// Dragover on target block little bit below the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(1)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.trigger('drop', { dataTransfer });
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
// In Edge test are performed slower, so we need to
|
||||
// increase timeout to wait until image is loaded on the page
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.find('img', { timeout: 10000 })
|
||||
.should('have.attr', 'src', uri);
|
||||
});
|
||||
|
||||
it('should drop image before the first block', function () {
|
||||
// Test by dropping the image.
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.first()
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = -20;
|
||||
// Dragover on target block little bit above the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(0)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.trigger('drop', { dataTransfer });
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
// In Edge test are performed slower, so we need to
|
||||
// increase timeout to wait until image is loaded on the page
|
||||
.eq(0)
|
||||
.find('img', { timeout: 10000 })
|
||||
.should('have.attr', 'src', uri);
|
||||
});
|
||||
|
||||
it('should have block dragover style on the top of target block', function () {
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.first()
|
||||
.click()
|
||||
.click();
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = -20;
|
||||
// Dragover on target block little bit above the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.then(($element) => {
|
||||
// check for dragover top style on target element.
|
||||
const classes = $element.attr('class').split(' ');
|
||||
|
||||
expect(classes).to.include('ce-block--drop-target');
|
||||
expect(classes).to.include('ce-block--drop-target-top');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have block dragover style on the bottom of target block', function () {
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.first()
|
||||
.click()
|
||||
.click();
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = 20;
|
||||
// Dragover on target block little bit below the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.then(($element) => {
|
||||
// check for dragover top style on target element.
|
||||
const classes = $element.attr('class').split(' ');
|
||||
|
||||
expect(classes).to.include('ce-block--drop-target');
|
||||
expect(classes).to.include('ce-block--drop-target-bottom');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should drag the first block and drop after the last block.', function () {
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.first()
|
||||
.click()
|
||||
.click();
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = 20;
|
||||
// Dragover on target block little bit below the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.trigger('drop', { dataTransfer });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(3); // 3 blocks are still here
|
||||
expect(blocks[0].data.text).to.eq('Block 1');
|
||||
expect(blocks[1].data.text).to.eq('Block 2');
|
||||
expect(blocks[2].data.text).to.eq('Block 0');
|
||||
});
|
||||
});
|
||||
|
||||
it('should drag the last block and drop before the first block.', function () {
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.click();
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(0)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = -20;
|
||||
// Dragover on target block little bit above the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(0)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.trigger('drop', { dataTransfer });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(3); // 3 blocks are still here
|
||||
expect(blocks[0].data.text).to.eq('Block 2');
|
||||
expect(blocks[1].data.text).to.eq('Block 0');
|
||||
expect(blocks[2].data.text).to.eq('Block 1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should drag the first two block and drop after the last block.', function () {
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(0)
|
||||
.type('{selectall}')
|
||||
.trigger('keydown', {
|
||||
shiftKey: true,
|
||||
keyCode: _.keyCodes.DOWN,
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(1)
|
||||
.trigger('mouseenter')
|
||||
.trigger('mousemove')
|
||||
.trigger('mouseleave');
|
||||
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = 20;
|
||||
// Dragover on target block little bit below the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.trigger('drop', { dataTransfer });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(3); // 3 blocks are still here
|
||||
expect(blocks[0].data.text).to.eq('Block 2');
|
||||
expect(blocks[1].data.text).to.eq('Block 0');
|
||||
expect(blocks[2].data.text).to.eq('Block 1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should drag the last two block and drop before the first block.', function () {
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(1)
|
||||
.type('{selectall}')
|
||||
.trigger('keydown', {
|
||||
shiftKey: true,
|
||||
keyCode: _.keyCodes.DOWN,
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(2)
|
||||
.trigger('mouseenter')
|
||||
.trigger('mousemove')
|
||||
.trigger('mouseleave');
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(0)
|
||||
.trigger('dragenter')
|
||||
.then((blocks) => {
|
||||
// Get the target block rect.
|
||||
const targetBlockRect = blocks[0].getBoundingClientRect();
|
||||
const yShiftFromMiddleLine = -20;
|
||||
// Dragover on target block little bit above the middle line.
|
||||
const dragOverYCoord =
|
||||
targetBlockRect.y +
|
||||
(targetBlockRect.height / 2 + yShiftFromMiddleLine);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.eq(0)
|
||||
.trigger('dragover', {
|
||||
clientX: targetBlockRect.x,
|
||||
clientY: dragOverYCoord,
|
||||
})
|
||||
.trigger('drop', { dataTransfer });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line cypress/require-data-selectors
|
||||
cy.get('.ce-toolbar__settings-btn')
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(3); // 3 blocks are still here
|
||||
expect(blocks[0].data.text).to.eq('Block 1');
|
||||
expect(blocks[1].data.text).to.eq('Block 2');
|
||||
expect(blocks[2].data.text).to.eq('Block 0');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue