neiki-editor 2.9.2 → 2.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * NeikiEditor - A Modern WYSIWYG Editor
3
- * Version: 2.9.2
3
+ * Version: 2.9.4
4
4
  *
5
5
  * A lightweight, feature-rich text editor with support for:
6
6
  * - Rich text formatting (bold, italic, underline, etc.)
@@ -19,6 +19,8 @@
19
19
  // SECTION 1: CONFIGURATION & CONSTANTS
20
20
  // ============================================
21
21
 
22
+ let EDITOR_INSTANCE_COUNTER = 0;
23
+
22
24
  // ============================================
23
25
  // TRANSLATIONS / i18n
24
26
  // ============================================
@@ -1089,8 +1091,9 @@
1089
1091
  // Register a custom translation (static method — available before init)
1090
1092
  function addTranslation(lang, keys) {
1091
1093
  if (!lang || typeof keys !== 'object') return;
1094
+ if (!Utils.isSafeObjectKey(lang)) return;
1092
1095
  if (!TRANSLATIONS[lang]) TRANSLATIONS[lang] = {};
1093
- Object.assign(TRANSLATIONS[lang], keys);
1096
+ Utils.safeAssign(TRANSLATIONS[lang], keys);
1094
1097
  }
1095
1098
 
1096
1099
  // Translation helper function
@@ -1127,6 +1130,7 @@
1127
1130
  theme: 'light',
1128
1131
  language: 'en',
1129
1132
  translations: null,
1133
+ autosaveKey: null,
1130
1134
  plugins: [],
1131
1135
  onChange: null,
1132
1136
  onSave: null,
@@ -1284,8 +1288,12 @@
1284
1288
  el.textContent = value;
1285
1289
  } else if (key.startsWith('on') && typeof value === 'function') {
1286
1290
  el.addEventListener(key.slice(2).toLowerCase(), value);
1287
- } else if (key === 'style' && typeof value === 'object') {
1288
- Object.assign(el.style, value);
1291
+ } else if (key === 'style' && value && typeof value === 'object') {
1292
+ Object.keys(value).forEach(styleName => {
1293
+ if (Utils.isSafeObjectKey(styleName)) {
1294
+ el.style[styleName] = value[styleName];
1295
+ }
1296
+ });
1289
1297
  } else {
1290
1298
  el.setAttribute(key, value);
1291
1299
  }
@@ -1308,9 +1316,24 @@
1308
1316
  };
1309
1317
  },
1310
1318
 
