neiki-editor 2.9.2 → 2.9.3

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.3
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,64 @@
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 parser = new DOMParser();
1348
+ const doc = parser.parseFromString(String(html || ''), 'text/html');
1349
+ const blockedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'base']);
1350
+ const urlAttrs = new Set(['href', 'src', 'xlink:href', 'poster']);
1351
+
1352
+ doc.body.querySelectorAll('*').forEach(el => {
1353
+ if (blockedTags.has(el.tagName.toLowerCase())) {
1354
+ el.remove();
1355
+ return;
1356
+ }
1357
+
1358
+ Array.from(el.attributes).forEach(attr => {
1359
+ const name = attr.name.toLowerCase();
1360
+ const value = attr.value.trim();
1361
+
1362
+ if (name.startsWith('on') || name === 'srcdoc') {
1363
+ el.removeAttribute(attr.name);
1364
+ return;
1365
+ }
1366
+
1367
+ if (urlAttrs.has(name) && !Utils.isSafeUrl(value, el.tagName.toLowerCase() === 'img')) {
1368
+ el.removeAttribute(attr.name);
1369
+ }
1370
+ });
1371
+
1372
+ if (el.tagName.toLowerCase() === 'a' && el.getAttribute('target') === '_blank') {
1373
+ el.setAttribute('rel', 'noopener noreferrer');
1374
+ }
1375
+ });
1376
+
1377
+ return doc.body.innerHTML;
1378
+ },
1379
+
1380
+ isSafeUrl(value, allowImageData = false) {
1381
+ if (!value) return true;
1382
+ if (value.startsWith('#') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) return true;
1383
+
1384
+ try {
1385
+ const parsed = new URL(value, window.location.href);
1386
+ const protocol = parsed.protocol.toLowerCase();
1387
+ return protocol === 'http:' ||
1388
+ protocol === 'https:' ||
1389
+ protocol === 'mailto:' ||
1390
+ protocol === 'tel:' ||
1391
+ (allowImageData && protocol === 'data:' && /^data:image\//i.test(value));
1392
+ } catch (e) {
1393
+ return false;
1394
+ }
1395
+ },
1396
+
1397
+ escapeHTML(value) {
1398
+ return String(value ?? '').replace(/[&<>"']/g, char => ({
1399
+ '&': '&amp;',
1400
+ '<': '&lt;',
1401
+ '>': '&gt;',
1402
+ '"': '&quot;',
1403
+ "'": '&#39;'
1404
+ })[char]);
1329
1405
  },
1330
1406
 
