blob: 09a132ce1afe2ac5dd0bf2c6a82b08ffef8b06c0 [file]
/**
* SimpleHtml Editor
* A minimal, modular, secure rich text editor.
*/
const SIMPLE_HTML_ALLOWED_TAGS = {
'b': [], 'strong': [], 'i': [], 'em': [], 'u': [],
'p': [], 'div': [],
'h1': [], 'h2': [], 'h3': [],
'blockquote': [],
'ul': [], 'ol': [], 'li': [],
'a': ['href', 'target', 'title', 'rel'],
'img': ['src', 'alt', 'width', 'height'],
'table': [],
'thead': [], 'tbody': [], 'tfoot': [],
'tr': [], 'td': ['colspan', 'rowspan'], 'th': ['colspan', 'rowspan'],
'code': [], 'pre': [], 'br': [], 'span': [], 'dd': []
};
const SIMPLE_HTML_DISALLOWED_PROTOCOLS = ['javascript:', 'vbscript:', 'data:'];
const SIMPLE_HTML_ALLOWED_IMAGE_MIME_TYPES = ['image/gif', 'image/png', 'image/jpeg'];
const SIMPLE_HTML_ALLOWED_DATA_IMAGE_PATTERN = /^data:image\/(?:gif|png|jpeg)(?:;[^,]*)?,/i;
class SimpleHtml {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
if (!this.container) throw new Error('SimpleHtml: Container element not found.');
this.options = Object.assign({
content: '',
placeholder: 'Start typing...',
toolbar: [
'bold', 'italic', 'underline', '|',
'h1', 'h2', 'h3', 'p', 'blockquote', 'code', 'removeFormat','|',
'insertUnorderedList', 'insertOrderedList', '|',
'link', 'unlink', 'image', 'table', '|',
'table_row_plus', 'table_row_minus', 'table_col_plus', 'table_col_minus', '|',
'undo', 'redo', '|',
'source'
],
buttonClass: {
blockquote: 'quote',
insertUnorderedList:'bullet',
insertOrderedList:'numbered',
code:'console',
image: 'pic',
source:'markup',
'table_row_plus':'add-row-down',
'table_row_minus':'row-red',
'table_col_plus':'add-col-right',
'table_col_minus':'col-red',
removeFormat: 'noformat'
}
}, options);
// Undo/Redo Stacks
this.history = [];
this.historyIndex = -1;
this.maxHistory = 50;
this.allowedTags = SIMPLE_HTML_ALLOWED_TAGS;
// Block `javascript:` protocols
this.disallowedProtocols = SIMPLE_HTML_DISALLOWED_PROTOCOLS;
this.allowedImageMimeTypes = SIMPLE_HTML_ALLOWED_IMAGE_MIME_TYPES;
this.allowedDataImagePattern = SIMPLE_HTML_ALLOWED_DATA_IMAGE_PATTERN;
this.init();
}
static isAllowedImageMimeType(mimeType) {
return !!mimeType && SIMPLE_HTML_ALLOWED_IMAGE_MIME_TYPES.includes(mimeType.toLowerCase());
}
static isAllowedDataImageUrl(url) {
return !!url && SIMPLE_HTML_ALLOWED_DATA_IMAGE_PATTERN.test(url.trim());
}
static sanitizeHtmlFragment(html) {
return SimpleHtml.prototype.sanitize.call({
allowedTags: SIMPLE_HTML_ALLOWED_TAGS,
disallowedProtocols: SIMPLE_HTML_DISALLOWED_PROTOCOLS,
allowedDataImagePattern: SIMPLE_HTML_ALLOWED_DATA_IMAGE_PATTERN,
isAllowedDataImageUrl: function (url) {
return SimpleHtml.isAllowedDataImageUrl(url);
}
}, html);
}
init() {
// Clear container and setup structure
this.container.innerHTML = '';
this.container.classList.add('rnd');
// Create Toolbar
this.toolbar = document.createElement('div');
this.toolbar.className = 'vgh-toolbar';
this.container.appendChild(this.toolbar);
this.renderToolbar();
this.initUrlForm();
// Create Content Area
this.contentArea = document.createElement('div');
this.contentArea.className = 'vgh-content';
this.contentArea.contentEditable = true;
// Initial set (will trigger history save)
this.contentArea.innerHTML = this.sanitize(this.options.content);
this.container.appendChild(this.contentArea);
// Create Source View Textarea
this.sourceArea = document.createElement('textarea');
this.sourceArea.className = 'vgh-markup';
this.container.appendChild(this.sourceArea);
this.initTablePicker();
// Initialize History
this.saveHistory();
// Bind Events
this.bindEvents();
}
renderToolbar() {
this.toolbar.innerHTML = '';
this.options.toolbar.forEach(tool => {
if (tool === '|') {
const separator = document.createElement('span');
Object.assign(separator.style, { width: '1px', background: '#ddd', margin: '0 5px' });
this.toolbar.appendChild(separator);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.command = tool;
//btn.textContent = this.getButtonLabel(tool);
btn.title = tool; // Tooltip helper
btn.className = 'sbn vgi-' + (this.options.buttonClass[tool] || tool);
// Initial state for table buttons - might be disabled
if (tool.startsWith('table_')) {
// We'll manage visibility in updateToolbarState
btn.style.display = 'none';
}
btn.addEventListener('click', (e) => {
e.preventDefault();
this.execCommand(tool, btn);
});
this.toolbar.appendChild(btn);
});
}
getButtonLabel(tool) {
const labels = {
'bold': 'B', 'italic': 'I', 'underline': 'U',
'h1': 'H1', 'h2': 'H2', 'h3': 'H3',
'p': 'P', 'blockquote': '""', 'code': '{}',
'removeFormat':'Clear',
'insertUnorderedList': '•', 'insertOrderedList': '1.',
'link': '🔗', 'unlink': '⛓️',
'image': '🖼️', 'table': '▦',
'table_row_plus': '+Row', 'table_row_minus': '-Row',
'table_col_plus': '+Col', 'table_col_minus': '-Col',
'undo': '↩', 'redo': '↪',
'source': '</>'
};
return labels[tool] || tool;
}
bindEvents() {
// Debounce history save
let timer;
const updateToolbar = () => this.updateToolbarState();
const handleInput = () => {
clearTimeout(timer);
timer = setTimeout(() => this.saveHistory(), 500);
updateToolbar();
};
this.contentArea.addEventListener('input', handleInput);
['keyup', 'mouseup', 'click'].forEach((event) => {
this.contentArea.addEventListener(event, updateToolbar);
});
// Handle Paste
this.contentArea.addEventListener('paste', (e) => this.handlePaste(e));
document.addEventListener('mousedown', (e) => {
if (!this.tablePicker || this.tablePicker.style.display !== 'block') return;
if (this.tablePicker.contains(e.target)) return;
const tableBtn = this.toolbar.querySelector('button[data-command="table"]');
if (tableBtn && (tableBtn === e.target || tableBtn.contains(e.target))) return;
this.hideTablePicker();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.hideTablePicker();
this.hideUrlForm({ restoreSelection: true });
}
});
}
initUrlForm() {
if (this.urlForm) return;
this.urlForm = document.createElement('form');
Object.assign(this.urlForm, { className: 'simplehtml-url-form', noValidate: true });
this.urlForm.style.display = 'none';
this.urlForm.setAttribute('aria-hidden', 'true');
this.urlInput = document.createElement('input');
Object.assign(this.urlInput, { type: 'url', className: 'txt simplehtml-url-input', placeholder: 'Enter URL' });
this.urlInput.setAttribute('aria-label', 'URL');
this.urlUploadButton = document.createElement('span');
var b = document.createElement('button');
Object.assign(b, { type: 'button', className: 'btn vgi-folder', textContent: 'Import File' });
//b.style.display = 'none';
this.urlUploadButton.append(document.createTextNode(' or '), b);
this.urlFileInput = document.createElement('input');
Object.assign(this.urlFileInput, {
type: 'file',
accept: this.allowedImageMimeTypes.join(','),
className: 'simplehtml-url-file'
});
this.urlFileInput.setAttribute('aria-hidden', 'true');
this.urlOkButton = document.createElement('button');
Object.assign(this.urlOkButton, { type: 'submit', className: 'btn sfe simplehtml-url-ok', textContent: 'OK' });
this.urlCancelButton = document.createElement('button');
Object.assign(this.urlCancelButton, { type: 'button', className: 'btn simplehtml-url-cancel', textContent: 'Cancel' });
this.urlForm.append(this.urlInput, this.urlUploadButton, this.urlOkButton, this.urlCancelButton, this.urlFileInput);
this.container.appendChild(this.urlForm);
const resetFileInput = () => { this.urlFileInput.value = ''; };
const closeUrlForm = () => {
this.hideUrlForm({ restoreSelection: true });
this.contentArea.focus();
};
this.urlForm.addEventListener('submit', (e) => {
e.preventDefault();
this.applyUrl();
});
this.urlUploadButton.addEventListener('click', (e) => {
e.preventDefault();
resetFileInput();
this.urlFileInput.click();
});
this.urlFileInput.addEventListener('change', () => {
const file = this.urlFileInput.files && this.urlFileInput.files[0];
if (!file || this.urlFormMode !== 'image') return resetFileInput();
if (!this.isAllowedImageMimeType(file.type)) return resetFileInput();
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
if (!this.isAllowedDataImageUrl(dataUrl)) return resetFileInput();
this.urlInput.value = dataUrl;
this.applyUrl();
resetFileInput();
closeUrlForm();
};
reader.onerror = resetFileInput;
reader.readAsDataURL(file);
});
this.urlCancelButton.addEventListener('click', closeUrlForm);
this.urlForm.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
e.preventDefault();
closeUrlForm();
});
this.urlFormPinned = false;
this.urlFormTarget = this.urlFormRange = this.urlFormMode = null;
}
setUrlFormMode(mode) {
this.urlFormMode = mode;
if (!this.urlInput) return;
const isImage = mode === 'image';
const label = isImage ? 'Image URL' : mode === 'link' ? 'Link URL' : 'URL';
this.urlInput.setAttribute('aria-label', label);
if (this.urlUploadButton) this.urlUploadButton.style.display = isImage ? 'inline-block' : 'none';
}
showUrlForm({ value = '', pin = false, focus = false } = {}) {
if (!this.urlForm) this.initUrlForm();
this.urlFormPinned = pin;
this.urlForm.style.display = 'flex';
this.urlForm.setAttribute('aria-hidden', 'false');
this.urlInput.value = value;
if (focus) {
this.urlInput.focus();
this.urlInput.select();
}
}
hideUrlForm({ restoreSelection = false } = {}) {
if (!this.urlForm) return;
this.urlForm.style.display = 'none';
this.urlForm.setAttribute('aria-hidden', 'true');
if (restoreSelection) this.restoreSelection(this.urlFormRange);
this.urlFormPinned = false;
this.urlFormTarget = this.urlFormRange = null;
this.setUrlFormMode(null);
}
openUrlForm({ auto = false, target = null, mode, defaultValue = '' } = {}) {
const resolved = target || (mode === 'image' ? this.getImageAtSelection() : this.getLinkAtSelection());
if (auto && !resolved) {
this.hideUrlForm();
return;
}
this.urlFormRange = this.saveSelection();
this.urlFormTarget = resolved;
this.setUrlFormMode(mode);
const attr = mode === 'image' ? 'src' : 'href';
const value = resolved ? resolved.getAttribute(attr) || '' : defaultValue;
this.showUrlForm({ value, pin: !auto, focus: !auto });
}
openImageForm({ auto = false, image = null } = {}) {
this.openUrlForm({ auto, target: image, mode: 'image' });
}
openLinkForm({ auto = false, link = null } = {}) {
this.openUrlForm({ auto, target: link, mode: 'link', defaultValue: 'https://' });
}
applyUrl() {
const url = this.urlInput.value.trim();
if (!url) {
this.hideUrlForm({ restoreSelection: true });
this.contentArea.focus();
return;
}
const target = this.urlFormTarget;
const isImage = this.urlFormMode === 'image';
const isLink = this.urlFormMode === 'link';
if (!isImage && !isLink) {
this.hideUrlForm({ restoreSelection: true });
this.contentArea.focus();
return;
}
const tagName = isImage ? 'IMG' : 'A';
const attr = isImage ? 'src' : 'href';
const command = isImage ? 'insertImage' : 'createLink';
if (target && target.tagName === tagName && this.contentArea.contains(target)) {
target.setAttribute(attr, url);
} else {
this.contentArea.focus();
this.restoreSelection(this.urlFormRange);
document.execCommand(command, false, url);
}
this.saveHistory();
this.hideUrlForm();
this.updateToolbarState();
this.contentArea.focus();
}
syncUrlFormWithSelection() {
if (!this.urlForm || this.urlFormPinned) return;
if (this.sourceArea && this.sourceArea.style.display === 'block') {
this.hideUrlForm();
return;
}
const link = this.getLinkAtSelection();
if (link) {
const currentValue = link.getAttribute('href') || '';
if (this.urlFormTarget !== link || this.urlFormMode !== 'link' || this.urlForm.style.display === 'none') {
this.openLinkForm({ auto: true, link });
} else if (this.urlInput.value !== currentValue) {
this.urlInput.value = currentValue;
}
return;
}
const image = this.getImageAtSelection();
if (image) {
const currentValue = image.getAttribute('src') || '';
if (this.urlFormTarget !== image || this.urlFormMode !== 'image' || this.urlForm.style.display === 'none') {
this.openImageForm({ auto: true, image });
} else if (this.urlInput.value !== currentValue) {
this.urlInput.value = currentValue;
}
} else {
this.hideUrlForm();
}
}
getImageAtSelection() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection.getRangeAt(0);
if (!this.contentArea.contains(range.commonAncestorContainer)) return null;
let node = range.commonAncestorContainer;
node = node.nodeType === 1 ? node : node.parentNode;
if (!node) return null;
if (node.tagName === 'IMG') return node;
const img = node.closest && node.closest('img');
if (img && this.contentArea.contains(img)) return img;
if (range.startContainer.nodeType === 1) {
const child = range.startContainer.childNodes[range.startOffset];
if (child && child.nodeType === 1 && child.tagName === 'IMG') return child;
}
if (range.endContainer.nodeType === 1) {
const index = Math.max(0, range.endOffset - 1);
const child = range.endContainer.childNodes[index];
if (child && child.nodeType === 1 && child.tagName === 'IMG') return child;
}
return null;
}
getLinkAtSelection() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection.getRangeAt(0);
if (!this.contentArea.contains(range.commonAncestorContainer)) return null;
let node = range.commonAncestorContainer;
node = node.nodeType === 1 ? node : node.parentNode;
if (!node) return null;
if (node.tagName === 'A') return node;
const anchor = node.closest && node.closest('a');
return anchor && this.contentArea.contains(anchor) ? anchor : null;
}
updateToolbarState() {
const selection = window.getSelection();
let node = null;
if (selection.rangeCount > 0) {
node = selection.getRangeAt(0).commonAncestorContainer;
node = node.nodeType === 3 ? node.parentNode : node;
}
// 1. Native Commands
this.options.toolbar.forEach((tool) => {
if (tool === '|') return;
const btn = this.toolbar.querySelector(`button[data-command="${tool}"]`);
if (!btn) return;
// Handle Table Context Buttons
if (tool.startsWith('table_')) {
btn.style.display = node && node.closest('div.vgh-content table') ? 'inline-block' : 'none';
return;
}
if (document.queryCommandState && !['source', 'undo', 'redo'].includes(tool)) {
try {
const active = document.queryCommandState(tool) ||
(node && ['blockquote', 'code'].includes(tool) && node.closest(`div.vgh-content ${tool}`));
btn.classList.toggle('active', active);
} catch (e) { }
}
});
// 2. Undo/Redo State
const btnUndo = this.toolbar.querySelector('button[data-command="undo"]');
if (btnUndo) btnUndo.disabled = this.historyIndex <= 0;
const btnRedo = this.toolbar.querySelector('button[data-command="redo"]');
if (btnRedo) btnRedo.disabled = this.historyIndex >= this.history.length - 1;
this.syncUrlFormWithSelection();
}
handlePaste(e) {
e.preventDefault();
const data = e.clipboardData || window.clipboardData;
const html = data.getData('text/html');
const text = data.getData('text');
// Favor HTML, but strictly sanitize it
const content = html ? this.sanitize(html) : text.replace(/\n/g, '<br>');
document.execCommand('insertHTML', false, content);
}
execCommand(command, btn) {
this.contentArea.focus();
// Snapshot for undo - normally handled by input, but some commands might use modal/etc
// For simple execCommands, the input event fires.
switch (command) {
case 'h1': case 'h2': case 'h3': case 'p': case 'blockquote':
document.execCommand('formatBlock', false, command);
break;
case 'code':
const selection = window.getSelection();
if (!selection.isCollapsed) {
const range = selection.getRangeAt(0);
const code = document.createElement('code');
code.textContent = selection.toString();
document.execCommand('insertHTML', false, code.outerHTML);
}
break;
case 'source':
this.toggleSourceView(btn);
break;
case 'link':
this.openLinkForm();
break;
case 'image':
this.openImageForm();
break;
case 'table':
this.insertTable(btn);
break;
case 'table_row_plus': this.modifyTable('row', 'add'); break;
case 'table_row_minus': this.modifyTable('row', 'delete'); break;
case 'table_col_plus': this.modifyTable('col', 'add'); break;
case 'table_col_minus': this.modifyTable('col', 'delete'); break;
case 'unlink':
this.unlinkAtCaret();
break;
case 'removeFormat':
this.removeAllFormatting();
break;
case 'undo':
this.performUndo();
break;
case 'redo':
this.performRedo();
break;
default:
document.execCommand(command, false, null);
break;
}
this.updateToolbarState();
}
removeAllFormatting() {
const lines = (this.contentArea.innerText || '').replace(/\r\n/g, '\n').split('\n');
const fragment = document.createDocumentFragment();
lines.forEach((line, index) => {
if (index > 0) fragment.appendChild(document.createElement('br'));
fragment.appendChild(document.createTextNode(line));
});
this.contentArea.innerHTML = '';
this.contentArea.appendChild(fragment);
this.saveHistory();
}
saveHistory() {
const currentHtml = this.contentArea.innerHTML;
// If identical to last save, skip
if (this.history[this.historyIndex] === currentHtml) return;
// Truncate future if we were in middle
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push(currentHtml);
if (this.history.length > this.maxHistory) this.history.shift();
else this.historyIndex++;
this.updateToolbarState();
}
performUndo() {
if (this.historyIndex > 0) {
this.historyIndex--;
this.contentArea.innerHTML = this.history[this.historyIndex];
this.updateToolbarState();
}
}
performRedo() {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.contentArea.innerHTML = this.history[this.historyIndex];
this.updateToolbarState();
}
}
saveSelection() {
const selection = window.getSelection();
return selection.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
}
restoreSelection(range) {
if (!range) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
unlinkAtCaret() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
if (!selection.isCollapsed) {
document.execCommand('unlink', false, null);
return;
}
const range = selection.getRangeAt(0);
let node = range.commonAncestorContainer;
if (node.nodeType === 3) node = node.parentNode;
const anchor = node && node.closest ? node.closest('a') : null;
if (!anchor) {
document.execCommand('unlink', false, null);
return;
}
const marker = document.createElement('span');
marker.setAttribute('data-sh-caret', '1');
range.insertNode(marker);
const parent = anchor.parentNode;
while (anchor.firstChild) {
parent.insertBefore(anchor.firstChild, anchor);
}
parent.removeChild(anchor);
const newRange = document.createRange();
newRange.setStartAfter(marker);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
marker.parentNode.removeChild(marker);
this.saveHistory();
}
initTablePicker() {
if (this.tablePicker) return;
const containerStyle = window.getComputedStyle(this.container);
if (containerStyle.position === 'static') {
this.container.style.position = 'relative';
}
this.tablePicker = document.createElement('div');
this.tablePicker.className = 'simplehtml-table-picker';
this.tablePicker.style.display = 'none';
this.tablePicker.setAttribute('aria-hidden', 'true');
this.tablePickerLabel = document.createElement('div');
this.tablePickerLabel.className = 'simplehtml-table-picker-label';
this.tablePickerLabel.textContent = '0 x 0';
this.tablePickerGrid = document.createElement('div');
this.tablePickerGrid.className = 'simplehtml-table-picker-grid';
this.tablePickerCells = [];
const max = 12;
for (let r = 1; r <= max; r++) {
for (let c = 1; c <= max; c++) {
const cell = document.createElement('div');
cell.className = 'simplehtml-table-picker-cell';
cell.dataset.row = r;
cell.dataset.col = c;
this.tablePickerGrid.appendChild(cell);
this.tablePickerCells.push(cell);
}
}
this.tablePicker.appendChild(this.tablePickerLabel);
this.tablePicker.appendChild(this.tablePickerGrid);
this.container.appendChild(this.tablePicker);
this.tablePickerGrid.addEventListener('mouseover', (e) => {
const cell = e.target.closest('.simplehtml-table-picker-cell');
if (!cell) return;
const rows = parseInt(cell.dataset.row, 10);
const cols = parseInt(cell.dataset.col, 10);
this.updateTablePickerSelection(rows, cols);
});
this.tablePickerGrid.addEventListener('click', (e) => {
const cell = e.target.closest('.simplehtml-table-picker-cell');
if (!cell) return;
const rows = parseInt(cell.dataset.row, 10);
const cols = parseInt(cell.dataset.col, 10);
this.insertTableWithSize(rows, cols);
this.hideTablePicker();
});
}
showTablePicker(btn) {
this.updateTablePickerSelection(0, 0);
this.tablePicker.style.display = 'block';
this.tablePicker.setAttribute('aria-hidden', 'false');
const containerRect = this.container.getBoundingClientRect();
const pickerRect = this.tablePicker.getBoundingClientRect();
let left = 8;
let top = this.toolbar ? this.toolbar.offsetHeight + 8 : 8;
if (btn) {
const btnRect = btn.getBoundingClientRect();
left = btnRect.left - containerRect.left;
top = btnRect.bottom - containerRect.top + 6;
}
if (left + pickerRect.width > containerRect.width) {
left = containerRect.width - pickerRect.width - 6;
}
if (left < 0) left = 0;
if (top < 0) top = 0;
this.tablePicker.style.left = `${left}px`;
this.tablePicker.style.top = `${top}px`;
}
hideTablePicker() {
if (!this.tablePicker) return;
this.tablePicker.style.display = 'none';
this.tablePicker.setAttribute('aria-hidden', 'true');
this.tablePickerSelectionRange = null;
}
updateTablePickerSelection(rows, cols) {
const label = rows && cols ? `${rows} x ${cols}` : '0 x 0';
this.tablePickerLabel.textContent = label;
this.tablePickerCells.forEach((cell) => {
const cellRow = parseInt(cell.dataset.row, 10);
const cellCol = parseInt(cell.dataset.col, 10);
if (cellRow <= rows && cellCol <= cols) {
cell.classList.add('active');
} else {
cell.classList.remove('active');
}
});
}
insertTable(btn) {
if (!this.tablePicker) this.initTablePicker();
if (this.tablePicker.style.display === 'block') {
this.hideTablePicker();
return;
}
this.tablePickerSelectionRange = this.saveSelection();
this.showTablePicker(btn);
}
insertTableWithSize(rows, cols) {
if (!rows || !cols) return;
this.contentArea.focus();
this.restoreSelection(this.tablePickerSelectionRange);
let html = '<table><tbody>';
const cells = '<td></td>'.repeat(cols);
for (let r = 0; r < rows; r++) html += `<tr>${cells}</tr>`;
html += '</tbody></table><p><br></p>';
document.execCommand('insertHTML', false, html);
}
modifyTable(type, action) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
let node = selection.getRangeAt(0).commonAncestorContainer;
node = node.nodeType === 3 ? node.parentNode : node;
const td = node.closest('div.vgh-content td') || node.closest('div.vgh-content th');
if (!td) return;
const tr = td.parentNode;
const table = tr.closest('div.vgh-content table');
const rowIndex = tr.rowIndex;
const colIndex = Array.from(tr.children).indexOf(td);
if (type === 'row') {
if (action === 'add') {
const newRow = table.insertRow(rowIndex + 1);
for (let i = 0; i < tr.children.length; i++) {
newRow.insertCell(i).innerHTML = '<br>';
}
} else if (action === 'delete') {
table.deleteRow(rowIndex);
}
} else if (type === 'col') {
// Iterate all rows
for (let i = 0; i < table.rows.length; i++) {
if (action === 'add') {
table.rows[i].insertCell(colIndex + 1).innerHTML = '<br>';
} else if (action === 'delete') {
if (table.rows[i].cells.length > 1) {
table.rows[i].deleteCell(colIndex);
}
}
}
}
this.saveHistory();
}
toggleSourceView(btn) {
this.hideUrlForm();
const isSource = this.sourceArea.style.display === 'block';
if (isSource) {
this.setValue(this.sourceArea.value);
this.sourceArea.style.display = 'none';
this.contentArea.style.display = 'block';
} else {
this.sourceArea.value = this.getValue();
this.contentArea.style.display = 'none';
this.sourceArea.style.display = 'block';
}
if (btn) btn.classList.toggle('active', !isSource);
}
getValue() {
return this.sanitize(this.sourceArea.style.display === 'block' ? this.sourceArea.value : this.contentArea.innerHTML);
}
setValue(html) {
const clean = this.sanitize(html);
this.contentArea.innerHTML = this.sourceArea.value = clean;
this.saveHistory(); // Record state change
}
isAllowedImageMimeType(mimeType) {
return SimpleHtml.isAllowedImageMimeType(mimeType);
}
isAllowedDataImageUrl(url) {
return SimpleHtml.isAllowedDataImageUrl(url);
}
sanitize(html) {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
// If the parser reports an error, bail out with empty content
if (doc.querySelector('parsererror')) {
return '';
}
// Helper to validate URL-bearing attributes more strictly
const isSafeUrl = (attrName, value) => {
if (!value) return false;
const trimmed = value.trim();
const lower = trimmed.toLowerCase();
// Always disallow javascript:, data: (except data:image/ for src), vbscript:, etc.
if (this.disallowedProtocols && this.disallowedProtocols.some(p => lower.startsWith(p))) {
if (attrName === 'src' && this.isAllowedDataImageUrl(trimmed)) {
return true;
}
return false;
}
// For href, do not allow data: at all
if (attrName === 'href' && lower.startsWith('data:')) {
return false;
}
try {
// Use URL parsing when possible to normalize the protocol
const url = new URL(trimmed, window.location.origin);
const protocol = url.protocol.toLowerCase();
if (this.disallowedProtocols && this.disallowedProtocols.some(p => protocol === p || protocol === p + ':')) {
return false;
}
} catch (e) {
// If URL constructor fails, fall back to prefix checks above
}
return true;
};
const cleanNode = (node) => {
if (node.nodeType === 3) return node.cloneNode(true);
if (node.nodeType !== 1) return null;
const tag = node.tagName.toLowerCase();
if (!this.allowedTags.hasOwnProperty(tag)) {
if (['script', 'style', 'iframe', 'object', 'embed'].includes(tag)) return null;
const fragment = document.createDocumentFragment();
for (let i = 0; i < node.childNodes.length; i++) {
const child = cleanNode(node.childNodes[i]);
if (child) fragment.appendChild(child);
}
return fragment;
}
const el = document.createElement(tag);
const allowedAttrs = this.allowedTags[tag];
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
const name = attr.name;
// Never allow event handler attributes, even if misconfigured in allowedAttrs
if (/^on/i.test(name)) continue;
if (!allowedAttrs.includes(name)) continue;
if (name === 'href' || name === 'src') {
if (!isSafeUrl(name, attr.value)) {
continue;
}
}
el.setAttribute(name, attr.value);
}
for (let i = 0; i < node.childNodes.length; i++) {
const child = cleanNode(node.childNodes[i]);
if (child) el.appendChild(child);
}
return el;
};
const result = document.createElement('div');
for (let i = 0; i < doc.body.childNodes.length; i++) {
const cleaned = cleanNode(doc.body.childNodes[i]);
if (cleaned) result.appendChild(cleaned);
}
return result.innerHTML;
}
}
// Export if module system is present, otherwise global
if (typeof module !== 'undefined' && module.exports) {
module.exports = SimpleHtml;
} else {
window.SimpleHtml = SimpleHtml;
}
if (typeof window !== 'undefined' && typeof window.sanitizeRichHtml !== 'function') {
window.sanitizeRichHtml = function (html) {
return SimpleHtml.sanitizeHtmlFragment(html);
};
}