1319
+ isSafeObjectKey(key) {
1320
+ return key !== '__proto__' && key !== 'prototype' && key !== 'constructor';
1321
+ },
1322
+
1323
+ safeAssign(target, source) {
1324
+ if (!source || typeof source !== 'object') return target;
1325
+ Object.keys(source).forEach(key => {
1326
+ if (!Object.prototype.hasOwnProperty.call(source, key) || !Utils.isSafeObjectKey(key)) return;
1327
+ target[key] = source[key];
1328
+ });
1329
+ return target;
1330
+ },
1331
+
1311
1332
  deepMerge(target, source) {
1312
1333
  const result = { ...target };
1334
+ if (!source || typeof source !== 'object') return result;
1313
1335
  Object.keys(source).forEach(key => {
1336
+ if (!Object.prototype.hasOwnProperty.call(source, key) || !Utils.isSafeObjectKey(key)) return;
1314
1337
  if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
1315
1338
  result[key] = Utils.deepMerge(result[key] || {}, source[key]);
1316
1339
  } else {
@@ -1321,11 +1344,264 @@
1321
1344
  },
1322
1345
 
1323
1346
  sanitizeHTML(html) {
1324
- const temp = document.createElement('div');
1325
- temp.innerHTML = html;
1326
- const scripts = temp.querySelectorAll('script');
1327
- scripts.forEach(s => s.remove());
1328
- return temp.innerHTML;
1347
+ const input = String(html || '');
1348
+ const lowerInput = input.toLowerCase();
1349
+ const root = document.createDocumentFragment();
1350
+ const stack = [root];
1351
+ const blockedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'base']);
1352
+ const allowedTags = new Set([
1353
+ 'a', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'div', 'em',
1354
+ 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol',
1355
+ 'p', 'pre', 's', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody',
1356
+ 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul'
1357
+ ]);
1358
+ const voidTags = new Set(['br', 'col', 'hr', 'img']);
1359
+ const urlAttrs = new Set(['href', 'src', 'xlink:href', 'poster']);
1360
+ let index = 0;
1361
+
1362
+ const currentParent = () => stack[stack.length - 1];
1363
+
1364
+ while (index < input.length) {
1365
+ const tagStart = input.indexOf('<', index);
1366
+
1367
+ if (tagStart === -1) {
1368
+ currentParent().appendChild(document.createTextNode(input.slice(index)));
1369
+ break;
1370
+ }
1371
+
1372
+ if (tagStart > index) {
1373
+ currentParent().appendChild(document.createTextNode(input.slice(index, tagStart)));
1374
+ }
1375
+
1376
+ if (input.slice(tagStart, tagStart + 4) === '<!--') {
1377
+ const commentEnd = input.indexOf('-->', tagStart + 4);
1378
+ index = commentEnd === -1 ? input.length : commentEnd + 3;
1379
+ continue;
1380
+ }
1381
+
1382
+ const tagEnd = Utils.findTagEnd(input, tagStart + 1);
1383
+ if (tagEnd === -1) {
1384
+ currentParent().appendChild(document.createTextNode(input.slice(tagStart)));
1385
+ break;
1386
+ }
1387
+
1388
+ const rawTag = input.slice(tagStart + 1, tagEnd).trim();
1389
+ if (!rawTag || rawTag[0] === '!' || rawTag[0] === '?') {
1390
+ index = tagEnd + 1;
1391
+ continue;
1392
+ }
1393
+
1394
+ if (rawTag[0] === '/') {
1395
+ const closingName = Utils.readHTMLName(rawTag, 1).name.toLowerCase();
1396
+ for (let i = stack.length - 1; i > 0; i--) {
1397
+ if (stack[i].nodeType === Node.ELEMENT_NODE && stack[i].tagName.toLowerCase() === closingName) {
1398
+ stack.length = i;
1399
+ break;
1400
+ }
1401
+ }
1402
+ index = tagEnd + 1;
1403
+ continue;
1404
+ }
1405
+
1406
+ const tagInfo = Utils.readHTMLName(rawTag, 0);
1407
+ const tagName = tagInfo.name.toLowerCase();
1408
+ const selfClosing = Utils.isSelfClosingTag(rawTag);
1409
+
1410
+ if (blockedTags.has(tagName)) {
1411
+ const closeTag = '</' + tagName;
1412
+ const closeStart = lowerInput.indexOf(closeTag, tagEnd + 1);
1413
+ const closeEnd = closeStart === -1 ? -1 : input.indexOf('>', closeStart + closeTag.length);
1414
+ index = closeEnd === -1 ? tagEnd + 1 : closeEnd + 1;
1415
+ continue;
1416
+ }
1417
+
1418
+ if (!allowedTags.has(tagName)) {
1419
+ index = tagEnd + 1;
1420
+ continue;
1421
+ }
1422
+
1423
+ const el = document.createElement(tagName);
1424
+ Utils.parseHTMLAttributes(rawTag, tagInfo.end).forEach(attr => {
1425
+ const attrName = attr.name.toLowerCase();
1426
+ const attrValue = attr.value.trim();
1427
+
1428
+ if (!Utils.isSafeHTMLAttribute(attrName)) return;
1429
+ if (urlAttrs.has(attrName) && !Utils.isSafeUrl(attrValue, tagName === 'img')) return;
1430
+ if (attrName === 'style' && !Utils.isSafeStyleValue(attrValue)) return;
1431
+
1432
+ el.setAttribute(attr.name, attr.value);
1433
+ });
1434
+
1435
+ if (tagName === 'a' && el.getAttribute('target') === '_blank') {
1436
+ el.setAttribute('rel', 'noopener noreferrer');
1437
+ }
1438
+
1439
+ currentParent().appendChild(el);
1440
+ if (!selfClosing && !voidTags.has(tagName)) {
1441
+ stack.push(el);
1442
+ }
1443
+
1444
+ index = tagEnd + 1;
1445
+ }
1446
+
1447
+ return Utils.serializeHTML(root);
1448
+ },
1449
+
1450
+ findTagEnd(input, start) {
1451
+ let quote = null;
1452
+
1453
+ for (let i = start; i < input.length; i++) {
1454
+ const char = input[i];
1455
+ if (quote) {
1456
+ if (char === quote) quote = null;
1457
+ } else if (char === '"' || char === "'") {
1458
+ quote = char;
1459
+ } else if (char === '>') {
1460
+ return i;
1461
+ }
1462
+ }
1463
+
1464
+ return -1;
1465
+ },
1466
+
1467
+ readHTMLName(input, start) {
1468
+ let i = start;
1469
+ let name = '';
1470
+
1471
+ while (i < input.length && Utils.isHTMLNameChar(input[i])) {
1472
+ name += input[i];
1473
+ i++;
1474
+ }
1475
+
1476
+ return { name, end: i };
1477
+ },
1478
+
1479
+ isHTMLNameChar(char) {
1480
+ return (char >= 'a' && char <= 'z') ||
1481
+ (char >= 'A' && char <= 'Z') ||
1482
+ (char >= '0' && char <= '9') ||
1483
+ char === '-' ||
1484
+ char === '_' ||
1485
+ char === ':';
1486
+ },
1487
+
1488
+ isSelfClosingTag(rawTag) {
1489
+ for (let i = rawTag.length - 1; i >= 0; i--) {
1490
+ const char = rawTag[i];
1491
+ if (char === ' ' || char === '\t' || char === '\n' || char === '\r') continue;
1492
+ return char === '/';
1493
+ }
1494
+ return false;
1495
+ },
1496
+
1497
+ parseHTMLAttributes(rawTag, start) {
1498
+ const attrs = [];
1499
+ let i = start;
1500
+
1501
+ while (i < rawTag.length) {
1502
+ while (i < rawTag.length && /\s/.test(rawTag[i])) i++;
1503
+ if (i >= rawTag.length || rawTag[i] === '/') break;
1504
+
1505
+ const attrInfo = Utils.readHTMLName(rawTag, i);
1506
+ if (!attrInfo.name) {
1507
+ i++;
1508
+ continue;
1509
+ }
1510
+
1511
+ i = attrInfo.end;
1512
+ while (i < rawTag.length && /\s/.test(rawTag[i])) i++;
1513
+
1514
+ let value = '';
1515
+ if (rawTag[i] === '=') {
1516
+ i++;
1517
+ while (i < rawTag.length && /\s/.test(rawTag[i])) i++;
1518
+
1519
+ if (rawTag[i] === '"' || rawTag[i] === "'") {
1520
+ const quote = rawTag[i];
1521
+ i++;
1522
+ const valueStart = i;
1523
+ while (i < rawTag.length && rawTag[i] !== quote) i++;
1524
+ value = rawTag.slice(valueStart, i);
1525
+ if (rawTag[i] === quote) i++;
1526
+ } else {
1527
+ const valueStart = i;
1528
+ while (i < rawTag.length && !/\s/.test(rawTag[i]) && rawTag[i] !== '/') i++;
1529
+ value = rawTag.slice(valueStart, i);
1530
+ }
1531
+ }
1532
+
1533
+ attrs.push({ name: attrInfo.name, value });
1534
+ }
1535
+
1536
+ return attrs;
1537
+ },
1538
+
1539
+ isSafeHTMLAttribute(name) {
1540
+ if (!name || name.startsWith('on') || name === 'srcdoc') return false;
1541
+ if (!Utils.isSafeObjectKey(name)) return false;
1542
+ return /^[a-z0-9_:-]+$/.test(name);
1543
+ },
1544
+
1545
+ isSafeStyleValue(value) {
1546
+ const lower = String(value || '').toLowerCase();
1547
+ return lower.indexOf('expression') === -1 &&
1548
+ lower.indexOf('javascript:') === -1 &&
1549
+ lower.indexOf('vbscript:') === -1 &&
1550
+ lower.indexOf('data:') === -1;
1551
+ },
1552
+
1553
+ serializeHTML(node) {
1554
+ let html = '';
1555
+
1556
+ node.childNodes.forEach(child => {
1557
+ if (child.nodeType === Node.TEXT_NODE) {
1558
+ html += Utils.escapeHTML(child.textContent);
1559
+ return;
1560
+ }
1561
+
1562
+ if (child.nodeType !== Node.ELEMENT_NODE) return;
1563
+
1564
+ const tagName = child.tagName.toLowerCase();
1565
+ html += '<' + tagName;
1566
+ Array.from(child.attributes).forEach(attr => {
1567
+ html += ' ' + attr.name + '="' + Utils.escapeHTML(attr.value) + '"';
1568
+ });
1569
+
1570
+ if (new Set(['br', 'col', 'hr', 'img']).has(tagName)) {
1571
+ html += '>';
1572
+ } else {
1573
+ html += '>' + Utils.serializeHTML(child) + '</' + tagName + '>';
1574
+ }
1575
+ });
1576
+
1577
+ return html;
1578
+ },
1579
+
1580
+ isSafeUrl(value, allowImageData = false) {
1581
+ if (!value) return true;
1582
+ if (value.startsWith('#') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) return true;
1583
+
1584
+ try {
1585
+ const parsed = new URL(value, window.location.href);
1586
+ const protocol = parsed.protocol.toLowerCase();
1587
+ return protocol === 'http:' ||
1588
+ protocol === 'https:' ||
1589
+ protocol === 'mailto:' ||
1590
+ protocol === 'tel:' ||
1591
+ (allowImageData && protocol === 'data:' && /^data:image\//i.test(value));
1592
+ } catch (e) {
1593
+ return false;
1594
+ }
1595
+ },
1596
+
1597
+ escapeHTML(value) {
1598
+ return String(value ?? '').replace(/[&<>"']/g, char => ({
1599
+ '&': '&amp;',
1600
+ '<': '&lt;',
1601
+ '>': '&gt;',
1602
+ '"': '&quot;',
1603
+ "'": '&#39;'
1604
+ })[char]);
1329
1605
  },
