284 lines
8.7 KiB
JavaScript
284 lines
8.7 KiB
JavaScript
// const ZERO_WIDTH_SPACE = '​';
|
|
const ZERO_WIDTH_SPACE = '​';
|
|
class EditableInputModel {
|
|
|
|
constructor(value = '') {
|
|
this.value = value;
|
|
}
|
|
|
|
setValue(newValue) {
|
|
this.value = newValue;
|
|
}
|
|
|
|
getValue() {
|
|
return this.value;
|
|
}
|
|
}
|
|
|
|
class EditableInputView {
|
|
constructor(model, controller) {
|
|
this.model = model;
|
|
this.controller = controller;
|
|
}
|
|
|
|
render(element) {
|
|
this.element = element;
|
|
this.element.innerHTML = this.generateInputHtml();
|
|
this.editableDiv = this.element.querySelector('.editableInputContent');
|
|
this.attachEventListeners();
|
|
}
|
|
|
|
generateInputHtml() {
|
|
let contentHtml = this.model.getValue() ? this.renderContent(this.parseContent(this.model.getValue())) : ZERO_WIDTH_SPACE;
|
|
return `
|
|
<div contenteditable="true" class="editableInputContent form-control" data-value="${this.model.getValue()}">
|
|
${contentHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
attachEventListeners() {
|
|
this.editableDiv.addEventListener('input', (event) => {
|
|
this.controller.handleInput(this.editableDiv);
|
|
if (this.controller.additionalCallback) {
|
|
this.controller.additionalCallback(this.model.getValue());
|
|
}
|
|
});
|
|
this.editableDiv.addEventListener('paste', (event) => {
|
|
event.preventDefault();
|
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
navigator.clipboard.readText().then(text => {
|
|
this.insertTextAtCursor(text);
|
|
}).catch(err => {
|
|
console.error('Failed to read clipboard contents: ', err);
|
|
// Fallback to using clipboardData from the paste event
|
|
const text = (event.clipboardData || window.clipboardData).getData('text');
|
|
this.insertTextAtCursor(text);
|
|
});
|
|
} else {
|
|
const text = (event.clipboardData || window.clipboardData).getData('text');
|
|
this.insertTextAtCursor(text);
|
|
}
|
|
});
|
|
}
|
|
|
|
insertTextAtCursor(text) {
|
|
const selection = window.getSelection();
|
|
if (!selection.rangeCount) return;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
range.deleteContents();
|
|
const textNode = document.createTextNode(text);
|
|
range.insertNode(textNode);
|
|
|
|
range.setStartAfter(textNode);
|
|
range.setEndAfter(textNode);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
this.controller.handleInput(this.editableDiv);
|
|
}
|
|
|
|
updateUI() {
|
|
let content = this.model.getValue();
|
|
if (content) {
|
|
this.editableDiv.innerHTML = this.renderContent(this.parseContent(content));
|
|
} else {
|
|
this.editableDiv.innerHTML = ZERO_WIDTH_SPACE; // Insert a zero-width space
|
|
}
|
|
|
|
this.editableDiv.setAttribute('data-value', content);
|
|
}
|
|
|
|
renderContent(contentArray) {
|
|
let content = '';
|
|
contentArray.forEach((item) => {
|
|
|
|
if (item.startsWith('{{') && item.endsWith('}}')) {
|
|
content += `<span class="variable">${item}</span>`;
|
|
} else {
|
|
content += `<span class="text">${item}</span>`;
|
|
}
|
|
});
|
|
return content;
|
|
}
|
|
|
|
parseContent(inputString) {
|
|
let returnStringArray = [];
|
|
let regex = /{{.*?}}/g;
|
|
let lastIndex = 0;
|
|
|
|
inputString.replace(regex, (match, index) => {
|
|
// Add the string before the match
|
|
if (index > lastIndex) {
|
|
returnStringArray.push(inputString.slice(lastIndex, index));
|
|
}
|
|
|
|
// Add the matched string including the delimiters
|
|
returnStringArray.push(match);
|
|
|
|
// Update lastIndex for the next iteration
|
|
lastIndex = index + match.length;
|
|
});
|
|
|
|
// Add any remaining string after the last match
|
|
if (lastIndex < inputString.length) {
|
|
returnStringArray.push(inputString.slice(lastIndex));
|
|
}
|
|
|
|
return returnStringArray;
|
|
}
|
|
}
|
|
|
|
|
|
class EditableInputController {
|
|
constructor(model) {
|
|
this.model = model;
|
|
this.view = new EditableInputView(this.model, this);
|
|
}
|
|
|
|
init(element, callback) {
|
|
this.view.render(element);
|
|
this.additionalCallback = callback;
|
|
}
|
|
|
|
|
|
getCurrentCursor(element) {
|
|
const selection = window.getSelection();
|
|
if (selection.rangeCount === 0) return null;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const preCaretRange = range.cloneRange();
|
|
preCaretRange.selectNodeContents(element);
|
|
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
|
return preCaretRange.toString().trim().length;
|
|
}
|
|
|
|
handleInput(editableDiv) {
|
|
// Check and remove zero-width space if it's the only content
|
|
if (editableDiv.innerHTML === '​') {
|
|
editableDiv.innerHTML = '';
|
|
}
|
|
|
|
let cursor = this.getCurrentCursor(editableDiv);
|
|
let mergedString = Array.from(editableDiv.childNodes)
|
|
.filter(function (node) {
|
|
// Keep only SPAN elements and text nodes
|
|
return node.nodeName.toUpperCase() === 'SPAN' || node.nodeType === 3;
|
|
})
|
|
.map(node => {
|
|
return node.nodeType === 3 ? node.data.trim() : node.innerText.trim();
|
|
})
|
|
.join('');
|
|
|
|
var result = mergedString.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
|
|
|
let content = this.parseContent(result);
|
|
|
|
this.model.setValue(content.join(''));
|
|
// Optionally, update the view if needed
|
|
this.view.updateUI();
|
|
|
|
this.restoreCursor(editableDiv, cursor);
|
|
}
|
|
|
|
parseContent(inputString) {
|
|
let returnStringArray = [];
|
|
let regex = /{{.*?}}/g;
|
|
let lastIndex = 0;
|
|
|
|
inputString.replace(regex, (match, index) => {
|
|
// Add the string before the match
|
|
if (index > lastIndex) {
|
|
returnStringArray.push(inputString.slice(lastIndex, index));
|
|
}
|
|
|
|
// Add the matched string including the delimiters
|
|
returnStringArray.push(match);
|
|
|
|
// Update lastIndex for the next iteration
|
|
lastIndex = index + match.length;
|
|
});
|
|
|
|
// Add any remaining string after the last match
|
|
if (lastIndex < inputString.length) {
|
|
returnStringArray.push(inputString.slice(lastIndex));
|
|
}
|
|
|
|
return returnStringArray;
|
|
}
|
|
|
|
restoreCursor(element, position) {
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
range.setStart(element, 0);
|
|
range.collapse(true);
|
|
|
|
const nodeStack = [element];
|
|
let node, foundStart = false, stop = false;
|
|
let charIndex = 0;
|
|
|
|
while (!stop && (node = nodeStack.pop())) {
|
|
if (node.nodeType === 3) {
|
|
const nextCharIndex = charIndex + node.length;
|
|
if (!foundStart && position >= charIndex && position <= nextCharIndex) {
|
|
range.setStart(node, position - charIndex);
|
|
foundStart = true;
|
|
}
|
|
if (foundStart && position >= charIndex && position <= nextCharIndex) {
|
|
range.setEnd(node, position - charIndex);
|
|
stop = true;
|
|
}
|
|
charIndex = nextCharIndex;
|
|
} else {
|
|
let i = node.childNodes.length;
|
|
while (i--) {
|
|
nodeStack.push(node.childNodes[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
|
|
updateUI() {
|
|
this.view.updateUI();
|
|
}
|
|
}
|
|
|
|
class EditableInput {
|
|
constructor(options = {}) {
|
|
// Initialize the model with the value
|
|
this.model = new EditableInputModel(options.value || '');
|
|
this.controller = new EditableInputController(this.model);
|
|
|
|
// Additional properties as required
|
|
this.name = options.name || '';
|
|
this.id = options.id || '';
|
|
this.class = options.class || 'form-control';
|
|
this.placeholder = options.placeholder || '';
|
|
}
|
|
|
|
init(element, callback) {
|
|
// Initialize the controller with the DOM element
|
|
this.controller.init(element, callback);
|
|
}
|
|
|
|
getValue() {
|
|
// Delegate to the model
|
|
return this.model.getValue();
|
|
}
|
|
|
|
setValue(newValue) {
|
|
// Delegate to the model and update the view
|
|
this.model.setValue(newValue);
|
|
this.controller.updateUI();
|
|
}
|
|
|
|
// Any additional methods required for interaction with this component
|
|
}
|
|
|
|
export default EditableInput;
|
|
|