1331
1407
  isValidUrl(string) {
@@ -1685,38 +1761,74 @@
1685
1761
  createLinkModal(data) {
1686
1762
  const modal = Utils.createElement('div', { className: 'neiki-modal' });
1687
1763
 
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
- `;
1764
+ const header = Utils.createElement('div', { className: 'neiki-modal-header' });
1765
+ header.appendChild(Utils.createElement('h3', { textContent: t('modal.insertLink') }));
1766
+ const closeBtn = Utils.createElement('button', {
1767
+ className: 'neiki-modal-close',
1768
+ type: 'button',
1769
+ innerHTML: Icons.close
1770
+ });
1771
+ header.appendChild(closeBtn);
1713
1772
 
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;
1773
+ const body = Utils.createElement('div', { className: 'neiki-modal-body' });
1774
+ const urlGroup = Utils.createElement('div', { className: 'neiki-form-group' });
1775
+ const urlInput = Utils.createElement('input', {
1776
+ type: 'url',
1777
+ className: 'neiki-input',
1778
+ name: 'url',
1779
+ placeholder: 'https://example.com'
1780
+ });
1781
+ urlInput.value = data.url || '';
1782
+ urlGroup.appendChild(Utils.createElement('label', { textContent: t('modal.url') }));
1783
+ urlGroup.appendChild(urlInput);
1784
+
1785
+ const textGroup = Utils.createElement('div', { className: 'neiki-form-group' });
1786
+ const textInput = Utils.createElement('input', {
1787
+ type: 'text',
1788
+ className: 'neiki-input',
1789
+ name: 'text',
1790
+ placeholder: t('modal.linkText')
1791
+ });
1792
+ textInput.value = data.text || '';
1793
+ textGroup.appendChild(Utils.createElement('label', { textContent: t('modal.text') }));
1794
+ textGroup.appendChild(textInput);
1795
+
1796
+ const newTabGroup = Utils.createElement('div', { className: 'neiki-form-group' });
1797
+ const newTabLabel = Utils.createElement('label');
1798
+ const newTabInput = Utils.createElement('input', { type: 'checkbox', name: 'newTab' });
1799
+ newTabInput.checked = !!data.newTab;
1800
+ newTabLabel.appendChild(newTabInput);
1801
+ newTabLabel.appendChild(document.createTextNode(' ' + t('modal.openInNewTab')));
1802
+ newTabGroup.appendChild(newTabLabel);
1803
+
1804
+ body.appendChild(urlGroup);
1805
+ body.appendChild(textGroup);
1806
+ body.appendChild(newTabGroup);
1807
+
1808
+ const footer = Utils.createElement('div', { className: 'neiki-modal-footer' });
1809
+ const cancelBtn = Utils.createElement('button', {
1810
+ className: 'neiki-btn neiki-btn-secondary',
1811
+ type: 'button',
1812
+ textContent: t('modal.cancel')
1813
+ });
1814
+ const insertBtn = Utils.createElement('button', {
1815
+ className: 'neiki-btn neiki-btn-primary',
1816
+ type: 'button',
1817
+ textContent: t('modal.insert')
1818
+ });
1819
+ footer.appendChild(cancelBtn);
1820
+ footer.appendChild(insertBtn);
1821
+
1822
+ modal.appendChild(header);
1823
+ modal.appendChild(body);
1824
+ modal.appendChild(footer);
1825
+
1826
+ closeBtn.addEventListener('click', () => this.close());
1827
+ cancelBtn.addEventListener('click', () => this.close());
1828
+ insertBtn.addEventListener('click', () => {
1829
+ const url = urlInput.value;
1830
+ const text = textInput.value || url;
1831
+ const newTab = newTabInput.checked;
1720
1832
 
1721
1833
  if (url) {
1722
1834
  this.editor.restoreSavedSelection();
@@ -1735,43 +1847,47 @@
1735
1847
 
1736
1848
  modal.innerHTML = `
1737
1849
  <div class="neiki-modal-header">
1738
- <h3>${t('modal.insertImage')}</h3>
1850
+ <h3>${Utils.escapeHTML(t('modal.insertImage'))}</h3>
1739
1851
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
1740
1852
  </div>
1741
1853
  <div class="neiki-modal-body">
1742
1854
  <div class="neiki-form-group">
1743
- <label>${t('modal.uploadImage')}</label>
1855
+ <label>${Utils.escapeHTML(t('modal.uploadImage'))}</label>
1744
1856
  <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>
1857
+ <small class="neiki-upload-hint" style="color: var(--neiki-text-muted); font-size: 11px;">${Utils.escapeHTML(uploadHint)}</small>
1746
1858
  </div>
1747
1859
  <div class="neiki-form-divider">
1748
- <span>${t('modal.or')}</span>
1860
+ <span>${Utils.escapeHTML(t('modal.or'))}</span>
1749
1861
  </div>
1750
1862
  <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 || ''}">
1863
+ <label>${Utils.escapeHTML(t('modal.imageUrl'))}</label>
1864
+ <input type="url" class="neiki-input" name="url" placeholder="https://example.com/image.jpg">
1753
1865
  </div>
1754
1866
  <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 || ''}">
1867
+ <label>${Utils.escapeHTML(t('modal.altText'))}</label>
1868
+ <input type="text" class="neiki-input" name="alt">
1757
1869
  </div>
1758
1870
  <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 || ''}">
1871
+ <label>${Utils.escapeHTML(t('modal.widthOptional'))}</label>
1872
+ <input type="text" class="neiki-input" name="width" placeholder="e.g. 300px or 50%">
1761
1873
  </div>
1762
1874
  </div>
1763
1875
  <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>
1876
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${Utils.escapeHTML(t('modal.cancel'))}</button>
1877
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${Utils.escapeHTML(t('modal.insert'))}</button>
1766
1878
  </div>
1767
1879
  `;
1768
1880
 
1769
1881
  const uploadInput = modal.querySelector('[name="upload"]');
1770
1882
  const urlInput = modal.querySelector('[name="url"]');
1771
- const hintEl = modal.querySelector('.neiki-upload-hint');
1772
1883
  const insertBtn = modal.querySelector('[data-action="insert"]');
1773
1884
  let pendingFiles = [];
1774
1885
 
1886
+ urlInput.value = data.url || '';
1887
+ modal.querySelector('[name="alt"]').placeholder = t('modal.describeImage');
1888
+ modal.querySelector('[name="alt"]').value = data.alt || '';
1889
+ modal.querySelector('[name="width"]').value = data.width || '';
1890
+
1775
1891
  // Handle file upload (supports multiple files)
1776
1892
  uploadInput.addEventListener('change', (e) => {
1777
1893
  const files = Array.from(e.target.files).filter(f => f.type.startsWith('image/'));
@@ -1842,7 +1958,6 @@
1842
1958
  this.close();
1843
1959
  } else if (pendingFiles.length > 1) {
1844
1960
  // Multiple files without handler — insert each as base64
1845
- let loaded = 0;
1846
1961
  insertBtn.disabled = true;
1847
1962
  insertBtn.textContent = t('modal.uploadingImage');
1848
1963
 
@@ -1878,32 +1993,35 @@
1878
1993
 
1879
1994
  modal.innerHTML = `
1880
1995
  <div class="neiki-modal-header">
1881
- <h3>${t('modal.insertTable')}</h3>
1996
+ <h3>${Utils.escapeHTML(t('modal.insertTable'))}</h3>
1882
1997
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
1883
1998
  </div>
1884
1999
  <div class="neiki-modal-body">
1885
2000
  <div class="neiki-form-row">
1886
2001
  <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}">
2002
+ <label>${Utils.escapeHTML(t('modal.rows'))}</label>
2003
+ <input type="number" class="neiki-input" name="rows" min="1" max="20">
1889
2004
  </div>
1890
2005
  <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}">
2006
+ <label>${Utils.escapeHTML(t('modal.columns'))}</label>
2007
+ <input type="number" class="neiki-input" name="cols" min="1" max="10">
1893
2008
  </div>
1894
2009
  </div>
1895
2010
  <div class="neiki-form-group">
1896
2011
  <label>
1897
- <input type="checkbox" name="header" ${data.header !== false ? 'checked' : ''}> ${t('modal.includeHeaderRow')}
2012
+ <input type="checkbox" name="header"> ${Utils.escapeHTML(t('modal.includeHeaderRow'))}
1898
2013
  </label>
1899
2014
  </div>
1900
2015
  </div>
1901
2016
  <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>
2017
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${Utils.escapeHTML(t('modal.cancel'))}</button>
2018
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${Utils.escapeHTML(t('modal.insert'))}</button>
1904
2019
  </div>
1905
2020
  `;
1906
2021
 
2022
+ modal.querySelector('[name="rows"]').value = parseInt(data.rows, 10) || 3;
2023
+ modal.querySelector('[name="cols"]').value = parseInt(data.cols, 10) || 3;
2024
+ modal.querySelector('[name="header"]').checked = data.header !== false;
1907
2025
  modal.querySelector('.neiki-modal-close').addEventListener('click', () => this.close());
1908
2026
  modal.querySelector('[data-action="cancel"]').addEventListener('click', () => this.close());
1909
2027
  modal.querySelector('[data-action="insert"]').addEventListener('click', () => {
@@ -1924,28 +2042,28 @@
1924
2042
 
1925
2043
  modal.innerHTML = `
1926
2044
  <div class="neiki-modal-header">
1927
- <h3>${t('modal.findReplace')}</h3>
2045
+ <h3>${Utils.escapeHTML(t('modal.findReplace'))}</h3>
1928
2046
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
1929
2047
  </div>
1930
2048
  <div class="neiki-modal-body">
1931
2049
  <div class="neiki-form-group">
1932
- <label>${t('modal.find')}</label>
1933
- <input type="text" class="neiki-input" name="find" placeholder="${t('modal.searchText')}">
2050
+ <label>${Utils.escapeHTML(t('modal.find'))}</label>
2051
+ <input type="text" class="neiki-input" name="find">
1934
2052
  </div>
1935
2053
  <div class="neiki-form-group">
1936
- <label>${t('modal.replaceWith')}</label>
1937
- <input type="text" class="neiki-input" name="replace" placeholder="${t('modal.replacementText')}">
2054
+ <label>${Utils.escapeHTML(t('modal.replaceWith'))}</label>
2055
+ <input type="text" class="neiki-input" name="replace">
1938
2056
  </div>
1939
2057
  <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>
2058
+ <label><input type="checkbox" name="regex"> ${Utils.escapeHTML(t('modal.useRegex'))}</label>
2059
+ <label><input type="checkbox" name="caseSensitive"> ${Utils.escapeHTML(t('modal.caseSensitive'))}</label>
1942
2060
  </div>
1943
2061
  <div class="neiki-find-results" style="margin-top:10px;font-size:13px;color:var(--neiki-text-muted);"></div>
1944
2062
  </div>
1945
2063
  <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>
2064
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="findNext">${Utils.escapeHTML(t('modal.findNext'))}</button>
2065
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="replaceOne">${Utils.escapeHTML(t('modal.replace'))}</button>
2066
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="replaceAll">${Utils.escapeHTML(t('modal.replaceAll'))}</button>
1949
2067
  </div>
1950
2068
  `;
1951
2069
 
@@ -1958,6 +2076,9 @@
1958
2076
  let currentMatches = [];
1959
2077
  let currentIndex = -1;
1960
2078
 
2079
+ findInput.placeholder = t('modal.searchText');
2080
+ replaceInput.placeholder = t('modal.replacementText');
2081
+
1961
2082
  const clearHighlights = () => {
1962
2083
  const highlights = this.editor.contentArea.querySelectorAll('.neiki-highlight-find');
1963
2084
  highlights.forEach(h => {
@@ -1978,7 +2099,6 @@
1978
2099
  return;
1979
2100
  }
1980
2101
 
1981
- const content = this.editor.contentArea.innerHTML;
1982
2102
  let flags = 'g';
1983
2103
  if (!caseCheck.checked) flags += 'i';
1984
2104
 
@@ -2127,16 +2247,16 @@
2127
2247
 
2128
2248
  modal.innerHTML = `
2129
2249
  <div class="neiki-modal-header">
2130
- <h3>${t('menu.help')}</h3>
2250
+ <h3>${Utils.escapeHTML(t('menu.help'))}</h3>
2131
2251
  <button class="neiki-modal-close" type="button">${Icons.close}</button>
2132
2252
  </div>
2133
2253
  <div class="neiki-modal-body" style="text-align: center; padding: 24px 20px;">
2134
2254
  <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
2255
  <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>
2256
+ <div><strong>${Utils.escapeHTML(t('help.author'))}:</strong> neikiri (Jindřich Stoklasa)</div>
2257
+ <div><strong>${Utils.escapeHTML(t('help.version'))}:</strong> 2.9.3</div>
2258
+ <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>
2259
+ <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
2260
  </div>
2141
2261
  </div>
