This is our custom implementation of the Inline Tool "Link" with a target
and title
attribute.
--> used and tested with @editorjs/[email protected]
This is our custom implementation of the Inline Tool "Link" with a target
and title
attribute.
--> used and tested with @editorjs/[email protected]
import * as EditorJS from '@editorjs/editorjs' | |
import SelectionUtils from './selection' | |
interface IContainers { | |
inputContainer: HTMLDivElement | |
titleContainer: HTMLDivElement | |
targetContainer: HTMLDivElement | |
} | |
interface IInputs { | |
input: HTMLInputElement | |
title: HTMLInputElement | |
target: HTMLInputElement | |
} | |
interface INodes extends IContainers, IInputs { | |
button: HTMLButtonElement | |
buttonSubmit: HTMLButtonElement | |
actionContainer: HTMLDivElement | |
} | |
type AllowedNodes = keyof IInputs | |
const ELEMENTS: { | |
type: 'input' | |
name: AllowedNodes | |
label: string | |
description?: string | |
props: { | |
placeholder?: string | |
type?: string | |
} | |
}[] = [ | |
{ | |
type: 'input', | |
name: 'input', | |
label: 'URL', | |
props: { | |
placeholder: 'URL', | |
}, | |
}, | |
{ | |
type: 'input', | |
name: 'title', | |
label: 'Title', | |
props: { | |
placeholder: 'Title', | |
}, | |
}, | |
{ | |
type: 'input', | |
name: 'target', | |
label: 'Target', | |
description: 'Open in new Window', | |
props: { | |
type: 'checkbox', | |
}, | |
}, | |
] | |
const addInlineStyles = () => { | |
const styleId = 'inline-tool-styling' | |
if (!document.getElementById(styleId)) { | |
const style = document.createElement('style') | |
style.id = styleId | |
style.innerHTML = ` | |
.ce-inline-link-tool.ce-inline-tool-input--showed { | |
padding: 0; | |
} | |
.ce-inline-tool-container { | |
border-top: 1px solid rgba(201,201,204,.48); | |
padding: 5px; | |
padding-left: 10px; | |
} | |
.ce-inline-tool-container label span:first-child { | |
padding-right: 10px; | |
width: 30px; | |
display: inline-block; | |
} | |
.ce-inline-tool-container input { | |
outline: none; | |
border: 0; | |
border-radius: 0 0 4px 4px; | |
margin: 0; | |
font-size: 13px; | |
margin: 5px; | |
-webkit-box-sizing: border-box; | |
box-sizing: border-box; | |
font-weight: 500; | |
} | |
.ce-inline-tool-container input + span { | |
padding-left: 5px; | |
} | |
.ce-inline-link-tool button { | |
outline: none; | |
padding: 5px 10px; | |
background: transparent; | |
margin-left: 10px; | |
display: block; | |
margin-bottom: 10px; | |
box-sizing: border-box; | |
} | |
` | |
document.head.appendChild(style) | |
} | |
} | |
/** | |
* Link Tool (Inline Toolbar Tool), wraps selected text with <a> tag | |
* | |
* Mainly inspired by EditorJS', but extended with own features like: | |
* - target setting | |
* - alternative text and title attribute | |
* @see https://github.com/codex-team/editor.js/blob/737ba2abb423665257cf6ccc5e8472742433322f/src/components/inline-tools/inline-tool-link.ts | |
*/ | |
export class LinkInlineTool implements EditorJS.InlineTool { | |
/** | |
* Specifies Tool as Inline Toolbar Tool | |
* | |
* @return {boolean} | |
*/ | |
public static isInline = true | |
/** | |
* Title for hover-tooltip | |
*/ | |
public static title = 'Link' | |
/** | |
* Set a shortcut | |
*/ | |
public get shortcut(): string { | |
return 'CMD+K' | |
} | |
/** | |
* Sanitizer Rule | |
* Leave <a> tags | |
* @return {object} | |
*/ | |
static get sanitize(): EditorJS.SanitizerConfig { | |
return { | |
a: { | |
href: true, | |
rel: 'nofollow', | |
target: '_blank', | |
title: true, | |
}, | |
} | |
} | |
/** | |
* Native Document's commands for insertHTML and unlink | |
* @see https://stackoverflow.com/a/23891233/1238150 | |
*/ | |
private readonly commandLink: string = 'insertHTML' | |
private readonly commandUnlink: string = 'unlink' | |
/** | |
* Enter key code | |
*/ | |
private readonly ENTER_KEY = 13 as const | |
/** | |
* Styles | |
*/ | |
private CSS: { | |
button: string | |
buttonActive: string | |
input: string | |
inputShowed: string | |
} = null | |
/** | |
* Elements | |
*/ | |
private nodes: INodes = { | |
actionContainer: null, | |
button: null, | |
buttonSubmit: null, | |
input: null, | |
inputContainer: null, | |
title: null, | |
titleContainer: null, | |
target: null, | |
targetContainer: null, | |
} | |
/** | |
* SelectionUtils instance | |
*/ | |
private selection: SelectionUtils | |
private apiSelection: EditorJS.API['selection'] | |
/** | |
* Input opening state | |
*/ | |
private inputOpened = false | |
/** | |
* Available Toolbar methods (open/close) | |
*/ | |
private toolbar: any | |
/** | |
* Available inline toolbar methods (open/close) | |
*/ | |
private inlineToolbar: any | |
/** | |
* Notifier API methods | |
*/ | |
private notifier: any | |
/** | |
* @param {{api: API}} - Editor.js API | |
*/ | |
constructor({ api }: { api: EditorJS.API }) { | |
this.toolbar = api.toolbar | |
this.inlineToolbar = api.inlineToolbar | |
this.notifier = api.notifier | |
this.apiSelection = api.selection | |
this.selection = new SelectionUtils() | |
/** | |
* CSS classes | |
*/ | |
this.CSS = { | |
button: api.styles.inlineToolButton, | |
buttonActive: api.styles.inlineToolButtonActive, | |
input: 'ce-inline-tool-input', | |
inputShowed: 'ce-inline-tool-input--showed', | |
} | |
addInlineStyles() | |
} | |
/** | |
* 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.nodes.button.innerHTML = `<svg class="icon icon--link" width="14px" height="10px"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#link"></use></svg>` | |
return this.nodes.button | |
} | |
/** | |
* Input for the link edit view | |
* | |
* the DOM structure for each input looks like this: | |
* | |
* ``` | |
* <div class="ce-inline-link-tool"> | |
* <div class="ce-inline-tool-container"> | |
* <label> | |
* <span>Label</span> | |
* <input /> | |
* <span>Description</span> // optional | |
* </label> | |
* </div> | |
* // ... other link inputs | |
* <button /> | |
* </div> | |
* ``` | |
*/ | |
public renderActions(): HTMLElement { | |
// Container holds all elements of the Inline Tool Actions | |
this.nodes.actionContainer = document.createElement('div') | |
this.nodes.actionContainer.classList.add('ce-inline-link-tool') | |
this.nodes.actionContainer.classList.add(this.CSS.input) | |
// the submit button applies the inline-tool styling to the selected text | |
this.nodes.buttonSubmit = document.createElement('button') | |
this.nodes.buttonSubmit.innerText = 'OK' | |
this.nodes.buttonSubmit.onclick = this.dataEntered.bind(this) | |
// renders each input container (eg. url, href, target) | |
ELEMENTS.forEach(element => { | |
// wrap each element with a container and a label for better UI/UX | |
const containerName = `${element.name}Container` as keyof IContainers | |
this.nodes[containerName] = document.createElement('div') | |
this.nodes[containerName].classList.add('ce-inline-tool-container') | |
const label = document.createElement('label') | |
label.innerHTML = `<span>${element.label}</span>` | |
// the element itself, with special treatment of checkbox elements | |
this.nodes[element.name] = document.createElement(element.type) | |
Object.keys(element.props).forEach((key: keyof typeof element.props) => { | |
this.nodes[element.name][key] = element.props[key] | |
if (element.props.type === 'checkbox') { | |
this.nodes[element.name].checked = true | |
} | |
}) | |
this.nodes[element.name].addEventListener( | |
'keydown', | |
(event: KeyboardEvent) => { | |
// prevent the default action of the ENTER_KEY or the inline-tool will | |
// close | |
if (event.keyCode === this.ENTER_KEY) { | |
event.preventDefault() | |
} | |
}, | |
) | |
// DOM: Container -> Label -> Input (+ Description) | |
label.appendChild(this.nodes[element.name]) | |
if (element.description) { | |
const description = document.createElement('span') | |
description.innerText = element.description | |
label.appendChild(description) | |
} | |
this.nodes[containerName].appendChild(label) | |
this.nodes.actionContainer.appendChild(this.nodes[containerName]) | |
}) | |
this.nodes.actionContainer.appendChild(this.nodes.buttonSubmit) | |
return this.nodes.actionContainer | |
} | |
/** | |
* Handle clicks on the Inline Toolbar icon | |
* @param {Range} range | |
*/ | |
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.apiSelection.findParentTag('A') | |
/** | |
* Unlink icon pressed | |
*/ | |
if (parentAnchor) { | |
this.apiSelection.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 | |
* @param {Selection} selection | |
*/ | |
public checkState(): boolean { | |
const anchorTag = this.apiSelection.findParentTag('A') | |
if (anchorTag) { | |
this.nodes.button.classList.add(this.CSS.buttonActive) | |
this.openActions() | |
/** | |
* Fill input values | |
*/ | |
const hrefAttr = anchorTag.getAttribute('href') | |
const titleAttr = anchorTag.getAttribute('title') | |
const targetAttr = anchorTag.getAttribute('target') | |
this.nodes.input.value = hrefAttr || '' | |
this.nodes.title.value = titleAttr || '' | |
this.nodes.target.checked = targetAttr === '_blank' | |
// save the current selection, because the editor will loose its selection | |
// during editing links. The selection will be restored later again. | |
this.selection.save() | |
} else { | |
this.nodes.button.classList.remove(this.CSS.buttonActive) | |
} | |
return !!anchorTag | |
} | |
/** | |
* Function called with Inline Toolbar closing | |
*/ | |
public clear(): void { | |
this.closeActions() | |
} | |
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.actionContainer.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() | |
} | |
// reset stylings and values | |
this.nodes.actionContainer.classList.remove(this.CSS.inputShowed) | |
this.nodes.input.value = '' | |
this.nodes.title.value = '' | |
this.nodes.target.checked = true | |
if (clearSavedSelection) { | |
this.selection.clearSaved() | |
} | |
this.inputOpened = false | |
} | |
/** | |
* Submit button pressed | |
*/ | |
private dataEntered(event: MouseEvent): void { | |
let value = this.nodes.input.value || '' | |
if (!value.trim()) { | |
this.selection.restore() | |
this.unlink() | |
event.preventDefault() | |
this.closeActions() | |
} | |
if (!this.validateURL(value)) { | |
this.notifier.show({ | |
message: 'Pasted link is not valid.', | |
style: 'error', | |
}) | |
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 | |
* @return {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 { | |
let newLink = link | |
newLink = newLink.trim() | |
newLink = this.addProtocol(newLink) | |
return newLink | |
} | |
/** | |
* Add 'http' protocol to the links like 'example.com', 'google.com' | |
* @param {String} link | |
*/ | |
private addProtocol(link: string): string { | |
let newLink = link | |
/** | |
* If protocol already exists, do nothing | |
*/ | |
if (/^(\w+):(\/\/)?/.test(newLink)) { | |
return newLink | |
} | |
/** | |
* 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(newLink) | |
const isAnchor = newLink.substring(0, 1) === '#' | |
const isProtocolRelative = /^\/\/[^/\s]/.test(newLink) | |
if (!isInternal && !isAnchor && !isProtocolRelative) { | |
newLink = `http://${newLink}` | |
} | |
return newLink | |
} | |
/** | |
* Inserts <a> tag with "href", "title" and "target" | |
* @param {string} link - "href" value | |
*/ | |
private insertLink(link: string): void { | |
/** | |
* Edit all link, not selected part | |
*/ | |
const anchorTag = this.apiSelection.findParentTag('A') | |
if (anchorTag) { | |
this.apiSelection.expandToTag(anchorTag) | |
} | |
const target = this.nodes.target.checked ? 'target="_blank"' : '' | |
const title = | |
this.nodes.title.value !== '' ? `title="${this.nodes.title.value}"` : '' | |
document.execCommand( | |
this.commandLink, | |
false, | |
`<a href="${link}" ${target} ${title}>${SelectionUtils.text}</a>`, | |
) | |
} | |
/** | |
* Removes <a> tag | |
*/ | |
private unlink(): void { | |
document.execCommand(this.commandUnlink) | |
} | |
} |
/** | |
* NOTE: SelectionUtils was mainly taken from EditorJS, due to the fact that it | |
* is required to properly create a custom LinkInlineTool. EditorJs does not | |
* expose all required methods, so we had to copy it. | |
* ==> Issue: https://github.com/codex-team/editor.js/issues/1066 | |
* | |
* @see https://github.com/codex-team/editor.js/blob/737ba2abb423665257cf6ccc5e8472742433322f/src/components/selection.ts | |
*/ | |
/** | |
* Working with selection | |
* @typedef {SelectionUtils} SelectionUtils | |
*/ | |
export default class SelectionUtils { | |
public instance: Selection = null | |
public selection: Selection = null | |
/** | |
* This property can store SelectionUtils's range for restoring later | |
* @type {Range|null} | |
*/ | |
public savedSelectionRange: Range = null | |
/** | |
* Fake background is active | |
* | |
* @return {boolean} | |
*/ | |
public isFakeBackgroundEnabled = false | |
/** | |
* Native Document's commands for fake background | |
*/ | |
private readonly commandBackground: string = 'backColor' | |
private readonly commandRemoveFormat: string = 'removeFormat' | |
/** | |
* Return first range | |
* @return {Range|null} | |
*/ | |
static get range(): Range { | |
const selection = window.getSelection() | |
return selection && selection.rangeCount ? selection.getRangeAt(0) : null | |
} | |
/** | |
* Returns selected text as String | |
* @returns {string} | |
*/ | |
static get text(): string { | |
return window.getSelection ? window.getSelection().toString() : '' | |
} | |
/** | |
* Returns window SelectionUtils | |
* {@link https://developer.mozilla.org/ru/docs/Web/API/Window/getSelection} | |
* @return {Selection} | |
*/ | |
public static get(): Selection { | |
return window.getSelection() | |
} | |
/** | |
* Removes fake background | |
*/ | |
public removeFakeBackground() { | |
if (!this.isFakeBackgroundEnabled) { | |
return | |
} | |
this.isFakeBackgroundEnabled = false | |
document.execCommand(this.commandRemoveFormat) | |
} | |
/** | |
* Sets fake background | |
*/ | |
public setFakeBackground() { | |
document.execCommand(this.commandBackground, false, '#a8d6ff') | |
this.isFakeBackgroundEnabled = true | |
} | |
/** | |
* Save SelectionUtils's range | |
*/ | |
public save(): void { | |
this.savedSelectionRange = SelectionUtils.range | |
} | |
/** | |
* Restore saved SelectionUtils's range | |
*/ | |
public restore(): void { | |
if (!this.savedSelectionRange) { | |
return | |
} | |
const sel = window.getSelection() | |
sel.removeAllRanges() | |
sel.addRange(this.savedSelectionRange) | |
} | |
/** | |
* Clears saved selection | |
*/ | |
public clearSaved(): void { | |
this.savedSelectionRange = null | |
} | |
/** | |
* Collapse current selection | |
*/ | |
public collapseToEnd(): void { | |
const sel = window.getSelection() | |
const range = document.createRange() | |
range.selectNodeContents(sel.focusNode) | |
range.collapse(false) | |
sel.removeAllRanges() | |
sel.addRange(range) | |
} | |
} |
Hi @RobinDev,
I am afraid I can‘t be of help for you. I am not working with the Editor at the moment and can‘t recall if we had that bug or how we solved it.
Hopefully you find a solution soon!
Did you face this issue and did you resolve it ?
codex-team/editor.js#2821