mirror of
https://github.com/codex-team/editor.js
synced 2024-05-03 15:13:22 +02:00
b1b582b150
* chore(icons): migrating to the coded icon pack * conversion toolbar * inline toolbar, part 1 * inline-link tool has the new icons * added a test for creating a link by Enter keydown in link input * rm last icons, svg sprite, loaders * rollback .ce-settings styles * Update CHANGELOG.md * Update settings.json
407 lines
9.1 KiB
TypeScript
407 lines
9.1 KiB
TypeScript
import SelectionUtils from '../selection';
|
|
import * as _ from '../utils';
|
|
import { InlineTool, SanitizerConfig, API } from '../../../types';
|
|
import { Notifier, Toolbar, I18n, InlineToolbar } from '../../../types/api';
|
|
import { IconLink, IconUnlink } from '@codexteam/icons';
|
|
|
|
/**
|
|
* Link Tool
|
|
*
|
|
* Inline Toolbar Tool
|
|
*
|
|
* Wrap selected text with <a> tag
|
|
*/
|
|
export default class LinkInlineTool implements InlineTool {
|
|
/**
|
|
* Specifies Tool as Inline Toolbar Tool
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
public static isInline = true;
|
|
|
|
/**
|
|
* Title for hover-tooltip
|
|
*/
|
|
public static title = 'Link';
|
|
|
|
/**
|
|
* Sanitizer Rule
|
|
* Leave <a> tags
|
|
*
|
|
* @returns {object}
|
|
*/
|
|
public static get sanitize(): SanitizerConfig {
|
|
return {
|
|
a: {
|
|
href: true,
|
|
target: '_blank',
|
|
rel: 'nofollow',
|
|
},
|
|
} as SanitizerConfig;
|
|
}
|
|
|
|
/**
|
|
* Native Document's commands for link/unlink
|
|
*/
|
|
private readonly commandLink: string = 'createLink';
|
|
private readonly commandUnlink: string = 'unlink';
|
|
|
|
/**
|
|
* Enter key code
|
|
*/
|
|
private readonly ENTER_KEY: number = 13;
|
|
|
|
/**
|
|
* Styles
|
|
*/
|
|
private readonly CSS = {
|
|
button: 'ce-inline-tool',
|
|
buttonActive: 'ce-inline-tool--active',
|
|
buttonModifier: 'ce-inline-tool--link',
|
|
buttonUnlink: 'ce-inline-tool--unlink',
|
|
input: 'ce-inline-tool-input',
|
|
inputShowed: 'ce-inline-tool-input--showed',
|
|
};
|
|
|
|
/**
|
|
* Elements
|
|
*/
|
|
private nodes: {
|
|
button: HTMLButtonElement;
|
|
input: HTMLInputElement;
|
|
} = {
|
|
button: null,
|
|
input: null,
|
|
};
|
|
|
|
/**
|
|
* SelectionUtils instance
|
|
*/
|
|
private selection: SelectionUtils;
|
|
|
|
/**
|
|
* Input opening state
|
|
*/
|
|
private inputOpened = false;
|
|
|
|
/**
|
|
* Available Toolbar methods (open/close)
|
|
*/
|
|
private toolbar: Toolbar;
|
|
|
|
/**
|
|
* Available inline toolbar methods (open/close)
|
|
*/
|
|
private inlineToolbar: InlineToolbar;
|
|
|
|
/**
|
|
* Notifier API methods
|
|
*/
|
|
private notifier: Notifier;
|
|
|
|
/**
|
|
* I18n API
|
|
*/
|
|
private i18n: I18n;
|
|
|
|
/**
|
|
* @param api - Editor.js API
|
|
*/
|
|
constructor({ api }: { api: API }) {
|
|
this.toolbar = api.toolbar;
|
|
this.inlineToolbar = api.inlineToolbar;
|
|
this.notifier = api.notifier;
|
|
this.i18n = api.i18n;
|
|
this.selection = new SelectionUtils();
|
|
}
|
|
|
|
/**
|
|
* Create button for Inline Toolbar
|
|
*/
|
|
public render(): HTMLElement {
|
|
this.nodes.button = document.createElement('button') as HTMLButtonElement;
|
|
this.nodes.button.type = 'button';
|
|
this.nodes.button.classList.add(this.CSS.button, this.CSS.buttonModifier);
|
|
|
|
this.nodes.button.innerHTML = IconLink;
|
|
|
|
return this.nodes.button;
|
|
}
|
|
|
|
/**
|
|
* Input for the link
|
|
*/
|
|
public renderActions(): HTMLElement {
|
|
this.nodes.input = document.createElement('input') as HTMLInputElement;
|
|
this.nodes.input.placeholder = this.i18n.t('Add a link');
|
|
this.nodes.input.classList.add(this.CSS.input);
|
|
this.nodes.input.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
if (event.keyCode === this.ENTER_KEY) {
|
|
this.enterPressed(event);
|
|
}
|
|
});
|
|
|
|
return this.nodes.input;
|
|
}
|
|
|
|
/**
|
|
* Handle clicks on the Inline Toolbar icon
|
|
*
|
|
* @param {Range} range - range to wrap with link
|
|
*/
|
|
public surround(range: Range): void {
|
|
/**
|
|
* Range will be null when user makes second click on the 'link icon' to close opened input
|
|
*/
|
|
if (range) {
|
|
/**
|
|
* Save selection before change focus to the input
|
|
*/
|
|
if (!this.inputOpened) {
|
|
/** Create blue background instead of selection */
|
|
this.selection.setFakeBackground();
|
|
this.selection.save();
|
|
} else {
|
|
this.selection.restore();
|
|
this.selection.removeFakeBackground();
|
|
}
|
|
const parentAnchor = this.selection.findParentTag('A');
|
|
|
|
/**
|
|
* Unlink icon pressed
|
|
*/
|
|
if (parentAnchor) {
|
|
this.selection.expandToTag(parentAnchor);
|
|
this.unlink();
|
|
this.closeActions();
|
|
this.checkState();
|
|
this.toolbar.close();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.toggleActions();
|
|
}
|
|
|
|
/**
|
|
* Check selection and set activated state to button if there are <a> tag
|
|
*/
|
|
public checkState(): boolean {
|
|
const anchorTag = this.selection.findParentTag('A');
|
|
|
|
if (anchorTag) {
|
|
this.nodes.button.innerHTML = IconUnlink;
|
|
this.nodes.button.classList.add(this.CSS.buttonUnlink);
|
|
this.nodes.button.classList.add(this.CSS.buttonActive);
|
|
this.openActions();
|
|
|
|
/**
|
|
* Fill input value with link href
|
|
*/
|
|
const hrefAttr = anchorTag.getAttribute('href');
|
|
|
|
this.nodes.input.value = hrefAttr !== 'null' ? hrefAttr : '';
|
|
|
|
this.selection.save();
|
|
} else {
|
|
this.nodes.button.innerHTML = IconLink;
|
|
this.nodes.button.classList.remove(this.CSS.buttonUnlink);
|
|
this.nodes.button.classList.remove(this.CSS.buttonActive);
|
|
}
|
|
|
|
return !!anchorTag;
|
|
}
|
|
|
|
/**
|
|
* Function called with Inline Toolbar closing
|
|
*/
|
|
public clear(): void {
|
|
this.closeActions();
|
|
}
|
|
|
|
/**
|
|
* Set a shortcut
|
|
*/
|
|
public get shortcut(): string {
|
|
return 'CMD+K';
|
|
}
|
|
|
|
/**
|
|
* Show/close link input
|
|
*/
|
|
private toggleActions(): void {
|
|
if (!this.inputOpened) {
|
|
this.openActions(true);
|
|
} else {
|
|
this.closeActions(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} needFocus - on link creation we need to focus input. On editing - nope.
|
|
*/
|
|
private openActions(needFocus = false): void {
|
|
this.nodes.input.classList.add(this.CSS.inputShowed);
|
|
if (needFocus) {
|
|
this.nodes.input.focus();
|
|
}
|
|
this.inputOpened = true;
|
|
}
|
|
|
|
/**
|
|
* Close input
|
|
*
|
|
* @param {boolean} clearSavedSelection — we don't need to clear saved selection
|
|
* on toggle-clicks on the icon of opened Toolbar
|
|
*/
|
|
private closeActions(clearSavedSelection = true): void {
|
|
if (this.selection.isFakeBackgroundEnabled) {
|
|
// if actions is broken by other selection We need to save new selection
|
|
const currentSelection = new SelectionUtils();
|
|
|
|
currentSelection.save();
|
|
|
|
this.selection.restore();
|
|
this.selection.removeFakeBackground();
|
|
|
|
// and recover new selection after removing fake background
|
|
currentSelection.restore();
|
|
}
|
|
|
|
this.nodes.input.classList.remove(this.CSS.inputShowed);
|
|
this.nodes.input.value = '';
|
|
if (clearSavedSelection) {
|
|
this.selection.clearSaved();
|
|
}
|
|
this.inputOpened = false;
|
|
}
|
|
|
|
/**
|
|
* Enter pressed on input
|
|
*
|
|
* @param {KeyboardEvent} event - enter keydown event
|
|
*/
|
|
private enterPressed(event: KeyboardEvent): void {
|
|
let value = this.nodes.input.value || '';
|
|
|
|
if (!value.trim()) {
|
|
this.selection.restore();
|
|
this.unlink();
|
|
event.preventDefault();
|
|
this.closeActions();
|
|
|
|
return;
|
|
}
|
|
|
|
if (!this.validateURL(value)) {
|
|
this.notifier.show({
|
|
message: 'Pasted link is not valid.',
|
|
style: 'error',
|
|
});
|
|
|
|
_.log('Incorrect Link pasted', 'warn', value);
|
|
|
|
return;
|
|
}
|
|
|
|
value = this.prepareLink(value);
|
|
|
|
this.selection.restore();
|
|
this.selection.removeFakeBackground();
|
|
|
|
this.insertLink(value);
|
|
|
|
/**
|
|
* Preventing events that will be able to happen
|
|
*/
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.stopImmediatePropagation();
|
|
this.selection.collapseToEnd();
|
|
this.inlineToolbar.close();
|
|
}
|
|
|
|
/**
|
|
* Detects if passed string is URL
|
|
*
|
|
* @param {string} str - string to validate
|
|
* @returns {boolean}
|
|
*/
|
|
private validateURL(str: string): boolean {
|
|
/**
|
|
* Don't allow spaces
|
|
*/
|
|
return !/\s/.test(str);
|
|
}
|
|
|
|
/**
|
|
* Process link before injection
|
|
* - sanitize
|
|
* - add protocol for links like 'google.com'
|
|
*
|
|
* @param {string} link - raw user input
|
|
*/
|
|
private prepareLink(link: string): string {
|
|
link = link.trim();
|
|
link = this.addProtocol(link);
|
|
|
|
return link;
|
|
}
|
|
|
|
/**
|
|
* Add 'http' protocol to the links like 'vc.ru', 'google.com'
|
|
*
|
|
* @param {string} link - string to process
|
|
*/
|
|
private addProtocol(link: string): string {
|
|
/**
|
|
* If protocol already exists, do nothing
|
|
*/
|
|
if (/^(\w+):(\/\/)?/.test(link)) {
|
|
return link;
|
|
}
|
|
|
|
/**
|
|
* We need to add missed HTTP protocol to the link, but skip 2 cases:
|
|
* 1) Internal links like "/general"
|
|
* 2) Anchors looks like "#results"
|
|
* 3) Protocol-relative URLs like "//google.com"
|
|
*/
|
|
const isInternal = /^\/[^/\s]/.test(link),
|
|
isAnchor = link.substring(0, 1) === '#',
|
|
isProtocolRelative = /^\/\/[^/\s]/.test(link);
|
|
|
|
if (!isInternal && !isAnchor && !isProtocolRelative) {
|
|
link = 'http://' + link;
|
|
}
|
|
|
|
return link;
|
|
}
|
|
|
|
/**
|
|
* Inserts <a> tag with "href"
|
|
*
|
|
* @param {string} link - "href" value
|
|
*/
|
|
private insertLink(link: string): void {
|
|
/**
|
|
* Edit all link, not selected part
|
|
*/
|
|
const anchorTag = this.selection.findParentTag('A');
|
|
|
|
if (anchorTag) {
|
|
this.selection.expandToTag(anchorTag);
|
|
}
|
|
|
|
document.execCommand(this.commandLink, false, link);
|
|
}
|
|
|
|
/**
|
|
* Removes <a> tag
|
|
*/
|
|
private unlink(): void {
|
|
document.execCommand(this.commandUnlink);
|
|
}
|
|
}
|