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>
This commit is contained in:
Peter Savchenko 2018-06-13 14:42:21 +03:00 committed by GitHub
parent dbb4cd6f8f
commit cba999a77d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 744 additions and 365 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

@ -20,7 +20,9 @@ Method that specifies how to merge two `Blocks` of the same type, for example on
Method does accept data object in same format as the `Render` and it should provide logic how to combine new
data with the currently stored value.
### Available settings
### Internal Tool Settings
Options that Tool can specify. All settings should be passed as static properties of Tool's class.
| Name | Type | Default Value | Description |
| -- | -- | -- | -- |
@ -29,4 +31,34 @@ data with the currently stored value.
| `irreplaceable` | _Boolean_ | `false` | By default, **empty** `Blocks` can be **replaced** by other `Blocks` with the `Toolbox`. Some tools with media-content may prefer another behaviour. Pass `true` and `Toolbox` will add a new block below yours. |
| `contentless` | _Boolean_ | `false` | Pass `true` for Tool which represents decorative empty `Blocks` |
### User configuration
All Tools can be configured by users. For this reason, we provide `toolConfig` option at the Editor Initial Settings.
Unlike Internal Tool Settings, this options can be specified outside the Tool class,
so users can set up different configurations for the same Tool.
```js
var editor = new CodexEditor({
holderId : 'codex-editor',
initialBlock : 'text',
tools: {
text: Text // 'Text' Tool class for Blocks with type 'text'
},
toolsConfig: {
text: { // user configuration for Blocks with type 'text'
inlineToolbar : true,
}
}
});
```
There are few options available by CodeX Editor.
| Name | Type | Default Value | Description |
| -- | -- | -- | -- |
| `enableLineBreaks` | _Boolean_ | `false` | With this option, CodeX Editor won't handle Enter keydowns. Can be helpful for Tools like `<code>` where line breaks should be handled by default behaviour. |
| `inlineToolbar` | _Boolean/Array_ | `false` | Pass `true` to enable the Inline Toolbar with all Tools, or pass an array with specified Tools list |
### Sanitize

View file

@ -65,9 +65,10 @@
text: Text
},
toolsConfig: {
text: {
inlineToolbar : true,
},
quote: {
iconClassname : 'quote-icon',
displayInToolbox : true,
enableLineBreaks : true
}
},

View file

@ -12,146 +12,120 @@
* @property {String} text HTML content to insert to text element
*
*/
class Text {
/**
* Pass true to display this tool in the Editor's Toolbox
* @returns {boolean}
*/
static get displayInToolbox() {
return true;
}
/**
* Pass true to display this tool in the Editor's Toolbox
*
* @returns {boolean}
*/
static get displayInToolbox() {
/**
* Class for the Toolbox icon
* @returns {string}
*/
static get iconClassName() {
return 'cdx-text-icon';
}
return true;
/**
* Render plugin`s html and set initial content
* @param {TextData} data initial plugin content
*/
constructor(data = {}, config) {
this._CSS = {
wrapper: 'ce-text'
};
this._data = {};
this._element = this.draw();
this.data = data;
}
/**
* Method fires before rendered data appended to the editors area
*/
appendCallback() {
console.log("text appended");
}
draw() {
let div = document.createElement('DIV');
div.classList.add(this._CSS.wrapper);
div.contentEditable = true;
return div;
}
/**
* Create div element and add needed css classes
* @returns {HTMLDivElement} Created DIV element
*/
render() {
return this._element;
}
/**
* Merge current data with passed data
* @param {TextData} data
*/
merge(data) {
let newData = {
text : this.data.text + data.text
};
this.data = newData;
}
/**
* Check if saved text is empty
*
* @param {TextData} savedData data received from plugins`s element
* @returns {boolean} false if saved text is empty, true otherwise
*/
validate(savedData) {
if (savedData.text.trim() === '') {
return false;
}
/**
* Class for the Toolbox icon
*
* @returns {string}
*/
static get iconClassName() {
return true;
}
return 'cdx-text-icon';
/**
* Get plugin`s element HTMLDivElement
* @param {HTMLDivElement} block - returned self content
* @returns {HTMLDivElement} Plugin`s element
*/
save(block) {
return this.data;
}
}
/**
* Get current plugin`s data
*
* @todo sanitize data while saving
*
* @returns {TextData} Current data
*/
get data() {
let text = this._element.innerHTML;
/**
* Render plugin`s html and set initial content
*
* @param {TextData} data initial plugin content
*/
constructor(data = {}) {
this._data.text = text;
this._CSS = {
wrapper: 'ce-text'
};
return this._data;
}
this._data = {};
this._element = this.draw();
/**
* Set new data for plugin
*
* @param {TextData} data data to set
*/
set data(data) {
Object.assign(this._data, data);
this.data = data;
}
/**
* Method fires before rendered data appended to the editors area
*/
appendCallback() {
console.log("text appended");
}
draw() {
let div = document.createElement('DIV');
div.classList.add(this._CSS.wrapper);
div.contentEditable = true;
return div;
}
/**
* Create div element and add needed css classes
* @returns {HTMLDivElement} Created DIV element
*/
render() {
return this._element;
}
/**
* Merge current data with passed data
* @param {TextData} data
*/
merge(data) {
let newData = {
text : this.data.text + data.text
};
this.data = newData;
}
/**
* Check if saved text is empty
*
* @param {TextData} savedData data received from plugins`s element
* @returns {boolean} false if saved text is empty, true otherwise
*/
validate(savedData) {
if (savedData.text.trim() === '') {
return false;
}
return true;
}
/**
* Get plugin`s element HTMLDivElement
* @param {HTMLDivElement} block - returned self content
* @returns {HTMLDivElement} Plugin`s element
*/
save(block) {
return this.data;
}
/**
* Get current plugin`s data
*
* @todo sanitize data while saving
*
* @returns {TextData} Current data
*/
get data() {
let text = this._element.innerHTML;
this._data.text = text;
return this._data;
}
/**
* Set new data for plugin
*
* @param {TextData} data data to set
*/
set data(data) {
Object.assign(this._data, data);
this._element.innerHTML = this._data.text || '';
}
}
this._element.innerHTML = this._data.text || '';
}
}

View file

@ -215,4 +215,4 @@ export default class Block {
this._html.classList.remove(Block.CSS.selected);
}
}
}
}

View file

