Skip to content

Instantly share code, notes, and snippets.

@bettysteger
Last active April 7, 2024 08:13
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bettysteger/d8f660030849ae85211d51d93e736a3b to your computer and use it in GitHub Desktop.
Save bettysteger/d8f660030849ae85211d51d93e736a3b to your computer and use it in GitHub Desktop.
Editor.js [Inline Tool] Mentions
import mentionsService from 'mentionsService.js'
import InlineMention from 'inlineMention.js'
/**
* this is just a code snippet on how to use the Mentions Service and Inline Tool
*/
const editor = new EditorJS({
holder: 'editorjs',
inlineToolbar: ['bold', 'italic', 'link', 'inlineMention'],
tools: {
inlineMention: InlineMention,
},
onReady: () => {
// this will start listing to '@' input
mentionsService.init(editor)
},
})
/**
* Saving and getting mentioned user ids out of blocks
*/
editor.save().then(outputData => {
const mentions = mentionUserIds(outputData.blocks);
console.log('mentions', mentions);
});
function mentionUserIds(blocks) {
const content = blocks.map(block => block.data.text || '').join('');
if (!content.includes('rel="tag"')) { return []; }
const mentions = [];
const tempEl = document.createElement('div');
tempEl.innerHTML = content;
// See inlineMention.js: <a href="#user-id" rel="tag">@username</a>
tempEl.querySelectorAll('a[rel=tag][href]').forEach((a) => {
// remove # first character from href attribute
mentions.push(a.getAttribute('href').substring(1));
});
return mentions;
}
import mentionsService from "mentionsService.js";
export default class InlineMention {
static get isInline() {
return true;
}
static get sanitize() {
return {
a: {
href: true,
rel: true
}
};
}
get state() {
return this._state;
}
set state(state) {
this._state = state;
}
/**
* same as in editorjs/components/inline-tools/inline-tool-link.ts
*/
get CSS() {
return {
input: 'ce-inline-tool-input',
inputShowed: 'ce-inline-tool-input--showed',
ul: 'tiny',
liActive: 'bg-muted'
};
}
constructor({ api }) {
this.api = api;
this._state = false;
this.nodes = {
input: null,
userList: null,
anchor: null,
};
}
/**
* Invisible button, will be triggered by typing @
* @see mentionsService.init
*/
render() {
return document.createElement('span');
}
surround(range) {}
checkState() {
const a = this.api.selection.findParentTag('A');
this.state = a?.rel === 'tag';
if (this.state) {
this.showActions(a);
} else {
this.hideActions();
}
}
renderActions() {
this.wrapper = document.createElement('div');
this.nodes.input = document.createElement('input');
this.nodes.input.placeholder = 'Search for a user';
this.nodes.input.classList.add(this.CSS.input);
this.nodes.input.addEventListener('input', (e) => {
this.getMentions(e.target.value)
});
this.nodes.input.addEventListener('keydown', (event) => {
if (event.keyCode === 13) {
this.enterPressed(event);
}
if (event.keyCode === 40 || event.keyCode === 38) {
this.downOrUpPressed(event);
}
});
this.wrapper.appendChild(this.nodes.input);
return this.wrapper;
}
getMentions(q) {
mentionsService.getMentions(q).then((users) => {
this.nodes.userList.innerHTML = '';
users.forEach((user, i) => {
const li = document.createElement('li');
li.innerHTML = mentionsService.outputTemplate(user);
if (i === 0) { li.classList.add(this.CSS.liActive); }
li.addEventListener('click', (e) => {
e.preventDefault();
const a = this.nodes.anchor
a.innerHTML = '@' + user.name;
a.href = '#' + user.id;
// adds a space after the mention
a.insertAdjacentHTML('afterend', '&nbsp;');
// focus after the space
this.setCursor(a.nextSibling, 1)
});
this.nodes.userList.appendChild(li);
});
});
}
enterPressed(event) {
event.preventDefault();
const firstLi = this.nodes.userList.querySelector(`li.${this.CSS.liActive}`);
if (!firstLi) { return; }
firstLi.click();
}
downOrUpPressed(event) {
event.preventDefault();
const currentLi = this.nodes.userList.querySelector(`li.${this.CSS.liActive}`);
const nextLi = event.keyCode === 40 ? currentLi?.nextSibling : currentLi?.previousSibling;
if (nextLi) {
currentLi.classList.remove(this.CSS.liActive);
nextLi.classList.add(this.CSS.liActive);
}
}
showActions(a) {
if (this.nodes.userList) { return; }
// remove input from original link inline tool
document.querySelector(`.${this.CSS.inputShowed}`).remove();
if (a.href) { return; }
this.nodes.anchor = a; // save anchor for later
this.nodes.input.classList.add(`${this.CSS.inputShowed}`);
setTimeout(() => { this.nodes.input.focus(); }); // wait for input to be visible
this.nodes.userList = document.createElement('ul');
this.nodes.userList.classList.add(this.CSS.ul);
this.wrapper.appendChild(this.nodes.userList);
this.getMentions();
}
hideActions() {
this.nodes.input.classList.remove(this.CSS.inputShowed);
this.nodes.input.value = '';
this.nodes.anchor = null;
if (this.nodes.userList) {
this.nodes.userList.remove();
this.nodes.userList = null;
}
}
clear() {
this.hideActions();
}
setCursor(element, offset) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(element, offset);
range.setEnd(element, offset);
selection.removeAllRanges();
selection.addRange(range);
return range.getBoundingClientRect();
}
}
const trigger = '@';
const keyCombos = { // will be set to true on keydown, check happens in keyup
'2': false, // Shift+2
l: false, // ⌥+L on Mac on a german keyboard
q: false // AltGr+Q on Windows on a german keyboard
};
/**
* Service methods for mentions in EditorJS
*/
export default {
init: (editor) => {
editor.listeners.on(editor.ui.nodes.wrapper, 'keydown', (e) => {
keyCombos.l = e.altKey;
keyCombos['2'] = e.shiftKey;
keyCombos.q = e.key === 'AltGraph';
});
editor.listeners.on(editor.ui.nodes.wrapper, 'keyup', (e) => {
// just checking '@' is not enough, because when pressing key combos too fast the '@' is not registered
if(e.key === trigger || keyCombos[e.key]) {
keyCombos[e.key] = false;
// add inline mention tool to current block, by replacing @ (at the end) with <a rel="tag">@</a>
const currentIdx = editor.blocks.getCurrentBlockIndex();
const currentBlock = editor.blocks.getBlockByIndex(currentIdx);
const paragraph = currentBlock.holder.querySelector('.ce-paragraph');
const regex = new RegExp(`${trigger}$`);
if (!paragraph || !paragraph.innerHTML.match(regex)) { return; }
paragraph.innerHTML = paragraph.innerHTML.replace(regex, `<a rel="tag">${trigger}</a>`);
const anchor = editor.ui.nodes.wrapper.querySelector('a[rel="tag"]:not([href])')
editor.selection.expandToTag(anchor); // this will open up the inline tool, see inlineMention.js
}
});
},
getMentions: async (q) => {
return fetch(`/api/users?q=${q}`)
.then(response => response.json())
.then(json => json.data);
},
outputTemplate: (user) => {
return `<a href="#${user.id}" rel="tag">${user.name}</a>`;
}
}
@danielquintao
Copy link

Very nice! I have another use case (not mentioning people) but I also needed to activate an inline tool by keyboard shortcut without necessarily selecting text, and your gist provided a great starting point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment