html_editor_enhanced/lib/assets/plugins/summernote-at-mention/summernote-at-mention.js
2022-10-30 15:25:33 +07:00

404 lines
12 KiB
JavaScript

class SelectionPreserver {
constructor(rootNode) {
if (rootNode === undefined || rootNode === null) {
throw new Error("Please provide a valid rootNode.");
}
this.rootNode = rootNode;
this.rangeStartContainerAddress = null;
this.rangeStartOffset = null;
}
preserve() {
const selection = window.getSelection();
this.rangeStartOffset = selection.getRangeAt(0).startOffset;
this.rangeStartContainerAddress = this.findRangeStartContainerAddress(
selection
);
}
restore(restoreIndex) {
if (
this.rangeStartOffset === null ||
this.rangeStartContainerAddress === null
) {
throw new Error("Please call preserve() first.");
}
let rangeStartContainer = this.findRangeStartContainer();
const range = document.createRange();
const offSet = restoreIndex || this.rangeStartOffset;
range.setStart(rangeStartContainer, offSet);
range.collapse();
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
findRangeStartContainer() {
let rangeStartContainer = this.rootNode;
this.rangeStartContainerAddress.forEach(address => {
rangeStartContainer = rangeStartContainer.childNodes[address];
});
return rangeStartContainer;
}
findRangeStartContainerAddress(selection) {
let rangeStartContainerAddress = [];
for (
let currentContainer = selection.getRangeAt(0).startContainer;
currentContainer !== this.rootNode;
currentContainer = currentContainer.parentNode
) {
const parent = currentContainer.parentElement;
const children = parent.childNodes;
for (let i = 0; i < children.length; i++) {
if (children[i] === currentContainer) {
rangeStartContainerAddress = [i, ...rangeStartContainerAddress];
break;
}
}
}
return rangeStartContainerAddress;
}
}
const WORD_REGEX = /^[^\s]+$/;
const UP_KEY_CODE = 38;
const DOWN_KEY_CODE = 40;
const ENTER_KEY_CODE = 13;
(function(factory) {
if (typeof define === "function" && define.amd) {
define(["jquery"], factory);
} else if (typeof module === "object" && module.exports) {
module.exports = factory(require("jquery"));
} else {
factory(window.jQuery);
}
})(function($) {
$.extend($.summernote.plugins, {
summernoteAtMention: function(context) {
/************************
* Setup instance vars. *
************************/
this.editableEl = context.layoutInfo.editable[0];
this.editorEl = context.layoutInfo.editor[0];
this.autocompleteAnchor = { left: null, top: null };
this.autocompleteContainer = null;
this.showingAutocomplete = false;
this.selectedIndex = null;
this.suggestions = null;
this.getSuggestions = _ => {
return [];
};
/********************
* Read-in options. *
********************/
if (
context.options &&
context.options.callbacks &&
context.options.callbacks.summernoteAtMention
) {
const summernoteCallbacks =
context.options.callbacks.summernoteAtMention;
if (summernoteCallbacks.getSuggestions) {
this.getSuggestions = summernoteCallbacks.getSuggestions;
}
if (summernoteCallbacks.onSelect) {
this.onSelect = summernoteCallbacks.onSelect;
}
}
/**********
* Events *
**********/
this.events = {
"summernote.blur": () => {
if (this.showingAutocomplete) this.hideAutocomplete();
},
"summernote.keydown": (_, event) => {
if (this.showingAutocomplete) {
switch (event.keyCode) {
case ENTER_KEY_CODE: {
event.preventDefault();
event.stopPropagation();
this.handleEnter();
break;
}
case UP_KEY_CODE: {
event.preventDefault();
event.stopPropagation();
const newIndex =
this.selectedIndex === 0 ? 0 : this.selectedIndex - 1;
this.updateAutocomplete(this.suggestions, newIndex);
break;
}
case DOWN_KEY_CODE: {
event.preventDefault();
event.stopPropagation();
const newIndex =
this.selectedIndex === this.suggestions.length - 1
? this.selectedIndex
: this.selectedIndex + 1;
this.updateAutocomplete(this.suggestions, newIndex);
break;
}
}
}
},
"summernote.keyup": async (_, event) => {
const selection = document.getSelection();
const currentText = selection.anchorNode.nodeValue;
const { word, absoluteIndex } = this.findWordAndIndices(
currentText || "",
selection.anchorOffset
);
const trimmedWord = word.slice(1);
if (
this.showingAutocomplete &&
![DOWN_KEY_CODE, UP_KEY_CODE, ENTER_KEY_CODE].includes(
event.keyCode
)
) {
if (word[0] === "@") {
const suggestions = await this.getSuggestions(trimmedWord);
this.updateAutocomplete(suggestions, this.selectedIndex);
} else {
this.hideAutocomplete();
}
} else if (!this.showingAutocomplete && word[0] === "@") {
this.suggestions = await this.getSuggestions(trimmedWord);
this.selectedIndex = 0;
this.showAutocomplete(absoluteIndex, selection.anchorNode);
}
}
};
/***********
* Helpers *
***********/
this.handleEnter = () => {
this.handleSelection();
};
this.handleClick = suggestion => {
const selectedIndex = this.suggestions.findIndex(s => s === suggestion);
if (selectedIndex === -1) {
throw new Error("Unable to find suggestion in suggestions.");
}
this.selectedIndex = selectedIndex;
this.handleSelection();
};
this.handleSelection = () => {
if (this.suggestions === null || this.suggestions.length === 0) {
return;
}
const newWord = this.suggestions[this.selectedIndex].trimStart();
if (this.onSelect !== undefined) {
this.onSelect(newWord);
}
const selection = document.getSelection();
const currentText = selection.anchorNode.nodeValue;
const { word, absoluteIndex } = this.findWordAndIndices(
currentText || "",
selection.anchorOffset
);
const selectionPreserver = new SelectionPreserver(this.editableEl);
selectionPreserver.preserve();
selection.anchorNode.textContent =
currentText.slice(0, absoluteIndex + 1) +
newWord +
" " +
currentText.slice(absoluteIndex + word.length);
selectionPreserver.restore(absoluteIndex + newWord.length + 1);
if (context.options.callbacks.onChange !== undefined) {
context.options.callbacks.onChange(this.editableEl.innerHTML);
}
};
this.updateAutocomplete = (suggestions, selectedIndex) => {
this.selectedIndex = selectedIndex;
this.suggestions = suggestions;
this.renderAutocomplete();
};
this.showAutocomplete = (atTextIndex, indexAnchor) => {
if (this.showingAutocomplete) {
throw new Error(
"Cannot call showAutocomplete if autocomplete is already showing."
);
}
this.setAutocompleteAnchor(atTextIndex, indexAnchor);
this.renderAutocompleteContainer();
this.renderAutocomplete();
this.showingAutocomplete = true;
};
this.renderAutocompleteContainer = () => {
this.autocompleteContainer = document.createElement("div");
this.autocompleteContainer.style.top =
String(this.autocompleteAnchor.top) + "px";
this.autocompleteContainer.style.left =
String(this.autocompleteAnchor.left) + "px";
this.autocompleteContainer.style.position = "absolute";
this.autocompleteContainer.style.backgroundColor = "#e4e4e4";
this.autocompleteContainer.style.zIndex = Number.MAX_SAFE_INTEGER;
document.body.appendChild(this.autocompleteContainer);
};
this.renderAutocomplete = () => {
if (this.autocompleteContainer === null) {
throw new Error(
"Cannot call renderAutocomplete without an autocompleteContainer. "
);
}
const autocompleteContent = document.createElement("div");
this.suggestions.forEach((suggestion, idx) => {
const suggestionDiv = document.createElement("div");
suggestionDiv.textContent = suggestion;
suggestionDiv.style.padding = "5px 10px";
if (this.selectedIndex === idx) {
suggestionDiv.style.backgroundColor = "#2e6da4";
suggestionDiv.style.color = "white";
}
suggestionDiv.addEventListener("mousedown", () => {
this.handleClick(suggestion);
});
autocompleteContent.appendChild(suggestionDiv);
});
this.autocompleteContainer.innerHTML = "";
this.autocompleteContainer.appendChild(autocompleteContent);
};
this.hideAutocomplete = () => {
if (!this.showingAutocomplete)
throw new Error(
"Cannot call hideAutocomplete if autocomplete is not showing."
);
document.body.removeChild(this.autocompleteContainer);
this.autocompleteAnchor = { left: null, top: null };
this.selectedIndex = null;
this.suggestions = null;
this.showingAutocomplete = false;
};
this.findWordAndIndices = (text, offset) => {
if (offset > text.length) {
return { word: "", relativeIndex: 0 };
} else {
let leftWord = "";
let rightWord = "";
let relativeIndex = 0;
let absoluteIndex = offset;
for (let currentOffset = offset; currentOffset > 0; currentOffset--) {
if (text[currentOffset - 1].match(WORD_REGEX)) {
leftWord = text[currentOffset - 1] + leftWord;
relativeIndex++;
absoluteIndex--;
} else {
break;
}
}
for (
let currentOffset = offset - 1;
currentOffset > 0 && currentOffset < text.length - 1;
currentOffset++
) {
if (text[currentOffset + 1].match(WORD_REGEX)) {
rightWord = rightWord + text[currentOffset + 1];
} else {
break;
}
}
return {
word: leftWord + rightWord,
relativeIndex,
absoluteIndex
};
}
};
this.setAutocompleteAnchor = (atTextIndex, indexAnchor) => {
let html = indexAnchor.parentNode.innerHTML;
const text = indexAnchor.nodeValue;
let atIndex = -1;
for (let i = 0; i <= atTextIndex; i++) {
if (text[i] === "@") {
atIndex++;
}
}
let htmlIndex;
for (let i = 0, htmlAtIndex = 0; i < html.length; i++) {
if (html[i] === "@") {
if (htmlAtIndex === atIndex) {
htmlIndex = i;
break;
} else {
htmlAtIndex++;
}
}
}
const atNodeId = "at-node-" + String(Math.floor(Math.random() * 10000));
const spanString = `<span id="${atNodeId}">@</span>`;
const selectionPreserver = new SelectionPreserver(this.editableEl);
selectionPreserver.preserve();
indexAnchor.parentNode.innerHTML =
html.slice(0, htmlIndex) + spanString + html.slice(htmlIndex + 1);
const anchorElement = document.querySelector("#" + atNodeId);
const anchorBoundingRect = anchorElement.getBoundingClientRect();
this.autocompleteAnchor = {
top: anchorBoundingRect.top + anchorBoundingRect.height + 2,
left: anchorBoundingRect.left
};
selectionPreserver.findRangeStartContainer().parentNode.innerHTML = html;
selectionPreserver.restore();
};
}
});
});