@ -101,7 +101,7 @@ export default class BlockManager extends Module {
bindEvents(block) {
this.Editor.Listeners.on(block.pluginsContent, 'keydown', (event) => this.Editor.Keyboard.blockKeydownsListener(event));
this.Editor.Listeners.on(block.pluginsContent, 'mouseup', (event) => {
this.Editor.InlineToolbar.move(event);
this.Editor.InlineToolbar.handleShowingEvent(event);
});
}
@ -246,10 +246,14 @@ export default class BlockManager extends Module {
/**
* Get Block instance by html element
* @param {HTMLElement} element
* @param {Node} element
* @returns {Block}
*/
getBlock(element) {
if (!$.isElement(element)) {
element = element.parentNode;
}
let nodes = this._blocks.nodes,
firstLevelBlock = element.closest(`.${Block.CSS.wrapper}`),
index = nodes.indexOf(firstLevelBlock);
@ -544,4 +548,4 @@ class Blocks {
return instance.get(index);
}
}
}

View file

@ -9,11 +9,11 @@
* @version 2.0.0
*/
import Selection from '../selection';
/**
* @typedef {Caret} Caret
*/
import Selection from '../Selection';
export default class Caret extends Module {
/**
* @constructor

View file

@ -71,7 +71,7 @@ export default class Keyboard extends Module {
* Don't handle Enter keydowns when Tool sets enableLineBreaks to true.
* Uses for Tools like <code> where line breaks should be handled by default behaviour.
*/
if (toolsConfig && toolsConfig.enableLineBreaks) {
if (toolsConfig && toolsConfig[this.Editor.Tools.apiSettings.IS_ENABLED_LINE_BREAKS]) {
return;
}
@ -146,4 +146,4 @@ export default class Keyboard extends Module {
arrowLeftAndUpPressed() {
this.Editor.BlockManager.navigatePrevious();
}
}
}

View file

@ -146,23 +146,20 @@ export default class Listeners extends Module {
* @return {Array}
*/
findAll(element, eventType, handler) {
let foundAllListeners,
foundByElements = [],
foundByEventType = [],
foundByHandler = [];
let found,
foundByElements = element ? this.findByElement(element) : [];
// foundByEventType = eventType ? this.findByType(eventType) : [],
// foundByHandler = handler ? this.findByHandler(handler) : [];
if (element)
foundByElements = this.findByElement(element);
if (element && eventType && handler) {
found = foundByElements.filter( event => event.eventType === eventType && event.handler === handler );
} else if (element && eventType) {
found = foundByElements.filter( event => event.eventType === eventType);
} else {
found = foundByElements;
}
if (eventType)
foundByEventType = this.findByType(eventType);
if (handler)
foundByHandler = this.findByHandler(handler);
foundAllListeners = foundByElements.concat(foundByEventType, foundByHandler);
return foundAllListeners;
return found;
}
/**
@ -175,4 +172,4 @@ export default class Listeners extends Module {
this.allListeners = [];
}
}
}

View file

@ -1,26 +1,28 @@
/**
* Inline toolbar with actions that modifies selected text fragment
*
* ________________________
* | |
* |¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯|
* | B i [link] [mark] |
* | _______________________|
*/
declare var Module: any;
declare var $: any;
declare var _: any;
import Selection from '../selection';
/**
* DOM Elements
*/
interface InlineToolbarNodes {
wrapper?: Element; // main wrapper
interface INodes {
wrapper?: HTMLElement; // main wrapper
}
/**
* CSS
*/
interface InlineToolbarCSS {
interface ICSS {
inlineToolbar: string;
inlineToolbarShowed: string;
}
export default class InlineToolbar extends Module {
@ -28,17 +30,23 @@ export default class InlineToolbar extends Module {
/**
* Inline Toolbar elements
*/
private nodes: InlineToolbarNodes = {
wrapper: null,
private nodes: INodes = {
wrapper: null,
};
/**
* CSS styles
*/
private CSS: InlineToolbarCSS = {
inlineToolbar: 'ce-inline-toolbar',
private CSS: ICSS = {
inlineToolbar: 'ce-inline-toolbar',
inlineToolbarShowed: 'ce-inline-toolbar--showed',
};
/**
* Margin above/below the Toolbar
*/
private readonly toolbarVerticalMargin: number = 20;
/**
* @constructor
*/
@ -62,7 +70,98 @@ export default class InlineToolbar extends Module {
}
public move() {
// moving
/**
* Shows Inline Toolbar by keyup/mouseup
* @param {KeyboardEvent|MouseEvent} event
*/
public handleShowingEvent(event): void {
if (!this.allowedToShow(event)) {
this.close();
return;
}
this.move();
this.open();
}
/**
* Move Toolbar to the selected text
*/
public move(): void {
const selectionRect = Selection.rect;
const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect();
const newCoords = {
x: selectionRect.x - wrapperOffset.left,
y: selectionRect.y
+ selectionRect.height
// + window.scrollY
- wrapperOffset.top
+ this.toolbarVerticalMargin,
};
/**
* If we know selections width, place InlineToolbar to center
*/
if (selectionRect.width) {
newCoords.x += Math.floor(selectionRect.width / 2);
}
this.nodes.wrapper.style.left = Math.floor(newCoords.x) + 'px';
this.nodes.wrapper.style.top = Math.floor(newCoords.y) + 'px';
}
/**
* Shows Inline Toolbar
*/
private open() {
this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed);
}
/**
* Hides Inline Toolbar
*/
private close() {
this.nodes.wrapper.classList.remove(this.CSS.inlineToolbarShowed);
}
/**
* Need to show Inline Toolbar or not
* @param {KeyboardEvent|MouseEvent} event
*/
private allowedToShow(event): boolean {
/**
* Tags conflicts with window.selection function.
* Ex. IMG tag returns null (Firefox) or Redactors wrapper (Chrome)
*/
const tagsConflictsWithSelection = ['IMG', 'INPUT'];
if (event && tagsConflictsWithSelection.includes(event.target.tagName)) {
return false;
}
const currentSelection = Selection.get(),
selectedText = Selection.text;
// old browsers
if (!currentSelection || !currentSelection.anchorNode) {
return false;
}
// empty selection
if (currentSelection.isCollapsed || selectedText.length < 1) {
return false;
}
// is enabled by current Block's Tool
const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode);
if (!currentBlock) {
return false;
}
const toolConfig = this.config.toolsConfig[currentBlock.name];
return toolConfig && toolConfig[this.Editor.Tools.apiSettings.IS_ENABLED_INLINE_TOOLBAR];
}
}

View file

@ -67,7 +67,9 @@ export default class Toolbox extends Module {
* @param {Tool} tool - tool class
*/
addTool(toolName, tool) {
if (tool.displayInToolbox && !tool.iconClassName) {
const api = this.Editor.Tools.apiSettings;
if (tool[api.IS_DISPLAYED_IN_TOOLBOX] && !tool[api.TOOLBAR_ICON_CLASS]) {
_.log('Toolbar icon class name is missed. Tool %o skipped', 'warn', toolName);
return;
}
@ -85,11 +87,11 @@ export default class Toolbox extends Module {
/**
* Skip tools that pass 'displayInToolbox=false'
*/
if (!tool.displayInToolbox) {
if (!tool[api.IS_DISPLAYED_IN_TOOLBOX]) {
return;
}
let button = $.make('li', [Toolbox.CSS.toolboxButton, tool.iconClassName], {
let button = $.make('li', [Toolbox.CSS.toolboxButton, tool[api.TOOLBAR_ICON_CLASS]], {
title: toolName
});
@ -135,7 +137,7 @@ export default class Toolbox extends Module {
* - block is not irreplaceable
* @type {Array}
*/
if (!tool.irreplaceable && currentBlock.isEmpty) {
if (!tool[this.Editor.Tools.apiSettings.IS_IRREPLACEBLE_TOOL] && currentBlock.isEmpty) {
this.Editor.BlockManager.replace(toolName);
} else {
this.Editor.BlockManager.insert(toolName);
@ -184,4 +186,4 @@ export default class Toolbox extends Module {
this.close();
}
}
}
}

View file

@ -11,6 +11,7 @@
* @property {String} iconClassname - this a icon in toolbar
* @property {Boolean} displayInToolbox - will be displayed in toolbox. Default value is TRUE
* @property {Boolean} enableLineBreaks - inserts new block or break lines. Default value is FALSE
* @property {Boolean|String[]} inlineToolbar - Pass `true` to enable the Inline Toolbar with all Tools, all pass an array with specified Tools list |
* @property render @todo add description
* @property save @todo add description
* @property settings @todo add description
@ -57,18 +58,31 @@ export default class Tools extends Module {
return this.toolsUnavailable;
}
/**
* Constant for available Tools Settings
* @return {object}
*/
get apiSettings() {
return {
TOOLBAR_ICON_CLASS: 'iconClassName',
IS_DISPLAYED_IN_TOOLBOX: 'displayInToolbox',
IS_ENABLED_LINE_BREAKS: 'enableLineBreaks',
IS_IRREPLACEBLE_TOOL: 'irreplaceable',
IS_ENABLED_INLINE_TOOLBAR: 'inlineToolbar',
};
}
/**
* Static getter for default Tool config fields
*
* @usage Tools.defaultConfig.displayInToolbox
* @return {ToolConfig}
*/
static get defaultConfig() {
get defaultConfig() {
return {
iconClassName : '',
displayInToolbox : false,
enableLineBreaks : false,
irreplaceable : false
[this.apiSettings.TOOLBAR_ICON_CLASS] : false,
[this.apiSettings.IS_DISPLAYED_IN_TOOLBOX] : false,
[this.apiSettings.IS_ENABLED_LINE_BREAKS] : false,
[this.apiSettings.IS_IRREPLACEBLE_TOOL] : false,
[this.apiSettings.IS_ENABLED_INLINE_TOOLBAR]: false,
};
}
@ -192,11 +206,7 @@ export default class Tools extends Module {
let plugin = this.toolClasses[tool],
config = this.config.toolsConfig[tool];
if (!config) {
config = this.defaultConfig;
}
let instance = new plugin(data, config);
let instance = new plugin(data, config || {});
return instance;
}
@ -209,4 +219,4 @@ export default class Tools extends Module {
isInitial(tool) {
return tool instanceof this.available[this.config.initialBlock];
}
}
}

View file

@ -222,17 +222,9 @@ export default class UI extends Module {
/**
* @todo hide the Inline Toolbar
* Close Inline Toolbar when nothing selected
*/
// var selectedText = editor.toolbar.inline.getSelectionText(),
// firstLevelBlock;
/** If selection range took off, then we hide inline toolbar */
// if (selectedText.length === 0) {
// editor.toolbar.inline.close();
// }
this.Editor.InlineToolbar.handleShowingEvent(event);
/**
*

View file

@ -1,5 +1,6 @@
/**
* Working with selection
* @typedef {Selection} Selection
*/
export default class Selection {
/**
@ -24,7 +25,7 @@ export default class Selection {
* {@link https://developer.mozilla.org/ru/docs/Web/API/Selection/anchorNode}
* @return {Node|null}
*/
static getAnchorNode() {
static get anchorNode() {
let selection = window.getSelection();
return selection ? selection.anchorNode : null;
@ -35,7 +36,7 @@ export default class Selection {
* {@link https://developer.mozilla.org/ru/docs/Web/API/Selection/anchorOffset}
* @return {Number|null}
*/
static getAnchorOffset() {
static get anchorOffset() {
let selection = window.getSelection();
return selection ? selection.anchorOffset : null;
@ -50,4 +51,75 @@ export default class Selection {
return selection ? selection.isCollapsed : null;
}
}
/**
* Calculates position and size of selected text
* @return {{x, y, width, height, top?, left?, bottom?, right?}}
*/
static get rect() {
let sel = document.selection, range;
let rect = {
x: 0,
y: 0,
width: 0,
height: 0
};
if (sel && sel.type !== 'Control') {
range = sel.createRange();
rect.x = range.boundingLeft;
rect.y = range.boundingTop;
rect.width = range.boundingWidth;
rect.height = range.boundingHeight;
return rect;
}
if (!window.getSelection) {
_.log('Method window.getSelection is not supported', 'warn');
return rect;
}
sel = window.getSelection();
if (!sel.rangeCount) {
_.log('Method Selection.rangeCount() is not supported', 'warn');
return rect;
}
range = sel.getRangeAt(0).cloneRange();
if (range.getBoundingClientRect) {
rect = range.getBoundingClientRect();
}
// Fall back to inserting a temporary element
if (rect.x === 0 && rect.y === 0) {
let span = document.createElement('span');
if (span.getBoundingClientRect) {
// Ensure span has dimensions and position by
// adding a zero-width space character
span.appendChild( document.createTextNode('\u200b') );
range.insertNode(span);
rect = span.getBoundingClientRect();
let spanParent = span.parentNode;
spanParent.removeChild(span);
// Glue any broken text nodes back together
spanParent.normalize();
}
}
return rect;
}
/**
* Returns selected text as String
* @returns {string}
*/
static get text() {
return window.getSelection ? window.getSelection().toString() : '';
};
}

View file

@ -168,4 +168,4 @@ export default class Util {
window.setTimeout(() => method.apply(context, args), timeout);
};
}
};
};

View file

@ -1,8 +1,12 @@
.ce-inline-toolbar {
position: absolute;
z-index: 2;
@apply --overlay-pane;
@apply --overlay-pane;
width: 100px;
height: 40px;
}
width: 100px;
height: 40px;
transform: translateX(-50%);
display: none;
&--showed {
display: block;
}
}

View file

@ -21,10 +21,11 @@
--toolbar-buttons-size: 34px;
--overlay-pane: {
position: absolute;
background: #FFFFFF;
box-shadow: 0 8px 23px -6px rgba(21,40,54,0.31), 22px -14px 34px -18px rgba(33,48,73,0.26);
border-radius: 4px;
position: relative;
z-index: 2;
&::before {
content: '';
@ -39,4 +40,4 @@
}
}
}
}

View file

@ -2,5 +2,6 @@
"compilerOptions" : {
"target": "es6",
"declaration": false,
"lib": ["es2016", "dom"]
}
}

View file

@ -1,6 +1,8 @@
{
"extends": "tslint:recommended",
"rules": {
"quotemark": [true, "single"]
"quotemark": [true, "single"],
"no-console": false,
"one-variable-per-declaration": false
}
}