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 +13 -17
- package/README.md +9 -9
- package/dist/neiki-editor.css +103 -2
- package/dist/neiki-editor.js +312 -34
- package/dist/neiki-editor.min.css +1 -1
- package/dist/neiki-editor.min.js +1 -1
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 19 November 2007
|
|
2
3
|
|
|
3
|
-
Copyright (
|
|
4
|
+
Copyright (C) 2026 neikiri
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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-
|
|
14
|
-
<img src="https://img.shields.io/badge/Version-2.9.
|
|
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.
|
|
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.
|
|
77
|
-
<script src="https://cdn.neikiri.dev/neiki-editor/2.9.
|
|
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.
|
|
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.
|
|
95
|
-
<script src="https://cdn.jsdelivr.net/gh/neikiri/neiki-editor@2.9.
|
|
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
|
|
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
|
|
package/dist/neiki-editor.css
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* NeikiEditor - Production-Ready WYSIWYG Rich Text Editor
|
|
3
3
|
* CSS Stylesheet
|
|
4
|
-
* Version: 2.9.
|
|
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:
|
|
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;
|
package/dist/neiki-editor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* NeikiEditor - A Modern WYSIWYG Editor
|
|
3
|
-
* Version: 2.9.
|
|
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
|
|
1348
|
-
const
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1372
|
+
if (tagStart > index) {
|
|
1373
|
+
currentParent().appendChild(document.createTextNode(input.slice(index, tagStart)));
|
|
1374
|
+
}
|
|
1361
1375
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1368
|
-
|
|
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 (
|
|
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
|
|
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
|
-
<
|
|
1857
|
-
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
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.
|
|
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
|
|
2880
|
-
|
|
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
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
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) {
|