neiki-editor 2.9.3 → 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.
package/LICENSE CHANGED
@@ -1,21 +1,17 @@
1
- MIT License
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
2
3
 
3
- Copyright (c) 2026 neikiri
4
+ Copyright (C) 2026 neikiri
4
5
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published
8
+ by the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
11
10
 
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
14
15
 
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  <img src="https://img.shields.io/badge/html5-%23E34F26.svg?style=for-the-badge&logo=html5&logoColor=white" alt="HTML5">
11
11
  <img src="https://img.shields.io/badge/css-%23663399.svg?style=for-the-badge&logo=css&logoColor=white" alt="CSS">
12
12
  <br>
13
- <img src="https://img.shields.io/badge/License-MIT-2563EB?style=for-the-badge&logo=open-source-initiative&logoColor=white&labelColor=000F15&logoWidth=20" alt="License">
14
- <img src="https://img.shields.io/badge/Version-2.9.3-2563EB?style=for-the-badge&logo=semantic-release&logoColor=white&labelColor=000F15&logoWidth=20" alt="Version">
13
+ <img src="https://img.shields.io/badge/License-AGPL--3.0-2563EB?style=for-the-badge&logo=open-source-initiative&logoColor=white&labelColor=000F15&logoWidth=20" alt="License">
14
+ <img src="https://img.shields.io/badge/Version-2.9.4-2563EB?style=for-the-badge&logo=semantic-release&logoColor=white&labelColor=000F15&logoWidth=20" alt="Version">
15
15
  </p>
16
16
 
17
17
  <p align="center">
@@ -62,7 +62,7 @@ Add this single line — CSS is included automatically, always the **latest vers
62
62
  #### Pin a specific version
63
63
 
64
64
  ```html
65
- <script src="https://cdn.neikiri.dev/neiki-editor/2.9.3/neiki-editor.min.js"></script>
65
+ <script src="https://cdn.neikiri.dev/neiki-editor/2.9.4/neiki-editor.min.js"></script>
66
66
  ```
67
67
 
68
68
  #### Load CSS and JS separately
@@ -73,8 +73,8 @@ Add this single line — CSS is included automatically, always the **latest vers
73
73
  <script src="https://cdn.neikiri.dev/neiki-editor/neiki-editor.js"></script>
74
74
 
75
75
  <!-- Or pinned -->
76
- <link rel="stylesheet" href="https://cdn.neikiri.dev/neiki-editor/2.9.3/neiki-editor.css">
77
- <script src="https://cdn.neikiri.dev/neiki-editor/2.9.3/neiki-editor.js"></script>
76
+ <link rel="stylesheet" href="https://cdn.neikiri.dev/neiki-editor/2.9.4/neiki-editor.css">
77
+ <script src="https://cdn.neikiri.dev/neiki-editor/2.9.4/neiki-editor.js"></script>
78
78
  ```
79
79
 
80
80
  #### Alternative CDN — jsDelivr
@@ -84,15 +84,15 @@ Add this single line — CSS is included automatically, always the **latest vers
84
84
  <script src="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@latest/dist/neiki-editor.min.js"></script>
85
85
 
86
86
  <!-- Pinned -->
87
- <script src="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@2.9.3/dist/neiki-editor.min.js"></script>
87
+ <script src="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@2.9.4/dist/neiki-editor.min.js"></script>
88
88
 
89
89
  <!-- Separate files (latest) -->
90
90
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@latest/dist/neiki-editor.css">
91
91
  <script src="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@latest/dist/neiki-editor.js"></script>
92
92
 
93
93
  <!-- Separate files (pinned) -->
94
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@2.9.3/dist/neiki-editor.css">
95
- <script src="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@2.9.3/dist/neiki-editor.js"></script>
94
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@2.9.4/dist/neiki-editor.css">
95
+ <script src="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@2.9.4/dist/neiki-editor.js"></script>
96
96
  ```
97
97
 
98
98
  #### Package Manager
@@ -742,7 +742,7 @@ neiki-editor/
742
742
 
743
743
  ## 📄 License
744
744
 
745
- This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
745
+ This project is licensed under the GNU Affero General Public License v3 — see the [LICENSE](LICENSE) file for details.
746
746
 
747
747
  ---
748
748
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * NeikiEditor - Production-Ready WYSIWYG Rich Text Editor
3
3
  * CSS Stylesheet
4
- * Version: 2.9.3
4
+ * Version: 2.9.4
5
5
  */
6
6
 
7
7
  /* ============================================
@@ -1141,6 +1141,84 @@
1141
1141
  border-radius: 5px 0 0 5px;
1142
1142
  }
1143
1143
 
1144
+ .neiki-image-upload-zone {
1145
+ position: relative;
1146
+ display: grid;
1147
+ justify-items: center;
1148
+ gap: 7px;
1149
+ padding: 24px 18px;
1150
+ border: 2px dashed var(--neiki-border-color);
1151
+ border-radius: 8px;
1152
+ background: var(--neiki-bg-secondary);
1153
+ color: var(--neiki-text-secondary);
1154
+ text-align: center;
1155
+ cursor: pointer;
1156
+ transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
1157
+ }
1158
+
1159
+ .neiki-image-upload-zone:hover,
1160
+ .neiki-image-upload-zone:focus-visible {
1161
+ border-color: var(--neiki-accent-color);
1162
+ background: var(--neiki-bg-primary);
1163
+ box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.12);
1164
+ outline: none;
1165
+ }
1166
+
1167
+ .neiki-image-upload-zone.drag-over {
1168
+ border-color: var(--neiki-accent-color);
1169
+ background: rgba(13, 110, 253, 0.08);
1170
+ }
1171
+
1172
+ .neiki-image-upload-zone.has-files {
1173
+ border-style: solid;
1174
+ border-color: var(--neiki-accent-color);
1175
+ }
1176
+
1177
+ .neiki-image-upload-input {
1178
+ position: absolute;
1179
+ width: 1px;
1180
+ height: 1px;
1181
+ opacity: 0;
1182
+ pointer-events: none;
1183
+ }
1184
+
1185
+ .neiki-image-upload-icon {
1186
+ width: 38px;
1187
+ height: 38px;
1188
+ color: var(--neiki-accent-color);
1189
+ }
1190
+
1191
+ .neiki-image-upload-icon svg {
1192
+ width: 100%;
1193
+ height: 100%;
1194
+ fill: currentColor;
1195
+ }
1196
+
1197
+ .neiki-image-upload-title {
1198
+ color: var(--neiki-text-primary);
1199
+ font-size: 14px;
1200
+ font-weight: 600;
1201
+ }
1202
+
1203
+ .neiki-image-upload-hint,
1204
+ .neiki-image-upload-files {
1205
+ max-width: 100%;
1206
+ color: var(--neiki-text-muted);
1207
+ font-size: 12px;
1208
+ line-height: 1.4;
1209
+ }
1210
+
1211
+ .neiki-image-upload-files {
1212
+ overflow: hidden;
1213
+ text-overflow: ellipsis;
1214
+ white-space: nowrap;
1215
+ }
1216
+
1217
+ .neiki-image-upload-zone.has-files .neiki-image-upload-files {
1218
+ color: var(--neiki-accent-color);
1219
+ font-weight: 500;
1220
+ }
1221
+
1144
1222
  .neiki-form-row {
1145
1223
  display: flex;
1146
1224
  gap: 16px;
@@ -1513,7 +1591,7 @@
1513
1591
  Floating Selection Toolbar
1514
1592
  ============================================ */
1515
1593
  .neiki-floating-toolbar {
1516
- position: absolute;
1594
+ position: fixed;
1517
1595
  z-index: 1000;
1518
1596
  display: none;
1519
1597
  background: var(--neiki-bg-primary);
@@ -1975,6 +2053,29 @@
1975
2053
  font-size: 16px; /* Prevents iOS zoom on focus */
1976
2054
  }
1977
2055
 
2056
+ .neiki-image-upload-zone {
2057
+ grid-template-columns: 32px 1fr;
2058
+ justify-items: start;
2059
+ gap: 4px 10px;
2060
+ padding: 14px;
2061
+ text-align: left;
2062
+ }
2063
+
2064
+ .neiki-image-upload-icon {
2065
+ grid-row: span 3;
2066
+ width: 30px;
2067
+ height: 30px;
2068
+ }
2069
+
2070
+ .neiki-image-upload-title {
2071
+ font-size: 13px;
2072
+ }
2073
+
2074
+ .neiki-image-upload-hint,
2075
+ .neiki-image-upload-files {
2076
+ font-size: 11px;
2077
+ }
2078
+
1978
2079
  .neiki-find-replace {
1979
2080
  width: 100%;
1980
2081
  border-radius: 0;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * NeikiEditor - A Modern WYSIWYG Editor
3
- * Version: 2.9.3
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.)
@@ -1344,37 +1344,237 @@
1344
1344
  },
1345
1345
 
1346
1346
  sanitizeHTML(html) {
1347
- const parser = new DOMParser();
1348
- const doc = parser.parseFromString(String(html || ''), 'text/html');
1347
+ const input = String(html || '');
1348
+ const lowerInput = input.toLowerCase();
1349
+ const root = document.createDocumentFragment();
1350
+ const stack = [root];
1349
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']);
1350
1359
  const urlAttrs = new Set(['href', 'src', 'xlink:href', 'poster']);
1360
+ let index = 0;
1351
1361
 
1352
- doc.body.querySelectorAll('*').forEach(el => {
1353
- if (blockedTags.has(el.tagName.toLowerCase())) {
1354
- el.remove();
1355
- return;
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;
1356
1370
  }
1357
1371
 
1358
- Array.from(el.attributes).forEach(attr => {
1359
- const name = attr.name.toLowerCase();
1360
- const value = attr.value.trim();
1372
+ if (tagStart > index) {
1373
+ currentParent().appendChild(document.createTextNode(input.slice(index, tagStart)));
1374
+ }
1361
1375
 
1362
- if (name.startsWith('on') || name === 'srcdoc') {
1363
- el.removeAttribute(attr.name);
1364
- return;
1365
- }
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
+ }
1366
1387
 
1367
- if (urlAttrs.has(name) && !Utils.isSafeUrl(value, el.tagName.toLowerCase() === 'img')) {
1368
- el.removeAttribute(attr.name);
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
+ }
1369
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);
1370
1433
  });
1371
1434
 
1372
- if (el.tagName.toLowerCase() === 'a' && el.getAttribute('target') === '_blank') {
1435
+ if (tagName === 'a' && el.getAttribute('target') === '_blank') {
1373
1436
  el.setAttribute('rel', 'noopener noreferrer');
1374
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
+ }
1375
1575
  });
1376
1576
 
1377
- return doc.body.innerHTML;
1577
+ return html;
1378
1578
  },
1379
1579
 
1380
1580
  isSafeUrl(value, allowImageData = false) {
@@ -1744,7 +1944,7 @@
1744
1944
  this.overlay.appendChild(modal);
1745
1945
  this.overlay.classList.add('active');
1746
1946
 
1747
- const firstInput = modal.querySelector('input');
1947
+ const firstInput = modal.querySelector('input:not([type="file"]), textarea, select, button');
1748
1948
  if (firstInput) firstInput.focus();
1749
1949
  }
1750
1950
 
@@ -1853,8 +2053,13 @@
1853
2053
  <div class="neiki-modal-body">
1854
2054
  <div class="neiki-form-group">
1855
2055
  <label>${Utils.escapeHTML(t('modal.uploadImage'))}</label>
1856
- <input type="file" class="neiki-input" name="upload" accept="image/*" multiple>
1857
- <small class="neiki-upload-hint" style="color: var(--neiki-text-muted); font-size: 11px;">${Utils.escapeHTML(uploadHint)}</small>
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>
1858
2063
  </div>
1859
2064
  <div class="neiki-form-divider">
1860
2065
  <span>${Utils.escapeHTML(t('modal.or'))}</span>