1330
1606
 
1331
1607
  isValidUrl(string) {
@@ -1668,7 +1944,7 @@
1668
1944
  this.overlay.appendChild(modal);
1669
1945
  this.overlay.classList.add('active');
1670
1946
 
1671
- const firstInput = modal.querySelector('input');
1947
+ const firstInput = modal.querySelector('input:not([type="file"]), textarea, select, button');
1672
1948
  if (firstInput) firstInput.focus();
1673
1949
  }
1674
1950
 
@@ -1685,38 +1961,74 @@
1685
1961
  createLinkModal(data) {
1686
1962
  const modal = Utils.createElement('div', { className: 'neiki-modal' });
1687
1963
 
1688
- modal.innerHTML = `
1689
- <div class="neiki-modal-header">
1690
- <h3>${t('modal.insertLink')}</h3>
1691
- <button class="neiki-modal-close" type="button">${Icons.close}</button>
1692
- </div>
1693
- <div class="neiki-modal-body">
1694
- <div class="neiki-form-group">
1695
- <label>${t('modal.url')}</label>
1696
- <input type="url" class="neiki-input" name="url" placeholder="https://example.com" value="${data.url || ''}">
1697
- </div>
1698
- <div class="neiki-form-group">
1699
- <label>${t('modal.text')}</label>
1700
- <input type="text" class="neiki-input" name="text" placeholder="${t('modal.linkText')}" value="${data.text || ''}">
1701
- </div>
1702
- <div class="neiki-form-group">
1703
- <label>
1704
- <input type="checkbox" name="newTab" ${data.newTab ? 'checked' : ''}> ${t('modal.openInNewTab')}
1705
- </label>
1706
- </div>
1707
- </div>
1708
- <div class="neiki-modal-footer">
1709
- <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${t('modal.cancel')}</button>
1710
- <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${t('modal.insert')}</button>
1711
- </div>
1712
- `;
1964
+ const header = Utils.createElement('div', { className: 'neiki-modal-header' });
1965
+ header.appendChild(Utils.createElement('h3', { textContent: t('modal.insertLink') }));
1966
+ const closeBtn = Utils.createElement('button', {
1967
+ className: 'neiki-modal-close',
1968
+ type: 'button',
1969
+ innerHTML: Icons.close
1970
+ });
1971
+ header.appendChild(closeBtn);
1713
1972
 
1714
- modal.querySelector('.neiki-modal-close').addEventListener('click', () => this.close());
1715
- modal.querySelector('[data-action="cancel"]').addEventListener('click', () => this.close());
1716
- modal.querySelector('[data-action="insert"]').addEventListener('click', () => {
1717
- const url = modal.querySelector('[name="url"]').value;
1718
- const text = modal.querySelector('[name="text"]').value || url;
1719
- const newTab = modal.querySelector('[name="newTab"]').checked;
1973
+ const body = Utils.createElement('div', { className: 'neiki-modal-body' });
1974
+ const urlGroup = Utils.createElement('div', { className: 'neiki-form-group' });
1975
+ const urlInput = Utils.createElement('input', {
1976
+ type: 'url',
1977
+ className: 'neiki-input',
1978
+ name: 'url',
1979
+ placeholder: 'https://example.com'
1980
+ });
1981
+ urlInput.value = data.url || '';
1982
+ urlGroup.appendChild(Utils.createElement('label', { textContent: t('modal.url') }));
1983
+ urlGroup.appendChild(urlInput);
1984
+
1985
+ const textGroup = Utils.createElement('div', { className: 'neiki-form-group' });
1986
+ const textInput = Utils.createElement('input', {
1987
+ type: 'text',
1988
+ className: 'neiki-input',
1989
+ name: 'text',
1990
+ placeholder: t('modal.linkText')
1991
+ });
1992
+ textInput.value = data.text || '';
1993
+ textGroup.appendChild(Utils.createElement('label', { textContent: t('modal.text') }));
1994
+ textGroup.appendChild(textInput);
1995
+
1996
+ const newTabGroup = Utils.createElement('div', { className: 'neiki-form-group' });
1997
+ const newTabLabel = Utils.createElement('label');
1998
+ const newTabInput = Utils.createElement('input', { type: 'checkbox', name: 'newTab' });
1999
+ newTabInput.checked = !!data.newTab;
2000
+ newTabLabel.appendChild(newTabInput);
2001
+ newTabLabel.appendChild(document.createTextNode(' ' + t('modal.openInNewTab')));
2002
+ newTabGroup.appendChild(newTabLabel);
2003
+
2004
+ body.appendChild(urlGroup);
2005
+ body.appendChild(textGroup);
2006
+ body.appendChild(newTabGroup);
2007
+
2008
+ const footer = Utils.createElement('div', { className: 'neiki-modal-footer' });
2009
+ const cancelBtn = Utils.createElement('button', {
2010
+ className: 'neiki-btn neiki-btn-secondary',
2011
+ type: 'button',
2012
+ textContent: t('modal.cancel')
2013
+ });
2014
+ const insertBtn = Utils.createElement('button', {
2015
+ className: 'neiki-btn neiki-btn-primary',
2016
+ type: 'button',
2017
+ textContent: t('modal.insert')
2018
+ });
2019
+ footer.appendChild(cancelBtn);
2020
+ footer.appendChild(insertBtn);
2021
+
2022
+ modal.appendChild(header);
2023
+ modal.appendChild(body);
2024
+ modal.appendChild(footer);
2025
+
2026
+ closeBtn.addEventListener('click', () => this.close());
2027
+ cancelBtn.addEventListener('click', () => this.close());
2028
+ insertBtn.addEventListener('click', () => {
2029
+ const url = urlInput.value;
2030
+ const text = textInput.value || url;
2031
+ const newTab = newTabInput.checked;
1720
2032
 
1721
2033
  if (url) {
1722
2034
  this.editor.restoreSavedSelection();
@@ -1735,47 +2047,64 @@
1735
2047
 
1736
2048
  modal.innerHTML = `
1737
2049
  <div class="neiki-modal-header">
1738
- <h3>${t('modal.insertImage')}</h3>
2050
+ <h3>${Utils.escapeHTML(t('modal.insertImage'))}</h3>
1739
2051
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
1740
2052
  </div>
1741
2053
  <div class="neiki-modal-body">
1742
2054
  <div class="neiki-form-group">
1743
- <label>${t('modal.uploadImage')}</label>
1744
- <input type="file" class="neiki-input" name="upload" accept="image/*" multiple>
1745
- <small class="neiki-upload-hint" style="color: var(--neiki-text-muted); font-size: 11px;">${uploadHint}</small>
2055
+ <label>${Utils.escapeHTML(t('modal.uploadImage'))}</label>
2056
+ <div class="neiki-image-upload-zone" role="button" tabindex="0">
2057
+ <input type="file" class="neiki-image-upload-input" name="upload" accept="image/*" multiple>
2058
+ <div class="neiki-image-upload-icon">${Icons.image}</div>
2059
+ <div class="neiki-image-upload-title">${Utils.escapeHTML(t('modal.uploadImage'))}</div>
2060
+ <div class="neiki-image-upload-hint">${Utils.escapeHTML(uploadHint)}</div>
2061
+ <div class="neiki-image-upload-files" aria-live="polite"></div>
2062
+ </div>
1746
2063
  </div>
1747
2064
  <div class="neiki-form-divider">
1748
- <span>${t('modal.or')}</span>
2065
+ <span>${Utils.escapeHTML(t('modal.or'))}</span>
1749
2066
  </div>
1750
2067
  <div class="neiki-form-group">
1751
- <label>${t('modal.imageUrl')}</label>
1752
- <input type="url" class="neiki-input" name="url" placeholder="https://example.com/image.jpg" value="${data.url || ''}">
2068
+ <label>${Utils.escapeHTML(t('modal.imageUrl'))}</label>
2069
+ <input type="url" class="neiki-input" name="url" placeholder="https://example.com/image.jpg">
1753
2070
  </div>
1754
2071
  <div class="neiki-form-group">
1755
- <label>${t('modal.altText')}</label>
1756
- <input type="text" class="neiki-input" name="alt" placeholder="${t('modal.describeImage')}" value="${data.alt || ''}">
2072
+ <label>${Utils.escapeHTML(t('modal.altText'))}</label>
2073
+ <input type="text" class="neiki-input" name="alt">
1757
2074
  </div>
1758
2075
  <div class="neiki-form-group">
1759
- <label>${t('modal.widthOptional')}</label>
1760
- <input type="text" class="neiki-input" name="width" placeholder="e.g. 300px or 50%" value="${data.width || ''}">
2076
+ <label>${Utils.escapeHTML(t('modal.widthOptional'))}</label>
2077
+ <input type="text" class="neiki-input" name="width" placeholder="e.g. 300px or 50%">
1761
2078
  </div>
1762
2079
  </div>
1763
2080
  <div class="neiki-modal-footer">
1764
- <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${t('modal.cancel')}</button>
1765
- <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${t('modal.insert')}</button>
2081
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${Utils.escapeHTML(t('modal.cancel'))}</button>
2082
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${Utils.escapeHTML(t('modal.insert'))}</button>
1766
2083
  </div>
1767
2084
  `;
1768
2085
 
1769
2086
  const uploadInput = modal.querySelector('[name="upload"]');
2087
+ const uploadZone = modal.querySelector('.neiki-image-upload-zone');
2088
+ const uploadFiles = modal.querySelector('.neiki-image-upload-files');
1770
2089
  const urlInput = modal.querySelector('[name="url"]');
1771
- const hintEl = modal.querySelector('.neiki-upload-hint');
1772
2090
  const insertBtn = modal.querySelector('[data-action="insert"]');
1773
2091
  let pendingFiles = [];
2092
+ let uploadDragCounter = 0;
1774
2093
 
1775
- // Handle file upload (supports multiple files)
1776
- uploadInput.addEventListener('change', (e) => {
1777
- const files = Array.from(e.target.files).filter(f => f.type.startsWith('image/'));
1778
- const invalid = Array.from(e.target.files).filter(f => !f.type.startsWith('image/'));
2094
+ urlInput.value = data.url || '';
2095
+ modal.querySelector('[name="alt"]').placeholder = t('modal.describeImage');
2096
+ modal.querySelector('[name="alt"]').value = data.alt || '';
2097
+ modal.querySelector('[name="width"]').value = data.width || '';
2098
+
2099
+ const updateUploadFeedback = (files) => {
2100
+ uploadZone.classList.toggle('has-files', files.length > 0);
2101
+ uploadFiles.textContent = files.map(file => file.name).join(', ');
2102
+ };
2103
+
2104
+ const handleSelectedFiles = (fileList) => {
2105
+ const selectedFiles = Array.from(fileList || []);
2106
+ const files = selectedFiles.filter(f => f.type.startsWith('image/'));
2107
+ const invalid = selectedFiles.filter(f => !f.type.startsWith('image/'));
1779
2108
 
1780
2109
  if (invalid.length > 0) {
1781
2110
  alert(t('modal.invalidImageFile'));
@@ -1783,13 +2112,15 @@
1783
2112
 
1784
2113
  if (files.length === 0) {
1785
2114
  pendingFiles = [];
2115
+ updateUploadFeedback([]);
2116
+ urlInput.disabled = false;
1786
2117
  return;
1787
2118
  }
1788
2119
 
1789
2120
  pendingFiles = files;
2121
+ updateUploadFeedback(files);
1790
2122
 
1791
2123
  if (files.length === 1 && !hasUploadHandler) {
1792
- // Single file without handler — preview as base64 in URL field
1793
2124
  const reader = new FileReader();
1794
2125
  reader.onload = (ev) => {
1795
2126
  urlInput.value = ev.target.result;
@@ -1797,10 +2128,51 @@
1797
2128
  };
1798
2129
  reader.readAsDataURL(files[0]);
1799
2130
  } else {
1800
- // Multiple files or handler present — disable URL field
1801
2131
  urlInput.value = '';
1802
2132
  urlInput.disabled = true;
1803
2133
  }
2134
+ };
2135
+
2136
+ // Handle file upload (supports multiple files)
2137
+ uploadInput.addEventListener('change', (e) => {
2138
+ handleSelectedFiles(e.target.files);
2139
+ });
2140
+
2141
+ uploadZone.addEventListener('click', (e) => {
2142
+ if (e.target !== uploadInput) uploadInput.click();
2143
+ });
2144
+
2145
+ uploadZone.addEventListener('keydown', (e) => {
2146
+ if (e.key === 'Enter' || e.key === ' ') {
2147
+ e.preventDefault();
2148
+ uploadInput.click();
2149
+ }
2150
+ });
2151
+
2152
+ uploadZone.addEventListener('dragenter', (e) => {
2153
+ e.preventDefault();
2154
+ uploadDragCounter++;
2155
+ uploadZone.classList.add('drag-over');
2156
+ });
2157
+
2158
+ uploadZone.addEventListener('dragover', (e) => {
2159
+ e.preventDefault();
2160
+ });
2161
+
2162
+ uploadZone.addEventListener('dragleave', (e) => {
2163
+ e.preventDefault();
2164
+ uploadDragCounter--;
2165
+ if (uploadDragCounter <= 0) {
2166
+ uploadDragCounter = 0;
2167
+ uploadZone.classList.remove('drag-over');
2168
+ }
2169
+ });
2170
+
2171
+ uploadZone.addEventListener('drop', (e) => {
2172
+ e.preventDefault();
2173
+ uploadDragCounter = 0;
2174
+ uploadZone.classList.remove('drag-over');
2175
+ handleSelectedFiles(e.dataTransfer.files);
1804
2176
  });
1805
2177
 
1806
2178
  // Clear URL when upload is cleared
@@ -1809,6 +2181,7 @@
1809
2181
  urlInput.disabled = false;
1810
2182
  uploadInput.value = '';
1811
2183
  pendingFiles = [];
2184
+ updateUploadFeedback([]);
1812
2185
  }
1813
2186
  });
1814
2187
 
@@ -1842,7 +2215,6 @@
1842
2215
  this.close();
1843
2216
  } else if (pendingFiles.length > 1) {
1844
2217
  // Multiple files without handler — insert each as base64
1845
- let loaded = 0;
1846
2218
  insertBtn.disabled = true;
1847
2219
  insertBtn.textContent = t('modal.uploadingImage');
1848
2220
 
@@ -1878,32 +2250,35 @@
1878
2250
 
1879
2251
  modal.innerHTML = `
1880
2252
  <div class="neiki-modal-header">
1881
- <h3>${t('modal.insertTable')}</h3>
2253
+ <h3>${Utils.escapeHTML(t('modal.insertTable'))}</h3>
1882
2254
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
1883
2255
  </div>
1884
2256
  <div class="neiki-modal-body">
1885
2257
  <div class="neiki-form-row">
1886
2258
  <div class="neiki-form-group">
1887
- <label>${t('modal.rows')}</label>
1888
- <input type="number" class="neiki-input" name="rows" min="1" max="20" value="${data.rows || 3}">
2259
+ <label>${Utils.escapeHTML(t('modal.rows'))}</label>
2260
+ <input type="number" class="neiki-input" name="rows" min="1" max="20">
1889
2261
  </div>
1890
2262
  <div class="neiki-form-group">
1891
- <label>${t('modal.columns')}</label>
1892
- <input type="number" class="neiki-input" name="cols" min="1" max="10" value="${data.cols || 3}">
2263
+ <label>${Utils.escapeHTML(t('modal.columns'))}</label>
2264
+ <input type="number" class="neiki-input" name="cols" min="1" max="10">
1893
2265
  </div>
1894
2266
  </div>
1895
2267
  <div class="neiki-form-group">
1896
2268
  <label>
1897
- <input type="checkbox" name="header" ${data.header !== false ? 'checked' : ''}> ${t('modal.includeHeaderRow')}
2269
+ <input type="checkbox" name="header"> ${Utils.escapeHTML(t('modal.includeHeaderRow'))}
1898
2270
  </label>
1899
2271
  </div>
1900
2272
  </div>
1901
2273
  <div class="neiki-modal-footer">
1902
- <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${t('modal.cancel')}</button>
1903
- <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${t('modal.insert')}</button>
2274
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${Utils.escapeHTML(t('modal.cancel'))}</button>
2275
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${Utils.escapeHTML(t('modal.insert'))}</button>
1904
2276
  </div>
1905
2277
  `;
1906
2278
 
2279
+ modal.querySelector('[name="rows"]').value = parseInt(data.rows, 10) || 3;
2280
+ modal.querySelector('[name="cols"]').value = parseInt(data.cols, 10) || 3;
2281
+ modal.querySelector('[name="header"]').checked = data.header !== false;
1907
2282
  modal.querySelector('.neiki-modal-close').addEventListener('click', () => this.close());
1908
2283
  modal.querySelector('[data-action="cancel"]').addEventListener('click', () => this.close());
1909
2284
  modal.querySelector('[data-action="insert"]').addEventListener('click', () => {
@@ -1924,28 +2299,28 @@
1924
2299
 
1925
2300
  modal.innerHTML = `
1926
2301
  <div class="neiki-modal-header">
1927
- <h3>${t('modal.findReplace')}</h3>
2302
+ <h3>${Utils.escapeHTML(t('modal.findReplace'))}</h3>
1928
2303
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
1929
2304
  </div>
1930
2305
  <div class="neiki-modal-body">
1931
2306
  <div class="neiki-form-group">
1932
- <label>${t('modal.find')}</label>
1933
- <input type="text" class="neiki-input" name="find" placeholder="${t('modal.searchText')}">
2307
+ <label>${Utils.escapeHTML(t('modal.find'))}</label>
2308
+ <input type="text" class="neiki-input" name="find">
1934
2309
  </div>
1935
2310
  <div class="neiki-form-group">
1936
- <label>${t('modal.replaceWith')}</label>
1937
- <input type="text" class="neiki-input" name="replace" placeholder="${t('modal.replacementText')}">
2311
+ <label>${Utils.escapeHTML(t('modal.replaceWith'))}</label>
2312
+ <input type="text" class="neiki-input" name="replace">
1938
2313
  </div>
1939
2314
  <div class="neiki-form-group neiki-form-row">
1940
- <label><input type="checkbox" name="regex"> ${t('modal.useRegex')}</label>
1941
- <label><input type="checkbox" name="caseSensitive"> ${t('modal.caseSensitive')}</label>
2315
+ <label><input type="checkbox" name="regex"> ${Utils.escapeHTML(t('modal.useRegex'))}</label>
2316
+ <label><input type="checkbox" name="caseSensitive"> ${Utils.escapeHTML(t('modal.caseSensitive'))}</label>
1942
2317
  </div>
1943
2318
  <div class="neiki-find-results" style="margin-top:10px;font-size:13px;color:var(--neiki-text-muted);"></div>
1944
2319
  </div>
1945
2320
  <div class="neiki-modal-footer">
1946
- <button class="neiki-btn neiki-btn-secondary" type="button" data-action="findNext">${t('modal.findNext')}</button>
1947
- <button class="neiki-btn neiki-btn-secondary" type="button" data-action="replaceOne">${t('modal.replace')}</button>
1948
- <button class="neiki-btn neiki-btn-primary" type="button" data-action="replaceAll">${t('modal.replaceAll')}</button>
2321
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="findNext">${Utils.escapeHTML(t('modal.findNext'))}</button>
2322
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="replaceOne">${Utils.escapeHTML(t('modal.replace'))}</button>
2323
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="replaceAll">${Utils.escapeHTML(t('modal.replaceAll'))}</button>
1949
2324
  </div>
1950
2325
  `;
1951
2326
 
@@ -1958,6 +2333,9 @@
1958
2333
  let currentMatches = [];
1959
2334
  let currentIndex = -1;
1960
2335
 
2336
+ findInput.placeholder = t('modal.searchText');
2337
+ replaceInput.placeholder = t('modal.replacementText');
2338
+
1961
2339
  const clearHighlights = () => {
1962
2340
  const highlights = this.editor.contentArea.querySelectorAll('.neiki-highlight-find');
1963
2341
  highlights.forEach(h => {
@@ -1978,7 +2356,6 @@
1978
2356
  return;
1979
2357
  }
1980
2358
 
1981
- const content = this.editor.contentArea.innerHTML;
1982
2359
  let flags = 'g';
1983
2360
  if (!caseCheck.checked) flags += 'i';
1984
2361
 
@@ -2127,16 +2504,16 @@
2127
2504
 
2128
2505
  modal.innerHTML = `
2129
2506
  <div class="neiki-modal-header">
2130
- <h3>${t('menu.help')}</h3>
2507
+ <h3>${Utils.escapeHTML(t('menu.help'))}</h3>
2131
2508
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
2132
2509
  </div>
2133
2510
  <div class="neiki-modal-body" style="text-align: center; padding: 24px 20px;">
2134
2511
  <img src="https://github.com/neikiri/neiki-editor/raw/main/logo.png" alt="Neiki's Editor" style="width: 120px; height: auto; margin: 0 auto 16px; display: block;">
2135
2512
  <div style="font-size: 14px; line-height: 2; color: var(--neiki-text-primary);">
2136
- <div><strong>${t('help.author')}:</strong> neikiri (Jindřich Stoklasa)</div>
2137
- <div><strong>${t('help.version')}:</strong> 2.9.2</div>
2138
- <div><strong>${t('help.github')}:</strong> <a href="https://github.com/neikiri/neiki-editor" target="_blank" style="color: var(--neiki-accent);">github.com/neikiri/neiki-editor</a></div>
2139
- <div><strong>${t('help.documentation')}:</strong> <a href="https://github.com/neikiri/neiki-editor/wiki" target="_blank" style="color: var(--neiki-accent);">Wiki</a></div>
2513
+ <div><strong>${Utils.escapeHTML(t('help.author'))}:</strong> neikiri (Jindřich Stoklasa)</div>
2514
+ <div><strong>${Utils.escapeHTML(t('help.version'))}:</strong> 2.9.4</div>
2515
+ <div><strong>${Utils.escapeHTML(t('help.github'))}:</strong> <a href="https://github.com/neikiri/neiki-editor" target="_blank" rel="noopener noreferrer" style="color: var(--neiki-accent);">github.com/neikiri/neiki-editor</a></div>
2516
+ <div><strong>${Utils.escapeHTML(t('help.documentation'))}:</strong> <a href="https://github.com/neikiri/neiki-editor/wiki" target="_blank" rel="noopener noreferrer" style="color: var(--neiki-accent);">Wiki</a></div>
2140
2517
  </div>
2141
2518
  </div>
2142
2519
  `;
@@ -2743,26 +3120,34 @@
2743
3120
 
2744
3121
  insertHTML(html) {
2745
3122
  this.editor.focus();
2746
- document.execCommand('insertHTML', false, html);
3123
+ document.execCommand('insertHTML', false, Utils.sanitizeHTML(html));
2747
3124
  this.editor.history.record();
2748
3125
  this.editor.triggerChange();
2749
3126
  }
2750
3127
 
2751
3128
  insertLink(url, text, newTab = false) {
3129
+ if (!Utils.isSafeUrl(url)) return;
2752
3130
  const selection = Utils.getSelection();
2753
3131
  const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
2754
3132
 
2755
3133
  if (range && !range.collapsed) {
2756
3134
  this.exec('createLink', url);
2757
3135
  if (newTab) {
2758
- const links = this.editor.contentArea.querySelectorAll('a[href="' + url + '"]');
2759
- links.forEach(link => link.setAttribute('target', '_blank'));
3136
+ const links = Array.from(this.editor.contentArea.querySelectorAll('a'))
3137
+ .filter(link => link.getAttribute('href') === url);
3138
+ links.forEach(link => {
3139
+ link.setAttribute('target', '_blank');
3140
+ link.setAttribute('rel', 'noopener noreferrer');
3141
+ });
2760
3142
  }
2761
3143
  } else {
2762
3144
  const link = document.createElement('a');
2763
3145
  link.href = url;
2764
3146
  link.textContent = text || url;
2765
- if (newTab) link.target = '_blank';
3147
+ if (newTab) {
3148
+ link.target = '_blank';
3149
+ link.rel = 'noopener noreferrer';
3150
+ }
2766
3151
 
2767
3152
  this.editor.focus();
2768
3153
  document.execCommand('insertHTML', false, link.outerHTML);
@@ -2772,9 +3157,10 @@
2772
3157
  }
2773
3158
 
2774
3159
  insertImage(url, alt = '', width = '') {
2775
- let html = `<img src="${url}"`;
2776
- if (alt) html += ` alt="${alt}"`;
2777
- if (width) html += ` width="${width}"`;
3160
+ if (!Utils.isSafeUrl(url, true)) return;
3161
+ let html = `<img src="${Utils.escapeHTML(url)}"`;
3162
+ if (alt) html += ` alt="${Utils.escapeHTML(alt)}"`;
3163
+ if (width) html += ` width="${Utils.escapeHTML(width)}"`;
2778
3164
  html += '>';
2779
3165
 
2780
3166
  this.editor.focus();
@@ -2841,6 +3227,8 @@
2841
3227
  'neiki_' + (typeof element === 'string' ? element.replace(/[^a-zA-Z0-9]/g, '_') : 'editor');
2842
3228
 
2843
3229
  this.config = Utils.deepMerge(DEFAULT_CONFIG, options);
3230
+ this.instanceIndex = ++EDITOR_INSTANCE_COUNTER;
3231
+ this.storageId = this.createAutosaveStorageId(element);
2844
3232
  this.isFullscreen = false;
2845
3233
  this.isAutosaveEnabled = false;
2846
3234
  this.autosaveInterval = null;
@@ -2851,7 +3239,7 @@
2851
3239
 
2852
3240
  init() {
2853
3241
  // Initialize storage first
2854
- this.storage = new StorageManager(this.id);
3242
+ this.storage = new StorageManager(this.storageId);
2855
3243
 
2856
3244
  // Set language for translations
2857
3245
  _currentLanguage = this.config.language || 'en';
@@ -2924,6 +3312,99 @@
2924
3312
  this.originalElement.parentNode.insertBefore(this.container, this.originalElement);
2925
3313
  }
2926
3314
 
3315
+ createAutosaveStorageId(element) {
3316
+ const customKey = this.config.autosaveKey ||
3317
+ this.originalElement.getAttribute('data-neiki-autosave-key');
3318
+
3319
+ if (customKey) {
3320
+ return 'autosave_' + this.normalizeStorageKey(customKey);
3321
+ }
3322
+
3323
+ const pageScope = this.hashString(this.getPageStorageScope());
3324
+ const elementScope = this.normalizeStorageKey(this.getElementStorageScope(element));
3325
+ return 'autosave_' + pageScope + '_' + elementScope;
3326
+ }
3327
+
3328
+ getPageStorageScope() {
3329
+ try {
3330
+ return window.location.href || window.location.pathname || 'page';
3331
+ } catch (e) {
3332
+ return 'page';
3333
+ }
3334
+ }
3335
+
3336
+ getElementStorageScope(element) {
3337
+ if (this.originalElement.id) return this.originalElement.id;
3338
+ if (this.originalElement.name) return this.originalElement.name;
3339
+ if (this.originalElement.getAttribute('data-neiki-id')) {
3340
+ return this.originalElement.getAttribute('data-neiki-id');
3341
+ }
3342
+ if (typeof element === 'string') return element;
3343
+ return this.getElementPath(this.originalElement);
3344
+ }
3345
+
3346
+ getElementPath(element) {
3347
+ const parts = [];
3348
+ let node = element;
3349
+
3350
+ while (node && node.nodeType === Node.ELEMENT_NODE && node !== document.body) {
3351
+ let part = node.tagName.toLowerCase();
3352
+ const parent = node.parentNode;
3353
+
3354
+ if (parent && parent.children) {
3355
+ const siblings = Array.prototype.filter.call(parent.children, child => child.tagName === node.tagName);
3356
+ if (siblings.length > 1) {
3357
+ part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
3358
+ }
3359
+ }
3360
+
3361
+ parts.unshift(part);
3362
+ node = parent;
3363
+ }
3364
+
3365
+ return parts.length ? parts.join('>') : 'editor_' + this.instanceIndex;
3366
+ }
3367
+
3368
+ normalizeStorageKey(value) {
3369
+ const input = String(value).trim();
3370
+ let output = '';
3371
+ let lastWasSeparator = false;
3372
+
3373
+ for (let i = 0; i < input.length; i++) {
3374
+ const char = input[i];
3375
+ const isSafe = (char >= 'a' && char <= 'z') ||
3376
+ (char >= 'A' && char <= 'Z') ||
3377
+ (char >= '0' && char <= '9') ||
3378
+ char === '-' ||
3379
+ char === '_';
3380
+
3381
+ if (isSafe) {
3382
+ output += char;
3383
+ lastWasSeparator = false;
3384
+ } else if (!lastWasSeparator) {
3385
+ output += '_';
3386
+ lastWasSeparator = true;
3387
+ }
3388
+ }
3389
+
3390
+ while (output[0] === '_') output = output.slice(1);
3391
+ while (output[output.length - 1] === '_') output = output.slice(0, -1);
3392
+
3393
+ return output || 'editor';
3394
+ }
3395
+
3396
+ hashString(value) {
3397
+ let hash = 0;
3398
+ const input = String(value);
3399
+
3400
+ for (let i = 0; i < input.length; i++) {
3401
+ hash = ((hash << 5) - hash) + input.charCodeAt(i);
3402
+ hash |= 0;
3403
+ }
3404
+
3405
+ return Math.abs(hash).toString(36);
3406
+ }
3407
+
2927
3408
  createToolbar() {
2928
3409
  this.toolbar = Utils.createElement('div', { className: 'neiki-toolbar' });
2929
3410
  this.toolbarButtons = {};
@@ -3357,14 +3838,14 @@
3357
3838
 
3358
3839
  if (autosaveEnabled && autosavedContent) {
3359
3840
  // Restore autosaved content only if autosave was enabled
3360
- this.contentArea.innerHTML = autosavedContent;
3841
+ this.contentArea.innerHTML = Utils.sanitizeHTML(autosavedContent);
3361
3842
  } else {
3362
3843
  // Always use original element content (textarea value or innerHTML)
3363
3844
  // This ensures the page's actual content is shown, not old localStorage data
3364
3845
  if (this.originalElement.value) {
3365
- this.contentArea.innerHTML = this.originalElement.value;
3846
+ this.contentArea.innerHTML = Utils.sanitizeHTML(this.originalElement.value);
3366
3847
  } else if (this.originalElement.innerHTML.trim()) {
3367
- this.contentArea.innerHTML = this.originalElement.innerHTML;
3848
+ this.contentArea.innerHTML = Utils.sanitizeHTML(this.originalElement.innerHTML);
3368
3849
  }
3369
3850
  }
3370
3851
 
@@ -5026,7 +5507,6 @@
5026
5507
  if (!this.isResizing || !this.currentImg) return;
5027
5508
 
5028
5509
  const dx = e.clientX - this.startX;
5029
- const dy = e.clientY - this.startY;
5030
5510
 
5031
5511
  let newWidth, newHeight;
5032
5512