2142
2262
  `;
@@ -2743,26 +2863,34 @@
2743
2863
 
2744
2864
  insertHTML(html) {
2745
2865
  this.editor.focus();
2746
- document.execCommand('insertHTML', false, html);
2866
+ document.execCommand('insertHTML', false, Utils.sanitizeHTML(html));
2747
2867
  this.editor.history.record();
2748
2868
  this.editor.triggerChange();
2749
2869
  }
2750
2870
 
2751
2871
  insertLink(url, text, newTab = false) {
2872
+ if (!Utils.isSafeUrl(url)) return;
2752
2873
  const selection = Utils.getSelection();
2753
2874
  const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
2754
2875
 
2755
2876
  if (range && !range.collapsed) {
2756
2877
  this.exec('createLink', url);
2757
2878
  if (newTab) {
2758
- const links = this.editor.contentArea.querySelectorAll('a[href="' + url + '"]');
2759
- links.forEach(link => link.setAttribute('target', '_blank'));
2879
+ const selectorUrl = window.CSS && CSS.escape ? CSS.escape(url) : url.replace(/"/g, '\\"');
2880
+ const links = this.editor.contentArea.querySelectorAll('a[href="' + selectorUrl + '"]');
2881
+ links.forEach(link => {
2882
+ link.setAttribute('target', '_blank');
2883
+ link.setAttribute('rel', 'noopener noreferrer');
2884
+ });
2760
2885
  }
2761
2886
  } else {
2762
2887
  const link = document.createElement('a');
2763
2888
  link.href = url;
2764
2889
  link.textContent = text || url;
2765
- if (newTab) link.target = '_blank';
2890
+ if (newTab) {
2891
+ link.target = '_blank';
2892
+ link.rel = 'noopener noreferrer';
2893
+ }
2766
2894
 
2767
2895
  this.editor.focus();
2768
2896
  document.execCommand('insertHTML', false, link.outerHTML);
@@ -2772,9 +2900,10 @@
2772
2900
  }
2773
2901
 
2774
2902
  insertImage(url, alt = '', width = '') {
2775
- let html = `<img src="${url}"`;
2776
- if (alt) html += ` alt="${alt}"`;
2777
- if (width) html += ` width="${width}"`;
2903
+ if (!Utils.isSafeUrl(url, true)) return;
2904
+ let html = `<img src="${Utils.escapeHTML(url)}"`;
2905
+ if (alt) html += ` alt="${Utils.escapeHTML(alt)}"`;
2906
+ if (width) html += ` width="${Utils.escapeHTML(width)}"`;
2778
2907
  html += '>';
2779
2908
 
2780
2909
  this.editor.focus();
@@ -2841,6 +2970,8 @@
2841
2970
  'neiki_' + (typeof element === 'string' ? element.replace(/[^a-zA-Z0-9]/g, '_') : 'editor');
2842
2971
 
2843
2972
  this.config = Utils.deepMerge(DEFAULT_CONFIG, options);
2973
+ this.instanceIndex = ++EDITOR_INSTANCE_COUNTER;
2974
+ this.storageId = this.createAutosaveStorageId(element);
2844
2975
  this.isFullscreen = false;
2845
2976
  this.isAutosaveEnabled = false;
2846
2977
  this.autosaveInterval = null;
@@ -2851,7 +2982,7 @@
2851
2982
 
2852
2983
  init() {
2853
2984
  // Initialize storage first
2854
- this.storage = new StorageManager(this.id);
2985
+ this.storage = new StorageManager(this.storageId);
2855
2986
 
2856
2987
  // Set language for translations
2857
2988
  _currentLanguage = this.config.language || 'en';
@@ -2924,6 +3055,78 @@
2924
3055
  this.originalElement.parentNode.insertBefore(this.container, this.originalElement);
2925
3056
  }
2926
3057
 
3058
+ createAutosaveStorageId(element) {
3059
+ const customKey = this.config.autosaveKey ||
3060
+ this.originalElement.getAttribute('data-neiki-autosave-key');
3061
+
3062
+ if (customKey) {
3063
+ return 'autosave_' + this.normalizeStorageKey(customKey);
3064
+ }
3065
+
3066
+ const pageScope = this.hashString(this.getPageStorageScope());
3067
+ const elementScope = this.normalizeStorageKey(this.getElementStorageScope(element));
3068
+ return 'autosave_' + pageScope + '_' + elementScope;
3069
+ }
3070
+
3071
+ getPageStorageScope() {
3072
+ try {
3073
+ return window.location.href || window.location.pathname || 'page';
3074
+ } catch (e) {
3075
+ return 'page';
3076
+ }
3077
+ }
3078
+
3079
+ getElementStorageScope(element) {
3080
+ if (this.originalElement.id) return this.originalElement.id;
3081
+ if (this.originalElement.name) return this.originalElement.name;
3082
+ if (this.originalElement.getAttribute('data-neiki-id')) {
3083
+ return this.originalElement.getAttribute('data-neiki-id');
3084
+ }
3085
+ if (typeof element === 'string') return element;
3086
+ return this.getElementPath(this.originalElement);
3087
+ }
3088
+
3089
+ getElementPath(element) {
3090
+ const parts = [];
3091
+ let node = element;
3092
+
3093
+ while (node && node.nodeType === Node.ELEMENT_NODE && node !== document.body) {
3094
+ let part = node.tagName.toLowerCase();
3095
+ const parent = node.parentNode;
3096
+
3097
+ if (parent && parent.children) {
3098
+ const siblings = Array.prototype.filter.call(parent.children, child => child.tagName === node.tagName);
3099
+ if (siblings.length > 1) {
3100
+ part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
3101
+ }
3102
+ }
3103
+
3104
+ parts.unshift(part);
3105
+ node = parent;
3106
+ }
3107
+
3108
+ return parts.length ? parts.join('>') : 'editor_' + this.instanceIndex;
3109
+ }
3110
+
3111
+ normalizeStorageKey(value) {
3112
+ return String(value)
3113
+ .trim()
3114
+ .replace(/[^a-zA-Z0-9_-]+/g, '_')
3115
+ .replace(/^_+|_+$/g, '') || 'editor';
3116
+ }
3117
+
3118
+ hashString(value) {
3119
+ let hash = 0;
3120
+ const input = String(value);
3121
+
3122
+ for (let i = 0; i < input.length; i++) {
3123
+ hash = ((hash << 5) - hash) + input.charCodeAt(i);
3124
+ hash |= 0;
3125
+ }
3126
+
3127
+ return Math.abs(hash).toString(36);
3128
+ }
3129
+
2927
3130
  createToolbar() {
2928
3131
  this.toolbar = Utils.createElement('div', { className: 'neiki-toolbar' });
2929
3132
  this.toolbarButtons = {};
@@ -3357,14 +3560,14 @@
3357
3560
 
3358
3561
  if (autosaveEnabled && autosavedContent) {
3359
3562
  // Restore autosaved content only if autosave was enabled
3360
- this.contentArea.innerHTML = autosavedContent;
3563
+ this.contentArea.innerHTML = Utils.sanitizeHTML(autosavedContent);
3361
3564
  } else {
3362
3565
  // Always use original element content (textarea value or innerHTML)
3363
3566
  // This ensures the page's actual content is shown, not old localStorage data
3364
3567
  if (this.originalElement.value) {
3365
- this.contentArea.innerHTML = this.originalElement.value;
3568
+ this.contentArea.innerHTML = Utils.sanitizeHTML(this.originalElement.value);
3366
3569
  } else if (this.originalElement.innerHTML.trim()) {
3367
- this.contentArea.innerHTML = this.originalElement.innerHTML;
3570
+ this.contentArea.innerHTML = Utils.sanitizeHTML(this.originalElement.innerHTML);
3368
3571
  }
3369
3572
  }
3370
3573
 
@@ -5026,7 +5229,6 @@
5026
5229
  if (!this.isResizing || !this.currentImg) return;
5027
5230
 
5028
5231
  const dx = e.clientX - this.startX;
5029
- const dy = e.clientY - this.startY;
5030
5232
 
5031
5233
  let newWidth, newHeight;
5032
5234