@@ -1879,19 +2084,27 @@
1879
2084
  `;
1880
2085
 
1881
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');
1882
2089
  const urlInput = modal.querySelector('[name="url"]');
1883
2090
  const insertBtn = modal.querySelector('[data-action="insert"]');
1884
2091
  let pendingFiles = [];
2092
+ let uploadDragCounter = 0;
1885
2093
 
1886
2094
  urlInput.value = data.url || '';
1887
2095
  modal.querySelector('[name="alt"]').placeholder = t('modal.describeImage');
1888
2096
  modal.querySelector('[name="alt"]').value = data.alt || '';
1889
2097
  modal.querySelector('[name="width"]').value = data.width || '';
1890
2098
 
1891
- // Handle file upload (supports multiple files)
1892
- uploadInput.addEventListener('change', (e) => {
1893
- const files = Array.from(e.target.files).filter(f => f.type.startsWith('image/'));
1894
- const invalid = Array.from(e.target.files).filter(f => !f.type.startsWith('image/'));
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/'));
1895
2108
 
1896
2109
  if (invalid.length > 0) {
1897
2110
  alert(t('modal.invalidImageFile'));
@@ -1899,13 +2112,15 @@
1899
2112
 
1900
2113
  if (files.length === 0) {
1901
2114
  pendingFiles = [];
2115
+ updateUploadFeedback([]);
2116
+ urlInput.disabled = false;
1902
2117
  return;
1903
2118
  }
1904
2119
 
1905
2120
  pendingFiles = files;
2121
+ updateUploadFeedback(files);
1906
2122
 
1907
2123
  if (files.length === 1 && !hasUploadHandler) {
1908
- // Single file without handler — preview as base64 in URL field
1909
2124
  const reader = new FileReader();
1910
2125
  reader.onload = (ev) => {
1911
2126
  urlInput.value = ev.target.result;
@@ -1913,10 +2128,51 @@
1913
2128
  };
1914
2129
  reader.readAsDataURL(files[0]);
1915
2130
  } else {
1916
- // Multiple files or handler present — disable URL field
1917
2131
  urlInput.value = '';
1918
2132
  urlInput.disabled = true;
1919
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);
1920
2176
  });
1921
2177
 
1922
2178
  // Clear URL when upload is cleared
@@ -1925,6 +2181,7 @@
1925
2181
  urlInput.disabled = false;
1926
2182
  uploadInput.value = '';
1927
2183
  pendingFiles = [];
2184
+ updateUploadFeedback([]);
1928
2185
  }
1929
2186
  });
1930
2187
 
@@ -2254,7 +2511,7 @@
2254
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;">
2255
2512
  <div style="font-size: 14px; line-height: 2; color: var(--neiki-text-primary);">
2256
2513
  <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>
2514
+ <div><strong>${Utils.escapeHTML(t('help.version'))}:</strong> 2.9.4</div>
2258
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>
2259
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>
2260
2517
  </div>
@@ -2876,8 +3133,8 @@
2876
3133
  if (range && !range.collapsed) {
2877
3134
  this.exec('createLink', url);
2878
3135
  if (newTab) {
2879
- const selectorUrl = window.CSS && CSS.escape ? CSS.escape(url) : url.replace(/"/g, '\\"');
2880
- const links = this.editor.contentArea.querySelectorAll('a[href="' + selectorUrl + '"]');
3136
+ const links = Array.from(this.editor.contentArea.querySelectorAll('a'))
3137
+ .filter(link => link.getAttribute('href') === url);
2881
3138
  links.forEach(link => {
2882
3139
  link.setAttribute('target', '_blank');
2883
3140
  link.setAttribute('rel', 'noopener noreferrer');
@@ -3109,10 +3366,31 @@
3109
3366
  }
3110
3367
 
3111
3368
  normalizeStorageKey(value) {
3112
- return String(value)
3113
- .trim()
3114
- .replace(/[^a-zA-Z0-9_-]+/g, '_')
3115
- .replace(/^_+|_+$/g, '') || 'editor';
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';
3116
3394
  }
3117
3395
 
3118
3396
  hashString(value) {