Drag'n'drop support (#445)

* moving caret initial

* small improvements

* last changes, added docs

* requested changes

* implement getters instead of functions in block cursors

* last requested changes

* caret module docs and last improvements

* update docs

* upgrade request

* update docs

* upd

* todo on delays

* Sanitizer docs

* split func upd

* split blocks update

* up docs

* Listeners Module: initial

* listener module updates

* split is ready

* update

* start to make merge

* upd

* split general upd

split is ready

* ups

* keyboard module update

* BlockManager removed keyboard handler

* commit before merging rewriting2.0

* general upd split

* Documentation upd

* document + listener upd

* upd doc

* documentation upd

* doc upd

* listener upd

* update algh extract fragm

* upd extractRangeContent

* upd dom.js

* keyboard upd (shift + enter & enableLineBreaks)

* upd enter pressed

* keyboard.js upd

* enter pressed upd

* documenation added

* documentation upd

* Toolbar: settings zone added. (#252)

* Toolbar: settings zone added.

* update some comments

* Making a Toolbar

* delete block

* dom improvements and merging blocks

* merge and split improvements

* fix merging

* do not remove block if block contains media

* optimize code

* caret behaviour improved

* up

* up

* merging blocks. Now plugins handles this cases

* mergeable getter

* save

* up

* dom getdeepestnode improvements

* improve getDeepest node method one more time

* upd

* Deal with it

* improve isAtStart

* improve docs

* use smart isAtStart and isAtEnt method in navigateNext/navigatePrevious

* improve docs

* fix bug in $.isEmpty, improve keydown

* fix isAtEnd

* rollback setCaret code duplication

* improve backspace

* Debug tree walker

* fix tree walker

* small caret fix

* queue ordering

* update bundle

* improve first letter checkup

* doc upd

* update current block index setter

* TypeScript support, Webpack 4, Inline Toolbar beginning (#257)

* Create UI

* Support TypeScript Modules

* remove tmp files

* migrate to 2-spaced tabs

* Add TS Linter

* Inline Toolbar moving (#258)

* Inline Toolbar moving

* simplify code

* Check is need to show Inline Toolbar

* remove duplicate from doc

* fix doc

* open/close IT

* Close IT by clicks on Redactor

* @guryn going strange

Co-Authored-By: Taly <vitalik7tv@yandex.ru>

* default settings initial

* add move up button to default tunes area

* need to figure out with assets

* Inline Toolbar Tools base example (#260)

* Inline Toolbar Tools base example

* texts fixed

* imrpove texts

* little fixes

* save

* tunes with interface

* add tool settings

* initial api methods

* api is ready

* started writing docs

* Create svg sprite (#261)

* API

* requested changes

* fix conflicts

* add docs

* doc fixes and interface improvements

* update

* API scopes improved

* Deleting block: Initial

* Delete block with confirmation

* Event subscription&unsubscription

* deletion trigger improvements

* small improvements

* Link Inline Tool (#264)

* Link Inline Tool

* api injected

* text improved

* Clear input on the reselection

* little improvements

* Delete tune fixes

* UI: Block Settings, show Plus after Enter keydown (#265)

* Some UI improvements: icons settigns

* Show plus button after split

* decrease autoprefixer

* rename variable

* Revert "Merge branch 'delete-tune-fixes' into rewriting-version2.0"

This reverts commit 779bf5db9e, reversing
changes made to 972eb87d89.

* Delete Tune improvements

* upd

* upd comments

* actualize API docs

* Allow to connect external Inline Tools (#269)

* Allow to connect external Inline Tools

* unhardcode tool's api settings

* Italic inline tool

* update icon size

* upgrade findParentTag function

* add interface selection

* fix cs

* save marker

* bundles

* add todo

* removing wrapper

* update styles

* market -> term

* add comments

* improve code

* descrease margin

* add text block to example

* add line brakes

* remove space

* fix bugs

* fix bug

* umd as a library target

* background -> background-color

if `background` has only `color` then use `background-color`

* Clear API (#274)

* blockManager.clear

* upd

* api bez ebanoj knopki api

* fix assignment

* insert empty block with clear method

* clear and render methods improved

* open saver.save()

* add comments

* update comments

* fix data returned by editor

* rename plugin name field in data object (#276)

* Text tool refactored (#277)

Now it returns strict data format.

* do not add block if tool is not exist (#278)

* do not add block if tool is not exist

* show warning

* add todo

* update warning message

* put message into variable

* Revert "put message into variable"

This reverts commit c1f63894d7.

* update comment

* Module Keyboard rewrited to BlockEvents (#279)

* Module Keydown rewrited to BlockEvents

* move keyup and mouseup to the Block Events

* Move-up tune (#268)

Co-authored-by: khaydarov <murod.haydarov@gmail.com>

* Move up tune initial
* move up tune initial behaviour
* moving up formula, docs and code cleaned
* do not close the toolbar if moving block up
* move nagivate methods to Caret Module
* navigations returns boolean if caret is set
* code improved
* update comments
* disable tune when block index is zero
* provide API with listener module methods
* caret improvements
* add to docs
* update docs and blocks api methods
* docs changes
* small improvements
* swap instead of moves
* update
* fix swap
* remove unused method
* remove useless return value from setToBlock
* improve caret isAtEnd and isAtStart
* improve swap, fix listeners api@off method

* Eslint --fix for project files (#280)

* Header plugin (#281)

* header initial

* fix styles

* eslint fix

* add appendCallback

* add comments

* update styles

* add svgs

* highlight settings buttons

* do not show text plugin in the toolbar

* remove svg

* Fixing caret behaviour. (#282)

Plugins can change their state so that affect on Block's pluginsContent property which is in memory.

* remove useless code

* fix merge

* "MoveDown" tune (#283)

* move down initial

Swap current block with next block and scroll window screen

* check if block is last

added new method to the blocks API that returns blocks count

* fix comments

* animate tune

* add animation when tune is disabled

* requested changes

* remove unused css

* Fix merge function and rename Block's wrapper (#284)

* Fix merge function and rename wrapper

* update

* renew condition

* update

* upd

* Merging blocks: Restore caret position 🤟🤟💪 (#286)

* Merging blocks: Restore caret position 🤟🤟💪

* requested changes

* update removing shadow caret

* hide toolbar and selection on typing (#289)

closes #288

* Editor Instance config Interface (#285)

* create interface for editor config

* use IEditorConfig

* create some interfaces

* add comments

* editor interface

* updates

* update editor interface (#293)

* При перемещении по стрелочкам убирать выделение блока (#296)

* При перемещении по стрелочкам убирать выделение блока

* Add comments

* update comments

* update comment

* update toolbar design (#301)

* Set caret at the end if clicked outsite the block (#305)

* Set caret at last block or create new block at end

* update comment

* fix comments

* Insert new Block when enter pressed on editor area (#307)

* insert new block when enter pressed on editor zone

* extra conditions. Enter must be handled on editors area

* move at editor condition to the Selection method

* closes can return null

* fixing editor area

* do not create new block

* clean example

* updates due to the requested changes

* Add placeholder to contentEditable elements (#306)

* add placeholder to contentEditable elements

* store selection color in a variable

* add placeholder to header block

* Add placeholder to contenteditable only if attribute data-placeholder exists

* remove tool config

* Close toolbar after block is removed (#314)

* makeSettings -> renderSettings (#315)

* Term: new icon, new style. + margin between settings buttons (#316)

Resolves #309

* Tool's renderToolboxIcon function  (#318)

* create renderToolboxIcon

issue was not done

* remove useless span wrapper

* update linters

* update styles

* process click on svg by closest

* remove commented code

* rename function: svgIcon -> toolboxIcon

* add toolboxIcon to docs

* Paste (#259)

* Paste module

* Rewrite paste logic

* Update due comments

* Docs

* Add all block elements

* Sanitize content on paste

* Remove logs and add header handler

* Add comment to dom.js

* Add comment to tools.js

* Split block if paste not at the end

* Update docs

* Update docs

* pidr

* Take Tool name from config

* Update docs

* Label onPaste handler as private

* Resolve conflict

* Replace current block if it is empty (#320)

* Improve Header line-height (#321)

* Fix typo (#324)

* CodeX Editor 2.0

Co-authored-by: Murod Khaydarov <murod.haydarov@gmail.com>
Co-authored-by: Petr Savchenko <specc.dev@gmail.com>
Co-authored-by: George Berezhnoy <gohabereg@gmail.com>
Co-authored-by: Taly Guryn <vitalik7tv@yandex.ru>

* Fix typo

* Installation docs (#325)

close #310

* Module Shortcuts (#317)

* import shortcuts

* node modules works

* enable shortcut for inline tools

* check shortcut existance

* enable shortcuts to Block tools

* set caret to block if block replaced

* enable shortcuts on inline-tools

* improve code

* last changes

* update

* fix

* insert returns block so we can set caret

* use Map instead own structure

* disable shortcut if iniline-toolbar disabled

* update code styles

* remove todo

* upd

* update

* remove settings from insert function

* create interface

* code improvements

* use const instead of let

* upd

* Simple Image Tool (#326)

* Simple Image

* fix pattern

* show tunes` state

* update code

* update code

* upd

* Fix toolbox appearance, tools boxed are clickable (#331)

* Remove toolsConfig from Editor's config (#327)

* fix linters

* remove toolsConfig

* update tool's interfaces

* add comments

* bundles

* remove test headers

* restore commented code

* update tool's interface

* toolConfig -> toolSetting

* fix typos

toolSetting -> toolSettings
toolsSettings -> toolSettings
toolClasses -> toolsClasses

* update code comments

* update installation doc

* update docs

* update dev dep packages (#333)

* Check is paste handler a function only if it exists (#328)

* Toolbar with tab (#330)

* toolbar tabs initial

* leaf initial

* save state

* flip back toolbox items

* enter on toolbox item

* update

* requested changes

* new condfition

* update

* improve animation on leaf

* fix shift+tab flip

* up

* update

* updates

* Consecutive blank lines are forbidden

* Correct choosing next toolbox item

* update

* update comment

* Validate editor's config before initing (#341)

* validate editor's config before initing

* update readme

* @

* update comments

* add function _.isClass

* Styles API (#343)

* StylesAPI

* use styles api in plugins

* add inline styles

* List Tool [new] (#344)

* list tool initial

* list class with settings

* make tool reactive

* final List improvements

* reorder

* tmp update

* unhadrcode enter handler

* updates

* enableLineBreaks also checks

* skip empty items

* select LI by CMD+A, fix backspace in the last item

* improve check for emptiness

* Example page improved (#347)

* Update new example

* imrpove example.html

* updates

* improve code

* Header plugin (#348)

* isFunction function

* use header from cdn

* Improve paste behaviour (#346)

* Improve paste behaviour

* Done

* Don't pass empty items

* Update comment

* move public up

* Remove disallow paste option

* Quote Tool (#329)

* Quote Tool

* Add icon

* Upd

* fix ENTER on quote

* Remove useless code

* items -> blocks (#351)

* use SimpleImage from cdn (#355)

* use simpleimage from cdn

* add comments

* fix spaces

* fix comments

* remove comments

* update simple-image script

* Update text on the example.html (#356)

* use Paragraph Tool from CDN (#357)

* use Paragraph Tool from CDN

* add line brakes

* rename block: paragraph -> text

* Remove _callbacks.js (#358)

* Clear unused files (#359)

* TOOLBAR_ICON_CLASS -> TOOLBAR_ICON (#360)

* TOOLBAR_ICON_CLASS -> TOOLBAR_ICON

* remove defaultConfig

* Delimiter tool (#362)

* Delimiter added

* ашч

* use delimiter from cdn

* Enter on editor (#363)

* Enterpress on editor

* use additional property

* check enter on body

* update

* fix toolbar behaviour

* upd

* update bundle

* remove useless ui property

* update comment

* add Element.prepend() function (#365)

* add Element.prepend() function

* allow to pass element of array to prepend() polyfill

* use List Tool from cdn (#366)

* use List Tool from cdn

* add missing </div> in example.html

* Pass "config" from Tool's settings to Tool's constructor (#367)

* pass config from Tool's settings to Tool's constructor

* reorder elements

* add apiSettings.CONFIG property

* use string as a object's key 😔 (#368)

* update placeholder's styles (#369)

* Add shortcuts for internal tools (#370)

* Add shortcuts for internal tools

* upd doc

* remove articles

* ☹️ guryn asked

* use quote from cdn (#371)

* Add cache to the inline tools (#372)

* use Inline Code Tool from cdn (#375)

* Issue 354 inline tools filter (#376)

* Allow to filter inline tools.

* update example

* update header

* fix endless cycle (#378)

* Destructured options for Inline Tools (#379)

* add destructured options for inline-tools

* temporary disable inlineCode

* remove term sources

* Fix toolbar moving after arrow navigation (#380)

* Fix toolbar moving after arrow navigation

Resolves #361

* move before open

* fix error on Enter after block removing

* add example Tools as submodules (#381)

* add destructured options for inline-tools

* temporary disable inlineCode

* remove term sources

* add inline code Tool as a submodule

* update version of inline-code package

* add Tools as submodules

* update Tools and use destructured params object

* Add constructor() docs

* update package Paragraph Tool

* update installation docs

* Input navigation (#339)

* Quote Tool

* Add icon

* Upd

* Initial setup

* Save changes

* Add scroll and fix input focus

* Add comments

* Rebuild bundle

* fix ENTER on quote

* Fix split behaviour

* Fix

* Navigate only to contentful blocks

* add comments

* Fix backspace on last block

* Remove log

* It works

* Resolve comments

* Use constants

* New readme 🦅 (#386)

* New readme 🦅

* upd text

* Issue 374 (#385)

* Fix issue #374

* Set current block index to -1 if there is no blocks left

* Insert new block if first one was removed by default

* Paragraph as a default Tool in editor; Zero-conf (#389)

* git commit -m "Removed submodule Paragraph"

* add paragraph to core

+ zero-config

* update bundle

* update comment

* remove log

* enable minifying (#390)

* Drop current block index only if there is no selection at the Editor (#388)

* Drop current block index only if there is no selection at the Editor

* Set current node if click ended out of editor

* Small backspace behaviour improvement (#391)

* Small backspace behaviour improvement

* fix caret position

* update from base branch

* Update webpack config

* Migrate to Yarn (#393)

* Migrate to yarn

* Update scripts

* Rewrite helpers classes to TypeScript (#396)

* Add docs and isReady promise (#394)

* set default holderId value (#404)

* Destroyer (#392)

* Initial destroy method

* Add destroy method to api docs

* Export isReady promise in CodexEditor constructor

* Add drag'n'drop support

* Add docs

* Small fix

* Continue merging master :|

* Fixes after merge

* Add styles and improve behavior

* Close inline toolbar and remove dragged content

* Some improvements of paste data processing

* Delete selection content only if drag has been started at editor

* Add comments

* Support of pasting from copy-buffer

* Change header level

* Improvements

* Use isDraNDrop flag

* Update example

* Improve d'n'd behaviour

* git pull for simple-image tool
This commit is contained in:
George Berezhnoy 2018-09-27 21:11:30 +03:00 committed by GitHub
parent cdf6d973c1
commit 577da8fa22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 777 additions and 321 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,14 +4,14 @@ CodeX Editor is a block-oriented editor. It means that entry composed with the l
`Tool` — is a class that provide custom `Block` type. All Tools represented by `Plugins`.
Each Tool should have an installation guide.
Each Tool should have an installation guide.
## Tool class structure
### constructor()
Each Tool's instance called with an params object.
| Param | Type | Description |
| ------ | ------------------- | ----------------------------------------------- |
| api | [`IAPI`][iapi-link] | CodeX Editor's API methods |
@ -62,7 +62,7 @@ Options that Tool can specify. All settings should be passed as static propertie
### User configuration
All Tools can be configured by users. You can set up some of available settings along with Tool's class
All Tools can be configured by users. You can set up some of available settings along with Tool's class
to the `tools` property of Editor Config.
```javascript
@ -103,7 +103,7 @@ To provide paste handling for your Tool you need to define static getter `onPast
##### HTML tags handling
To handle pasted HTML elements object returned from `onPaste` getter should contain following fields:
To handle pasted HTML elements object returned from `onPaste` getter should contain following fields:
| Name | Type | Description |
| -- | -- | -- |
@ -140,7 +140,7 @@ Your Tool can analyze text by RegExp patterns to substitute pasted string with d
| `patterns` | `Object` | _Optional_. `patterns` object contains RegExp patterns with their names as object's keys |
| `patternHandler(text: string, key: string)` | `Function` | _Optional_. Gets pasted string and pattern name. Should return the same object as Tool `save` method |
**Note** Editor will check pattern's full match, so don't forget to handle all available chars in there.
**Note** Editor will check pattern's full match, so don't forget to handle all available chars in there.
Pattern will be processed only if paste was on `initialBlock` Tool and pasted string length is less than 450 characters.
@ -156,7 +156,7 @@ static get onPaste() {
},
patternHandler: (text, key) => {
const urlData = Youtube.onPaste.patterns[key].exec(text);
return {
iframe: Youtube.makeEmbededFromURL(urlData)
};
@ -167,4 +167,41 @@ static get onPaste() {
> Both `onPaste.handler` and `onPaste.patternHandler` can be `async` or return a `Promise`.
##### Files
Your Tool can handle files pasted or dropped into the Editor.
To handle file you should provide `files` and `fileHandler` properties in your `onPaste` configuration object.
`fileHandler` property should be a function which takes File object as an argument and returns the same object as Tool\`s `save` method.
`file` property is an object with the following fields:
| Name | Type | Description |
| ---- | ---- | ----------- |
| `extensions` | `string[]` | _Optional_ Array of extensions your Tool can handle |
| `mimeTypes` | `sring[]` | _Optional_ Array of MIME types your Tool can handle |
Example
```javascript
static get onPaste() {
return {
files: {
mimeTypes: ['image/png'],
extensions: ['json']
},
fileHandler: (file) => {
/* do smth with the file */
return {
data // Some extracted content
}
}
}
}
```
### Sanitize

@ -1 +1 @@
Subproject commit b3ba0e5de15bed3d4354b4bf23f63158bd4167dd
Subproject commit 94389715bd0b33d2ae1a1aa715176e460ef3216f

View file

@ -32,12 +32,13 @@ export default class Block {
* CSS classes for the Block
* @return {{wrapper: string, content: string}}
*/
static get CSS(): {wrapper: string, wrapperStretched: string, content: string, selected: string} {
static get CSS(): {wrapper: string, wrapperStretched: string, content: string, selected: string, dropTarget: string} {
return {
wrapper: 'ce-block',
wrapperStretched: 'ce-block--stretched',
content: 'ce-block__content',
selected: 'ce-block--selected',
dropTarget: 'ce-block--drop-target',
};
}
@ -363,6 +364,14 @@ export default class Block {
return tunesElement;
}
/**
* Toggle drop target state
* @param {boolean} state
*/
public set dropTarget(state) {
this.holder.classList.toggle(Block.CSS.dropTarget, state);
}
/**
* Make default Block wrappers and put Tool`s content there
* @returns {HTMLDivElement}

View file

@ -262,7 +262,7 @@ export default class Core {
* @return {Promise}
*/
async start() {
const modulesToPrepare = ['Tools', 'UI', 'BlockManager', 'Paste', 'ModificationsObserver'];
const modulesToPrepare = ['Tools', 'UI', 'BlockManager', 'Paste', 'DragNDrop', 'ModificationsObserver'];
await modulesToPrepare.reduce(
(promise, module) => promise.then(async () => {

View file

@ -120,6 +120,28 @@ export default class BlockEvents extends Module {
}
}
/**
* Add drop target styles
*
* @param {DragEvent} e
*/
public dragOver(e: DragEvent) {
const block = this.Editor.BlockManager.getBlockByChildNode(e.target);
block.dropTarget = true;
}
/**
* Remove drop target style
*
* @param {DragEvent} e
*/
public dragLeave(e: DragEvent) {
const block = this.Editor.BlockManager.getBlockByChildNode(e.target);
block.dropTarget = false;
}
/**
* ENTER pressed on block
* @param {KeyboardEvent} event - keydown

View file

@ -103,6 +103,8 @@ export default class BlockManager extends Module {
this.Editor.Listeners.on(block.holder, 'keydown', (event) => this.Editor.BlockEvents.keydown(event), true);
this.Editor.Listeners.on(block.holder, 'mouseup', (event) => this.Editor.BlockEvents.mouseUp(event));
this.Editor.Listeners.on(block.holder, 'keyup', (event) => this.Editor.BlockEvents.keyup(event));
this.Editor.Listeners.on(block.holder, 'dragover', (event) => this.Editor.BlockEvents.dragOver(event));
this.Editor.Listeners.on(block.holder, 'dragleave', (event) => this.Editor.BlockEvents.dragLeave(event));
}
/**
@ -362,9 +364,10 @@ export default class BlockManager extends Module {
* 2) Mark it as current
*
* @param {Element|Text} childNode - look ahead from this node.
* @param {string} caretPosition - position where to set caret
* @throws Error - when passed Node is not included at the Block
*/
setCurrentBlockByChildNode(childNode) {
setCurrentBlockByChildNode(childNode, caretPosition = 'default') {
/**
* If node is Text TextNode
*/
@ -372,7 +375,7 @@ export default class BlockManager extends Module {
childNode = childNode.parentNode;
}
let parentFirstLevelBlock = childNode.closest(`.${Block.CSS.wrapper}`);
const parentFirstLevelBlock = childNode.closest(`.${Block.CSS.wrapper}`);
if (parentFirstLevelBlock) {
/**
@ -381,12 +384,31 @@ export default class BlockManager extends Module {
*/
this.currentBlockIndex = this._blocks.nodes.indexOf(parentFirstLevelBlock);
this.Editor.Caret.setToInput(childNode);
this.Editor.Caret.setToInput(childNode, caretPosition);
} else {
throw new Error('Can not find a Block from this child Node');
}
}
/**
* Return block which contents passed node
*
* @param {Node} childNode
* @return {Block}
*/
getBlockByChildNode(childNode) {
/**
* If node is Text TextNode
*/
if (!$.isElement(childNode)) {
childNode = childNode.parentNode;
}
const firstLevelBlock = childNode.closest(`.${Block.CSS.wrapper}`);
return this.blocks.find(block => block.holder === firstLevelBlock);
}
/**
* Swap Blocks Position
* @param {Number} fromIndex

View file

@ -0,0 +1,77 @@
import SelectionUtils from '../selection';
declare var Module: any;
export default class DragNDrop extends Module {
/**
* If drag has been started at editor, we save it
*
* @type Boolean
* @private
*/
private isStartedAtEditor = false;
/**
* Bind events
*
* @private
*/
public prepare(): void {
this.bindEvents();
}
/**
* Add drag events listeners to editor zone
* @private
*/
private bindEvents(): void {
this.Editor.Listeners.on(this.Editor.UI.nodes.holder, 'drop', this.processDrop, true);
this.Editor.Listeners.on(this.Editor.UI.nodes.holder, 'dragstart', (dragEvent: DragEvent) => {
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed) {
this.isStartedAtEditor = true;
}
this.Editor.InlineToolbar.close();
});
/* Prevent default browser behavior to allow drop on non-contenteditable elements */
this.Editor.Listeners.on(this.Editor.UI.nodes.holder, 'dragover', (e) => e.preventDefault(), true);
}
/**
* Handle drop event
*
* @param {DragEvent} dropEvent
*/
private processDrop = async (dropEvent: DragEvent): Promise<void> => {
const {
BlockManager,
Paste,
} = this.Editor;
dropEvent.preventDefault();
BlockManager.blocks.forEach((block) => block.dropTarget = false);
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed && this.isStartedAtEditor) {
document.execCommand('delete');
}
this.isStartedAtEditor = false;
/**
* Try to set current block by drop target.
* If drop target (error will be thrown) is not part of the Block, set last Block as current.
*/
try {
BlockManager.setCurrentBlockByChildNode(dropEvent.target, 'end');
} catch (e) {
BlockManager.setCurrentBlockByChildNode(BlockManager.lastBlock.holder, 'end');
}
Paste.processDataTransfer(dropEvent.dataTransfer, true);
}
}

View file

@ -1,6 +1,7 @@
import IBlockToolData from '../interfaces/tools/block-tool';
import IEditorConfig from '../interfaces/editor-config';
import CaretClass from './caret';
import SelectionUtils from '../selection';
declare const Module: any;
declare const $: any;
@ -32,6 +33,19 @@ interface IPatternSubstitute {
tool: string;
}
/**
* Files` types substitutions object.
*
* @param {string[]} extensions - array of extenstions Tool can handle
* @param {string[]} mimeTypes - array of MIME types Tool can handle
* @param {Function} handler - callback to handle pasted File
*/
interface IFilesSubstitution {
extensions: string[];
mimeTypes: string[];
handler: (file: File) => IBlockToolData;
}
/**
* Processed paste data object.
*
@ -46,6 +60,19 @@ interface IPasteData {
isBlock: boolean;
handler: (content: HTMLElement|string, patten?: RegExp) => IBlockToolData;
}
/**
* Tool onPaste configuration object
*/
interface IPasteConfig {
tags?: string[];
handler?: (element: HTMLElement) => IBlockToolData;
patterns?: {[key: string]: RegExp};
patternHandler?: (text: string, key: string) => IBlockToolData;
files?: {extensions?: string[], mimeTypes?: string[]};
fileHandler?: (file: File) => IBlockToolData;
}
/**
* @class Paste
* @classdesc Contains methods to handle paste on editor
@ -67,6 +94,11 @@ export default class Paste extends Module {
/** Patterns` substitutions parameters */
private toolsPatterns: IPatternSubstitute[] = [];
/** Files` substitutions parameters */
private toolsFiles: {
[tool: string]: IFilesSubstitution,
} = {};
/**
* @constructor
* @param {IEditorConfig} config
@ -75,18 +107,64 @@ export default class Paste extends Module {
super({config});
}
/**
* Set onPaste callback and collect tools` paste configurations
*
* @public
*/
public async prepare(): Promise<void> {
this.setCallback();
this.processTools();
}
/**
* Handle pasted or dropped data transfer object
*
* @param {DataTransfer} dataTransfer - pasted or dropped data transfer object
*/
public async processDataTransfer(dataTransfer: DataTransfer, isDragNDrop = false): Promise<void> {
const { Sanitizer } = this.Editor;
if (dataTransfer.types.includes('Files')) {
await this.processFiles(dataTransfer.items);
return;
}
const plainData = dataTransfer.getData('text/plain');
let htmlData = dataTransfer.getData('text/html');
/**
* If text was drag'n'dropped, wrap content with P tag to insert it as the new Block
*/
if (isDragNDrop && plainData.trim() && htmlData.trim()) {
htmlData = '<p>' + ( htmlData.trim() ? htmlData : plainData ) + '</p>';
}
/** Add all tags that can be substituted to sanitizer configuration */
const toolsTags = Object.keys(this.toolsTags).reduce((result, tag) => {
result[tag.toLowerCase()] = true;
return result;
}, {});
const customConfig = {tags: Object.assign({}, toolsTags, Sanitizer.defaultConfig.tags)};
const cleanData = Sanitizer.clean(htmlData, customConfig);
/** If there is no HTML or HTML string is equal to plain one, process it as plain text */
if (!cleanData.trim() || cleanData.trim() === plainData || !$.isHTMLString(cleanData)) {
await this.processText(plainData);
} else {
await this.processText(cleanData, true);
}
}
/**
* Set onPaste callback handler
*/
private setCallback(): void {
const {Listeners, UI} = this.Editor;
Listeners.on(UI.nodes.redactor, 'paste', this.processPastedData);
Listeners.on(UI.nodes.redactor, 'paste', this.handlePasteEvent);
}
/**
@ -99,76 +177,157 @@ export default class Paste extends Module {
}
/**
* Process paste config for each tools
* Process paste config for each tool
*
* @param {string} tool
* @param {string} name
* @param {Tool} tool
*/
private processTool = ([name, tool]) => {
try {
const toolPasteConfig = tool.onPaste || {};
if (this.config.initialBlock === name && !toolPasteConfig.handler) {
this.getTagsConfig(name, toolPasteConfig);
this.getFilesConfig(name, toolPasteConfig);
this.getPatternsConfig(name, toolPasteConfig);
} catch (e) {
_.log(
`Paste handling for «${name}» Tool hasn't been set up because of the error`,
'warn',
e,
);
}
}
/**
* Get tags to substitute by Tool
*
* @param {string} name - Tool name
* @param {IPasteConfig} toolPasteConfig - Tool onPaste configuration
*/
private getTagsConfig(name: string, toolPasteConfig: IPasteConfig): void {
if (this.config.initialBlock === name && !toolPasteConfig.handler) {
_.log(
`«${name}» Tool must provide a paste handler.`,
'warn',
);
}
if (!toolPasteConfig.handler) {
return;
}
if (typeof toolPasteConfig.handler !== 'function') {
_.log(
`Paste handler for «${name}» Tool should be a function.`,
'warn',
);
return;
}
const tags = toolPasteConfig.tags || [];
tags.forEach((tag) => {
if (this.toolsTags.hasOwnProperty(tag)) {
_.log(
`«${name}» Tool must provide a paste handler.`,
`Paste handler for «${name}» Tool on «${tag}» tag is skipped ` +
`because it is already used by «${this.toolsTags[tag].tool}» Tool.`,
'warn',
);
}
if (toolPasteConfig.handler && typeof toolPasteConfig.handler !== 'function') {
_.log(
`Paste handler for «${name}» Tool should be a function.`,
'warn',
);
} else {
const tags = toolPasteConfig.tags || [];
tags.forEach((tag) => {
if (this.toolsTags.hasOwnProperty(tag)) {
_.log(
`Paste handler for «${name}» Tool on «${tag}» tag is skipped ` +
`because it is already used by «${this.toolsTags[tag].tool}» Tool.`,
'warn',
);
return;
}
this.toolsTags[tag] = {
handler: toolPasteConfig.handler,
tool: name,
};
});
}
if (!toolPasteConfig.patternHandler || _.isEmpty(toolPasteConfig.patterns)) {
return;
}
if (typeof toolPasteConfig.patternHandler !== 'function') {
this.toolsTags[tag.toUpperCase()] = {
handler: toolPasteConfig.handler,
tool: name,
};
});
}
/**
* Get files` types and extensions to substitute by Tool
*
* @param {string} name - Tool name
* @param {IPasteConfig} toolPasteConfig - Tool onPaste configuration
*/
private getFilesConfig(name: string, toolPasteConfig: IPasteConfig): void {
const {fileHandler, files = {}} = toolPasteConfig;
let {extensions, mimeTypes} = files;
if (!fileHandler || (!extensions && !mimeTypes)) {
return;
}
if (typeof fileHandler !== 'function') {
_.log(`Drop handler for «${name}» Tool should be a function.`);
return;
}
if (extensions && !Array.isArray(extensions)) {
_.log(`«extensions» property of the onDrop config for «${name}» Tool should be an array`);
extensions = [];
}
if (mimeTypes && !Array.isArray(mimeTypes)) {
_.log(`«mimeTypes» property of the onDrop config for «${name}» Tool should be an array`);
mimeTypes = [];
}
if (mimeTypes) {
mimeTypes = mimeTypes.filter((type) => {
if (!_.isValidMimeType(type)) {
_.log(`MIME type value «${type}» for the «${name}» Tool is not a valid MIME type`, 'warn');
return false;
}
return true;
});
}
this.toolsFiles[name] = {
extensions: extensions || [],
mimeTypes: mimeTypes || [],
handler: fileHandler,
};
}
/**
* Get RegExp patterns to substitute by Tool
*
* @param {string} name - Tool name
* @param {IPasteConfig} toolPasteConfig - Tool onPaste configuration
*/
private getPatternsConfig(name: string, toolPasteConfig: IPasteConfig): void {
if (!toolPasteConfig.patternHandler || _.isEmpty(toolPasteConfig.patterns)) {
return;
}
if (typeof toolPasteConfig.patternHandler !== 'function') {
_.log(
`Pattern parser for «${name}» Tool should be a function.`,
'warn',
);
return;
}
Object.entries(toolPasteConfig.patterns).forEach(([key, pattern]: [string, RegExp]) => {
/** Still need to validate pattern as it provided by user */
if (!(pattern instanceof RegExp)) {
_.log(
`Pattern parser for «${name}» Tool should be a function.`,
`Pattern ${pattern} for «${name}» Tool is skipped because it should be a Regexp instance.`,
'warn',
);
} else {
Object.entries(toolPasteConfig.patterns).forEach(([key, pattern]: [string, RegExp]) => {
/** Still need to validate pattern as it provided by user */
if (!(pattern instanceof RegExp)) {
_.log(
`Pattern ${pattern} for «${name}» Tool is skipped because it should be a Regexp instance.`,
'warn',
);
}
this.toolsPatterns.push({
key,
pattern,
handler: toolPasteConfig.patternHandler,
tool: name,
});
});
}
} catch (e) {
_.log(`Paste handling for «${name}» Tool is not enabled because of an error `, 'warn', e);
}
this.toolsPatterns.push({
key,
pattern,
handler: toolPasteConfig.patternHandler,
tool: name,
});
});
}
/**
@ -190,42 +349,106 @@ export default class Paste extends Module {
}
/**
* Get pasted data, process it and insert into editor
* Check if Editor should process pasted data and pass data transfer object to handler
*
* @param {ClipboardEvent} event
*/
private processPastedData = async (event: ClipboardEvent): Promise<void> => {
private handlePasteEvent = async (event: ClipboardEvent): Promise<void> => {
const {
Editor: {Tools, Sanitizer, BlockManager, Caret},
Editor: {Sanitizer, BlockManager, Tools, Caret},
} = this;
/** If target is native input or is not Block, use browser behaviour */
if (this.isNativeBehaviour(event.target)) {
if (
this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files')
) {
return;
}
event.preventDefault();
this.processDataTransfer(event.clipboardData);
}
const htmlData = event.clipboardData.getData('text/html'),
plainData = event.clipboardData.getData('text/plain');
/**
* Get files from data transfer object and insert related Tools
*
* @param {DataTransferItemList} items - pasted or dropped items
*/
private async processFiles(items: DataTransferItemList) {
const {BlockManager} = this.Editor;
/** Add all tags that can be substituted to sanitizer configuration */
const toolsTags = Object.keys(this.toolsTags).reduce((result, tag) => {
result[tag.toLowerCase()] = {};
let dataToInsert: Array<{type: string, data: IBlockToolData}>;
return result;
}, {});
dataToInsert = await Promise.all(
Array
.from(items)
.map((item) => this.processFile(item)),
);
dataToInsert = dataToInsert.filter((data) => !!data);
const customConfig = {tags: Object.assign({}, toolsTags, Sanitizer.defaultConfig.tags)};
const cleanData = Sanitizer.clean(htmlData, customConfig);
dataToInsert.forEach(
(data, i) => {
if (i === 0 && BlockManager.currentBlock && BlockManager.currentBlock.isEmpty) {
BlockManager.replace(data.type, data.data);
return;
}
let dataToInsert = [];
BlockManager.insert(data.type, data.data);
},
);
}
/** If there is no HTML or HTML string is equal to plain one, process it as plain text */
if (!cleanData.trim() || cleanData.trim() === plainData || !$.isHTMLString(cleanData)) {
dataToInsert = this.processPlain(plainData);
} else {
dataToInsert = this.processHTML(htmlData);
/**
* Get information about file and find Tool to handle it
*
* @param {DataTransferItem} item
*/
private async processFile(item: DataTransferItem) {
if (item.kind === 'string') {
return;
}
const file = item.getAsFile();
const extension = _.getFileExtension(file);
const foundConfig = Object
.entries(this.toolsFiles)
.find(([toolName, {mimeTypes, extensions}]) => {
const [fileType, fileSubtype] = file.type.split('/');
const foundExt = extensions.find((ext) => ext.toLowerCase() === extension.toLowerCase());
const foundMimeType = mimeTypes.find((mime) => {
const [type, subtype] = mime.split('/');
return type === fileType && (subtype === fileSubtype || subtype === '*');
});
return !!foundExt || !!foundMimeType;
});
if (!foundConfig) {
return;
}
const [tool, {handler}] = foundConfig;
return {
data: await handler(file),
type: tool,
};
}
/**
* Process pasted text and divide them into Blocks
*
* @param {string} data - text to process. Can be HTML or plain.
* @param {boolean} isHTML - if passed string is HTML, this parameter should be true
*/
private async processText(data: string, isHTML: boolean = false) {
const {Caret, BlockManager} = this.Editor;
const dataToInsert = isHTML ? this.processHTML(data) : this.processPlain(data);
if (!dataToInsert.length) {
return;
}
if (dataToInsert.length === 1 && !dataToInsert[0].isBlock) {
@ -233,15 +456,103 @@ export default class Paste extends Module {
return;
}
this.splitBlock();
/**
* If caret not at the end of of the Block and there is no selection,
* we split the Block and insert content at the middle.
*/
if (SelectionUtils.isAtEditor && !Caret.isAtEnd && SelectionUtils.isCollapsed) {
this.splitBlock();
}
await Promise.all(dataToInsert.map(
async (data, i) => await this.insertBlock(data, i === 0),
async (content, i) => await this.insertBlock(content, i === 0),
));
Caret.setToBlock(BlockManager.currentBlock, CaretClass.positions.END);
}
/**
* Split HTML string to blocks and return it as array of Block data
*
* @param {string} innerHTML
* @returns {IPasteData[]}
*/
private processHTML(innerHTML: string): IPasteData[] {
const {Tools, Sanitizer} = this.Editor,
initialTool = this.config.initialBlock,
wrapper = $.make('DIV');
wrapper.innerHTML = innerHTML;
const nodes = this.getNodes(wrapper);
return nodes
.map((node) => {
let content, tool = initialTool, isBlock = false;
switch (node.nodeType) {
/** If node is a document fragment, use temp wrapper to get innerHTML */
case Node.DOCUMENT_FRAGMENT_NODE:
content = $.make('div');
content.appendChild(node);
break;
/** If node is an element, then there might be a substitution */
case Node.ELEMENT_NODE:
content = node as HTMLElement;
isBlock = true;
if (this.toolsTags[content.tagName]) {
tool = this.toolsTags[content.tagName].tool;
}
break;
}
const {handler, tags} = Tools.blockTools[tool].onPaste;
const toolTags = tags.reduce((result, tag) => {
result[tag.toLowerCase()] = {};
return result;
}, {});
const customConfig = {tags: Object.assign({}, toolTags, Sanitizer.defaultConfig.tags)};
content.innerHTML = Sanitizer.clean(content.innerHTML, customConfig);
return {content, isBlock, handler, tool};
})
.filter((data) => !$.isNodeEmpty(data.content) || $.isSingleTag(data.content));
}
/**
* Split plain text by new line symbols and return it as array of Block data
*
* @param {string} plain
* @returns {IPasteData[]}
*/
private processPlain(plain: string): IPasteData[] {
const {initialBlock} = this.config as {initialBlock: string},
{Tools} = this.Editor;
if (!plain) {
return [];
}
const tool = initialBlock,
handler = Tools.blockTools[tool].onPaste.handler;
return plain
.split(/\r?\n/)
.filter((text) => text.trim())
.map((text) => {
const content = $.make('div');
content.innerHTML = text;
return {content, tool, isBlock: false, handler};
});
}
/**
* Process paste to single Block:
* 1. Find patterns` matches
@ -342,85 +653,6 @@ export default class Paste extends Module {
}
}
/**
* Split HTML string to blocks and return it as array of Block data
*
* @param {string} innerHTML
* @returns {IPasteData[]}
*/
private processHTML(innerHTML: string): IPasteData[] {
const {Tools, Sanitizer} = this.Editor,
initialTool = this.config.initialBlock,
wrapper = $.make('DIV');
wrapper.innerHTML = innerHTML;
const nodes = this.getNodes(wrapper);
return nodes
.map((node) => {
let content, tool = initialTool, isBlock = false;
switch (node.nodeType) {
/** If node is a document fragment, use temp wrapper to get innerHTML */
case Node.DOCUMENT_FRAGMENT_NODE:
content = $.make('div');
content.appendChild(node);
break;
/** If node is an element, then there might be a substitution */
case Node.ELEMENT_NODE:
content = node as HTMLElement;
isBlock = true;
if (this.toolsTags[content.tagName]) {
tool = this.toolsTags[content.tagName].tool;
}
break;
}
const {handler, tags} = Tools.blockTools[tool].onPaste;
const toolTags = tags.reduce((result, tag) => {
result[tag.toLowerCase()] = {};
return result;
}, {});
const customConfig = {tags: Object.assign({}, toolTags, Sanitizer.defaultConfig.tags)};
content.innerHTML = Sanitizer.clean(content.innerHTML, customConfig);
return {content, isBlock, handler, tool};
})
.filter((data) => !$.isNodeEmpty(data.content));
}
/**
* Split plain text by new line symbols and return it as array of Block data
*
* @param {string} plain
* @returns {IPasteData[]}
*/
private processPlain(plain: string): IPasteData[] {
const {initialBlock} = this.config as {initialBlock: string},
{Tools} = this.Editor;
if (!plain) {
return [];
}
const tool = initialBlock,
handler = Tools.blockTools[tool].onPaste.handler;
return plain.split('\n\n').map((text) => {
const content = $.make('div');
content.innerHTML = text;
return {content, tool, isBlock: false, handler};
});
}
/**
* Recursively divide HTML string to two types of nodes:
* 1. Block element
@ -434,7 +666,7 @@ export default class Paste extends Module {
tags = Object.keys(this.toolsTags);
const reducer = (nodes: Node[], node: Node): Node[] => {
if ($.isEmpty(node)) {
if ($.isEmpty(node) && !$.isSingleTag(node as HTMLElement)) {
return nodes;
}

View file

@ -182,6 +182,26 @@ export default class Util {
};
}
/**
* Get file extension
*
* @param {File} file
* @return string
*/
public static getFileExtension(file: File): string {
return file.name.split('.').pop();
}
/**
* Check if string is MIME type
*
* @param {string} type
* @return boolean
*/
public static isValidMimeType(type: string): boolean {
return /^[-\w]+\/([-+\w]+|\*)$/.test(type);
}
/**
* Debouncing method
* Call method after passed time
@ -202,7 +222,7 @@ export default class Util {
const later = () => {
timeout = null;
if (!immediate) {
if (!immediate) {
func.apply(context, args);
}
};

View file

@ -13,7 +13,40 @@
}
&__content {
position: relative;
max-width: var(--content-width);
margin: 0 auto;
}
&--drop-target &__content {
&:before {
content: '';
position: absolute;
top: 100%;
left: -20px;
margin-top: -1px;
height: 8px;
width: 8px;
border: solid var(--color-active-icon);
border-width: 1px 1px 0 0;
transform-origin: right;
transform: rotate(45deg);
}
&:after {
content: '';
position: absolute;
top: 100%;
height: 1px;
width: 100%;
color: var(--color-active-icon);
background: repeating-linear-gradient(
90deg,
var(--color-active-icon),
var(--color-active-icon) 1px,
#fff 1px,
#fff 6px
);
}
}
}