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.
- package/LICENSE +13 -17
- package/README.md +24 -13
- package/dist/neiki-editor.css +103 -2
- package/dist/neiki-editor.js +578 -98
- package/dist/neiki-editor.min.css +1 -1
- package/dist/neiki-editor.min.js +1 -1
- package/package.json +2 -2
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.)
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
1325
|
-
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
+
'&': '&',
|
|
1600
|
+
'<': '<',
|
|
1601
|
+
'>': '>',
|
|
1602
|
+
'"': '"',
|
|
1603
|
+
"'": '''
|
|
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
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
<
|
|
1745
|
-
|
|
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"
|
|
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"
|
|
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%"
|
|
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
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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"
|
|
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"
|
|
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" ${
|
|
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"
|
|
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"
|
|
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.
|
|
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
|
|
2759
|
-
|
|
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)
|
|
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
|
-
|
|
2776
|
-
|
|
2777
|
-
if (
|
|
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.
|
|
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
|
|