mirror of
https://github.com/codex-team/editor.js
synced 2024-05-19 06:47:16 +02:00
524 lines
14 KiB
TypeScript
524 lines
14 KiB
TypeScript
import Module from '../../__module';
|
|
import $ from '../../dom';
|
|
|
|
import BoldInlineTool from '../../inline-tools/inline-tool-bold';
|
|
import ItalicInlineTool from '../../inline-tools/inline-tool-italic';
|
|
import LinkInlineTool from '../../inline-tools/inline-tool-link';
|
|
import SelectionUtils from '../../selection';
|
|
import _ from '../../utils';
|
|
import {InlineTool, InlineToolConstructable, ToolConstructable, ToolSettings} from '../../../../types';
|
|
|
|
/**
|
|
* Inline toolbar with actions that modifies selected text fragment
|
|
*
|
|
* |¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯|
|
|
* | B i [link] [mark] |
|
|
* |________________________|
|
|
*/
|
|
export default class InlineToolbar extends Module {
|
|
|
|
/**
|
|
* Returns internal inline tools
|
|
* Includes Bold, Italic, Link
|
|
*/
|
|
private get internalTools(): { [name: string]: InlineTool } {
|
|
return {
|
|
bold: this.Editor.Tools.constructInline(BoldInlineTool),
|
|
italic: this.Editor.Tools.constructInline(ItalicInlineTool),
|
|
link: this.Editor.Tools.constructInline(LinkInlineTool),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get external tools
|
|
* Tools that has isInline is true
|
|
*/
|
|
private get externalTools(): { [name: string]: InlineTool } {
|
|
const result = {};
|
|
|
|
for (const tool in this.Editor.Tools.inline) {
|
|
if (this.Editor.Tools.inline.hasOwnProperty(tool)) {
|
|
result[tool] = this.Editor.Tools.constructInline(this.Editor.Tools.inline[tool]);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* CSS styles
|
|
*/
|
|
public CSS = {
|
|
inlineToolbar: 'ce-inline-toolbar',
|
|
inlineToolbarShowed: 'ce-inline-toolbar--showed',
|
|
buttonsWrapper: 'ce-inline-toolbar__buttons',
|
|
actionsWrapper: 'ce-inline-toolbar__actions',
|
|
actionsWrapperOpened: 'ce-inline-toolbar__actions--opened',
|
|
inlineToolButton: 'ce-inline-tool',
|
|
inlineToolButtonPinch: 'ce-inline-tool--pinch',
|
|
inlineToolButtonLast: 'ce-inline-tool--last',
|
|
inputField: 'cdx-input',
|
|
};
|
|
|
|
/**
|
|
* Inline Toolbar elements
|
|
*/
|
|
private nodes: { wrapper: HTMLElement, buttons: HTMLElement, actions: HTMLElement } = {
|
|
wrapper: null,
|
|
buttons: null,
|
|
/**
|
|
* Zone below the buttons where Tools can create additional actions by 'renderActions()' method
|
|
* For example, input for the 'link' tool or textarea for the 'comment' tool
|
|
*/
|
|
actions: null,
|
|
};
|
|
|
|
private toolsButtons: {[name: string]: HTMLElement} = {};
|
|
private toolsActions: {[name: string]: HTMLElement} = {};
|
|
private currentActionsTool: string;
|
|
|
|
/**
|
|
* Margin above/below the Toolbar
|
|
*/
|
|
private readonly toolbarVerticalMargin: number = 20;
|
|
|
|
/**
|
|
* Tools instances
|
|
*/
|
|
private toolsInstances: Map<string, InlineTool>;
|
|
|
|
/**
|
|
* Inline Toolbar Tools
|
|
*
|
|
* @returns Map<string, InlineTool>
|
|
*/
|
|
get tools(): Map<string, InlineTool> {
|
|
if (!this.toolsInstances || this.toolsInstances.size === 0) {
|
|
const allTools = this.inlineTools;
|
|
|
|
this.toolsInstances = new Map();
|
|
for (const tool in allTools) {
|
|
if (allTools.hasOwnProperty(tool)) {
|
|
this.toolsInstances.set(tool, allTools[tool]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return this.toolsInstances;
|
|
}
|
|
|
|
/**
|
|
* Making DOM
|
|
*/
|
|
public make() {
|
|
this.nodes.wrapper = $.make('div', this.CSS.inlineToolbar);
|
|
this.nodes.buttons = $.make('div', this.CSS.buttonsWrapper);
|
|
this.nodes.actions = $.make('div', this.CSS.actionsWrapper);
|
|
|
|
// To prevent reset of a selection when click on the wrapper
|
|
this.Editor.Listeners.on(this.nodes.wrapper, 'mousedown', (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
/**
|
|
* Append Inline Toolbar to the Editor
|
|
*/
|
|
$.append(this.nodes.wrapper, this.nodes.buttons);
|
|
$.append(this.Editor.UI.nodes.wrapper, this.nodes.wrapper);
|
|
|
|
/**
|
|
* Append Inline Toolbar Tools
|
|
*/
|
|
this.addTools();
|
|
|
|
}
|
|
|
|
/**
|
|
* Moving / appearance
|
|
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
*/
|
|
|
|
/**
|
|
* Shows Inline Toolbar by keyup/mouseup
|
|
* @param {KeyboardEvent|MouseEvent} event
|
|
*/
|
|
public handleShowingEvent(event): void {
|
|
if (!this.allowedToShow()) {
|
|
this.close();
|
|
return;
|
|
}
|
|
|
|
this.move();
|
|
this.open();
|
|
|
|
/** Check Tools state for selected fragment */
|
|
this.checkToolsState();
|
|
|
|
/** Clear selection */
|
|
this.Editor.BlockSelection.clearSelection();
|
|
}
|
|
|
|
/**
|
|
* Move Toolbar to the selected text
|
|
*/
|
|
public move(): void {
|
|
const selectionRect = SelectionUtils.rect as DOMRect;
|
|
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';
|
|
}
|
|
|
|
/**
|
|
* Hides Inline Toolbar
|
|
*/
|
|
public close(): void {
|
|
this.currentActionsTool = null;
|
|
|
|
this.nodes.wrapper.classList.remove(this.CSS.inlineToolbarShowed);
|
|
this.tools.forEach((toolInstance) => {
|
|
if (typeof toolInstance.clear === 'function') {
|
|
toolInstance.clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
public toggleActions(toolName: string) {
|
|
if (!this.currentActionsTool) {
|
|
this.openActions(toolName);
|
|
return;
|
|
}
|
|
|
|
if (this.currentActionsTool === toolName) {
|
|
this.closeActions();
|
|
return;
|
|
}
|
|
|
|
this.closeActions();
|
|
this.openActions(toolName);
|
|
}
|
|
|
|
private openActions(toolName: string) {
|
|
if (!this.toolsActions[toolName]) {
|
|
return;
|
|
}
|
|
|
|
this.currentActionsTool = toolName;
|
|
|
|
const toolbarWidth = this.nodes.buttons.clientWidth;
|
|
this.nodes.buttons.style.minWidth = `${toolbarWidth}px`;
|
|
|
|
Array.from(this.nodes.actions.children).forEach((child) => child.remove());
|
|
this.nodes.actions.appendChild(this.toolsActions[toolName]);
|
|
this.nodes.buttons.insertBefore(this.nodes.actions, this.toolsButtons[toolName]);
|
|
|
|
setImmediate(() => {
|
|
Object.entries(this.toolsButtons).forEach(([name, button]) => {
|
|
button.classList.toggle(this.CSS.inlineToolButtonPinch, name !== toolName);
|
|
});
|
|
|
|
this.nodes.actions.style.width = `${toolbarWidth - 34}px`;
|
|
this.nodes.actions.classList.add(this.CSS.actionsWrapperOpened);
|
|
});
|
|
}
|
|
|
|
private closeActions() {
|
|
this.currentActionsTool = null;
|
|
|
|
// Array.from(this.nodes.actions.children).forEach((child) => child.remove());
|
|
|
|
Object.entries(this.toolsButtons).forEach(([name, button]) => {
|
|
button.classList.remove(this.CSS.inlineToolButtonPinch);
|
|
});
|
|
|
|
this.nodes.buttons.style.minWidth = '';
|
|
this.nodes.actions.style.width = '';
|
|
this.nodes.actions.classList.remove(this.CSS.actionsWrapperOpened);
|
|
}
|
|
|
|
/**
|
|
* Shows Inline Toolbar
|
|
*/
|
|
private open(): void {
|
|
this.currentActionsTool = null;
|
|
|
|
/**
|
|
* Filter inline-tools and show only allowed by Block's Tool
|
|
*/
|
|
this.filterTools();
|
|
|
|
/**
|
|
* Show Inline Toolbar
|
|
*/
|
|
this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed);
|
|
|
|
/**
|
|
* Call 'clear' method for Inline Tools (for example, 'link' want to clear input)
|
|
*/
|
|
this.tools.forEach((toolInstance: InlineTool) => {
|
|
if (typeof toolInstance.clear === 'function') {
|
|
toolInstance.clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Need to show Inline Toolbar or not
|
|
*/
|
|
private allowedToShow(): boolean {
|
|
/**
|
|
* Tags conflicts with window.selection function.
|
|
* Ex. IMG tag returns null (Firefox) or Redactors wrapper (Chrome)
|
|
*/
|
|
const tagsConflictsWithSelection = ['IMG', 'INPUT'];
|
|
const currentSelection = SelectionUtils.get();
|
|
const selectedText = SelectionUtils.text;
|
|
|
|
// old browsers
|
|
if (!currentSelection || !currentSelection.anchorNode) {
|
|
return false;
|
|
}
|
|
|
|
// empty selection
|
|
if (currentSelection.isCollapsed || selectedText.length < 1) {
|
|
return false;
|
|
}
|
|
|
|
const target = currentSelection.anchorNode.parentElement;
|
|
|
|
if (currentSelection && tagsConflictsWithSelection.includes(target.tagName)) {
|
|
return false;
|
|
}
|
|
|
|
// The selection of the element only in contenteditable
|
|
const contenteditable = target.closest('[contenteditable="true"]');
|
|
if (contenteditable === null) {
|
|
return false;
|
|
}
|
|
|
|
// is enabled by current Block's Tool
|
|
const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement);
|
|
|
|
if (!currentBlock) {
|
|
return false;
|
|
}
|
|
|
|
const toolSettings = this.Editor.Tools.getToolSettings(currentBlock.name);
|
|
|
|
return toolSettings && toolSettings[this.Editor.Tools.apiSettings.IS_ENABLED_INLINE_TOOLBAR] as boolean;
|
|
}
|
|
|
|
/**
|
|
* Show only allowed Tools
|
|
*/
|
|
private filterTools(): void {
|
|
const currentSelection = SelectionUtils.get(),
|
|
currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement);
|
|
|
|
const toolSettings = this.Editor.Tools.getToolSettings(currentBlock.name),
|
|
inlineToolbarSettings = toolSettings && toolSettings[this.Editor.Tools.apiSettings.IS_ENABLED_INLINE_TOOLBAR];
|
|
|
|
/**
|
|
* All Inline Toolbar buttons
|
|
* @type {HTMLElement[]}
|
|
*/
|
|
const buttons = Object.values(this.toolsButtons);
|
|
|
|
/**
|
|
* Show previously hided
|
|
*/
|
|
buttons.forEach((button) => {
|
|
button.hidden = false;
|
|
button.classList.remove(this.CSS.inlineToolButtonLast);
|
|
});
|
|
|
|
/**
|
|
* Filter buttons if Block Tool pass config like inlineToolbar=['link']
|
|
*/
|
|
if (Array.isArray(inlineToolbarSettings)) {
|
|
buttons.forEach((button) => {
|
|
button.hidden = !inlineToolbarSettings.includes(button.dataset.tool);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tick for removing right-margin from last visible button.
|
|
* Current generation of CSS does not allow to filter :visible elements
|
|
*/
|
|
const lastVisibleButton = buttons.filter((button) => !button.hidden).pop();
|
|
|
|
if (lastVisibleButton) {
|
|
lastVisibleButton.classList.add(this.CSS.inlineToolButtonLast);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Working with Tools
|
|
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
*/
|
|
|
|
/**
|
|
* Fill Inline Toolbar with Tools
|
|
*/
|
|
private addTools(): void {
|
|
this.tools.forEach((toolInstance, toolName) => {
|
|
this.addTool(toolName, toolInstance);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add tool button and activate clicks
|
|
*/
|
|
private addTool(toolName: string, tool: InlineTool): void {
|
|
const {
|
|
Listeners,
|
|
Tools,
|
|
} = this.Editor;
|
|
|
|
const button = tool.render();
|
|
|
|
this.toolsButtons[toolName] = button;
|
|
|
|
if (!button) {
|
|
_.log('Render method must return an instance of Node', 'warn', toolName);
|
|
return;
|
|
}
|
|
|
|
button.dataset.tool = toolName;
|
|
this.nodes.buttons.appendChild(button);
|
|
|
|
if (typeof tool.renderActions === 'function') {
|
|
this.toolsActions[toolName] = tool.renderActions();
|
|
}
|
|
|
|
Listeners.on(button, 'click', (event) => {
|
|
this.toolClicked(toolName, tool);
|
|
event.preventDefault();
|
|
});
|
|
|
|
/**
|
|
* Enable shortcuts
|
|
* Ignore tool that doesn't have shortcut or empty string
|
|
*/
|
|
const toolSettings = Tools.getToolSettings(toolName);
|
|
|
|
let shortcut = null;
|
|
|
|
/**
|
|
* Get internal inline tools
|
|
*/
|
|
const internalTools: string[] = Object
|
|
.entries(Tools.internalTools)
|
|
.filter(([name, toolClass]: [string, ToolConstructable | ToolSettings]) => {
|
|
if (_.isFunction(toolClass)) {
|
|
return toolClass[Tools.apiSettings.IS_INLINE];
|
|
}
|
|
|
|
return (toolClass as ToolSettings).class[Tools.apiSettings.IS_INLINE];
|
|
})
|
|
.map(([name]: [string, InlineToolConstructable | ToolSettings]) => name);
|
|
|
|
/**
|
|
* 1) For internal tools, check public getter 'shortcut'
|
|
* 2) For external tools, check tool's settings
|
|
*/
|
|
if (internalTools.includes(toolName)) {
|
|
shortcut = this.inlineTools[toolName].shortcut;
|
|
} else if (toolSettings && toolSettings[Tools.apiSettings.SHORTCUT]) {
|
|
shortcut = toolSettings[Tools.apiSettings.SHORTCUT];
|
|
}
|
|
|
|
if (shortcut) {
|
|
this.enableShortcuts(toolName, tool, shortcut);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable Tool shortcut with Editor Shortcuts Module
|
|
* @param {InlineTool} tool - Tool instance
|
|
* @param {string} shortcut - shortcut according to the ShortcutData Module format
|
|
*/
|
|
private enableShortcuts(toolName: string, tool: InlineTool, shortcut: string): void {
|
|
this.Editor.Shortcuts.add({
|
|
name: shortcut,
|
|
handler: (event) => {
|
|
const {currentBlock} = this.Editor.BlockManager;
|
|
|
|
/**
|
|
* Editor is not focused
|
|
*/
|
|
if (!currentBlock) {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* We allow to fire shortcut with empty selection (isCollapsed=true)
|
|
* it can be used by tools like «Mention» that works without selection:
|
|
* Example: by SHIFT+@ show dropdown and insert selected username
|
|
*/
|
|
// if (SelectionUtils.isCollapsed) return;
|
|
|
|
const toolSettings = this.Editor.Tools.getToolSettings(currentBlock.name);
|
|
|
|
if (!toolSettings || !toolSettings[this.Editor.Tools.apiSettings.IS_ENABLED_INLINE_TOOLBAR]) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
this.toolClicked(toolName, tool);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inline Tool button clicks
|
|
* @param {InlineTool} tool - Tool's instance
|
|
*/
|
|
private toolClicked(toolName: string, tool: InlineTool): void {
|
|
const range = SelectionUtils.range;
|
|
|
|
tool.surround(range);
|
|
this.checkToolsState();
|
|
}
|
|
|
|
/**
|
|
* Check Tools` state by selection
|
|
*/
|
|
private checkToolsState(): void {
|
|
this.tools.forEach((toolInstance) => {
|
|
toolInstance.checkState(SelectionUtils.get());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get inline tools tools
|
|
* Tools that has isInline is true
|
|
*/
|
|
private get inlineTools(): { [name: string]: InlineTool } {
|
|
const result = {};
|
|
|
|
for (const tool in this.Editor.Tools.inline) {
|
|
if (this.Editor.Tools.inline.hasOwnProperty(tool)) {
|
|
result[tool] = this.Editor.Tools.constructInline(this.Editor.Tools.inline[tool]);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|