@overlap/rte 0.1.11 → 1.0.0
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/README.md +45 -5
- package/dist/components/Dropdown.d.ts.map +1 -1
- package/dist/components/Editor.d.ts.map +1 -1
- package/dist/components/LinkTooltip.d.ts.map +1 -1
- package/dist/components/Toolbar.d.ts.map +1 -1
- package/dist/hooks/useEditorEvents.d.ts +4 -1
- package/dist/hooks/useEditorEvents.d.ts.map +1 -1
- package/dist/hooks/useEditorInit.d.ts.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +349 -151
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +349 -150
- package/dist/index.js.map +1 -1
- package/dist/plugins/image.d.ts +1 -1
- package/dist/plugins/image.d.ts.map +1 -1
- package/dist/plugins/linkDialog.d.ts.map +1 -1
- package/dist/plugins/optional.d.ts.map +1 -1
- package/dist/utils/autoLink.d.ts.map +1 -1
- package/dist/utils/content.d.ts +1 -0
- package/dist/utils/content.d.ts.map +1 -1
- package/dist/utils/sanitize.d.ts +6 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/package.json +1 -4
package/dist/index.esm.js
CHANGED
|
@@ -102,7 +102,20 @@ const Dropdown = ({ icon, label, options, onSelect, currentValue, disabled, show
|
|
|
102
102
|
setIsOpen(false);
|
|
103
103
|
};
|
|
104
104
|
const currentOption = options.find(opt => opt.value === currentValue);
|
|
105
|
-
|
|
105
|
+
// Close on Escape key
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!isOpen)
|
|
108
|
+
return;
|
|
109
|
+
const handleKeyDown = (e) => {
|
|
110
|
+
if (e.key === "Escape") {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
setIsOpen(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
116
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
117
|
+
}, [isOpen]);
|
|
118
|
+
return (jsxs("div", { className: "rte-dropdown", ref: dropdownRef, onMouseDown: (e) => e.preventDefault(), children: [jsxs("button", { type: "button", onClick: () => !disabled && setIsOpen(!isOpen), disabled: disabled, className: `rte-toolbar-button rte-dropdown-button ${currentOption ? 'rte-dropdown-button-has-value' : ''}`, title: label, "aria-label": label, "aria-expanded": isOpen, "aria-haspopup": "listbox", children: [jsx(Icon, { icon: icon, width: 18, height: 18 }), currentOption && (jsx("span", { className: "rte-dropdown-value", children: currentOption.label }))] }), isOpen && (jsxs("div", { className: "rte-dropdown-menu", role: "listbox", "aria-label": label, children: [options.map((option) => (jsxs("button", { type: "button", role: "option", "aria-selected": currentValue === option.value, className: `rte-dropdown-item ${currentValue === option.value ? 'rte-dropdown-item-active' : ''}`, onClick: () => handleSelect(option.value), children: [option.color && (jsx("span", { className: `rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`, style: { backgroundColor: option.color } })), option.preview && !option.headingPreview && (jsx("span", { className: "rte-dropdown-fontsize-preview", style: { fontSize: `${option.preview}px` }, children: "Aa" })), option.headingPreview && (jsx("span", { className: `rte-dropdown-heading-preview ${option.headingPreview}`, children: option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase() })), option.icon && jsx(Icon, { icon: option.icon, width: 16, height: 16 }), jsx("span", { style: { flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }, children: option.label })] }, option.value))), showCustomColorInput && (jsxs("div", { className: "rte-color-custom-input", onMouseDown: (e) => e.stopPropagation(), children: [jsx("input", { type: "color", value: customColor, onChange: (e) => setCustomColor(e.target.value), title: "Pick a color" }), jsx("input", { type: "text", value: customColor, onChange: (e) => {
|
|
106
119
|
const v = e.target.value;
|
|
107
120
|
setCustomColor(v);
|
|
108
121
|
}, placeholder: "#000000", maxLength: 7, onKeyDown: (e) => {
|
|
@@ -657,6 +670,168 @@ function useCheckbox({ editorRef, isUpdatingRef, pushToHistory, notifyChange, ge
|
|
|
657
670
|
};
|
|
658
671
|
}
|
|
659
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Lightweight HTML sanitizer (no external dependencies).
|
|
675
|
+
*
|
|
676
|
+
* Uses an allowlist approach to strip dangerous elements and attributes
|
|
677
|
+
* from pasted/imported HTML before it enters the editor.
|
|
678
|
+
*/
|
|
679
|
+
/** Tags that are always removed (including their content). */
|
|
680
|
+
const REMOVE_TAGS = new Set([
|
|
681
|
+
"script",
|
|
682
|
+
"style",
|
|
683
|
+
"iframe",
|
|
684
|
+
"object",
|
|
685
|
+
"embed",
|
|
686
|
+
"applet",
|
|
687
|
+
"form",
|
|
688
|
+
"input",
|
|
689
|
+
"textarea",
|
|
690
|
+
"select",
|
|
691
|
+
"button",
|
|
692
|
+
"noscript",
|
|
693
|
+
"meta",
|
|
694
|
+
"link",
|
|
695
|
+
"base",
|
|
696
|
+
"svg",
|
|
697
|
+
"math",
|
|
698
|
+
"template",
|
|
699
|
+
"details",
|
|
700
|
+
"video",
|
|
701
|
+
"audio",
|
|
702
|
+
"marquee",
|
|
703
|
+
]);
|
|
704
|
+
/** Attributes that are always removed (event handlers, dangerous data-* attrs). */
|
|
705
|
+
const REMOVE_ATTRS_PATTERN = /^on|^data-(?!(attachment-id|placeholder)$)/i;
|
|
706
|
+
/** Specific dangerous attribute names. */
|
|
707
|
+
const REMOVE_ATTRS = new Set([
|
|
708
|
+
"srcdoc",
|
|
709
|
+
"formaction",
|
|
710
|
+
"xlink:href",
|
|
711
|
+
"ping",
|
|
712
|
+
]);
|
|
713
|
+
/** Allowed URL schemes for href/src attributes. */
|
|
714
|
+
const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/(?!\/))/i;
|
|
715
|
+
/**
|
|
716
|
+
* Check if a URL is safe to set as href/src.
|
|
717
|
+
* Blocks javascript:, data:, vbscript:, and unknown schemes.
|
|
718
|
+
* Strips control characters before checking.
|
|
719
|
+
*/
|
|
720
|
+
function isUrlSafe(url) {
|
|
721
|
+
if (!url)
|
|
722
|
+
return false;
|
|
723
|
+
// Strip control characters and whitespace that could obfuscate schemes
|
|
724
|
+
const cleaned = url.trim().replace(/[\x00-\x1f\x7f]/g, "");
|
|
725
|
+
if (!cleaned)
|
|
726
|
+
return false;
|
|
727
|
+
// Block protocol-relative URLs (//evil.com)
|
|
728
|
+
if (cleaned.startsWith("//"))
|
|
729
|
+
return false;
|
|
730
|
+
// Must match an allowed scheme or be a relative path
|
|
731
|
+
return ALLOWED_SCHEMES.test(cleaned);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Sanitize an HTML string by stripping dangerous tags and attributes.
|
|
735
|
+
*
|
|
736
|
+
* @param html - Raw HTML string
|
|
737
|
+
* @returns Sanitized HTML string
|
|
738
|
+
*/
|
|
739
|
+
function sanitizeHtml(html) {
|
|
740
|
+
// Use the browser's DOMParser to parse the HTML safely
|
|
741
|
+
const parser = new DOMParser();
|
|
742
|
+
const doc = parser.parseFromString(html, "text/html");
|
|
743
|
+
sanitizeNode(doc.body);
|
|
744
|
+
return doc.body.innerHTML;
|
|
745
|
+
}
|
|
746
|
+
/** Recursively sanitize a DOM node. */
|
|
747
|
+
function sanitizeNode(node) {
|
|
748
|
+
const childrenToRemove = [];
|
|
749
|
+
node.childNodes.forEach((child) => {
|
|
750
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
751
|
+
const el = child;
|
|
752
|
+
const tag = el.tagName.toLowerCase();
|
|
753
|
+
// Remove dangerous tags entirely
|
|
754
|
+
if (REMOVE_TAGS.has(tag)) {
|
|
755
|
+
childrenToRemove.push(child);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
// Remove dangerous attributes
|
|
759
|
+
const attrsToRemove = [];
|
|
760
|
+
for (let i = 0; i < el.attributes.length; i++) {
|
|
761
|
+
const attr = el.attributes[i];
|
|
762
|
+
const name = attr.name.toLowerCase();
|
|
763
|
+
if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
|
|
764
|
+
attrsToRemove.push(attr.name);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
// Validate URL attributes
|
|
768
|
+
if (name === "href" || name === "src" || name === "action" || name === "cite" || name === "poster") {
|
|
769
|
+
const value = attr.value.trim();
|
|
770
|
+
if (value) {
|
|
771
|
+
// Allow data:image/* for img src (matches contentToDOM behavior)
|
|
772
|
+
const isDataImage = name === "src" && el.tagName === "IMG" && value.startsWith("data:image/");
|
|
773
|
+
if (!isDataImage && !ALLOWED_SCHEMES.test(value)) {
|
|
774
|
+
attrsToRemove.push(attr.name);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// Remove dangerous URI schemes in any attribute value
|
|
779
|
+
const lowerValue = attr.value.toLowerCase().replace(/[\x00-\x1f\x7f\s]/g, "");
|
|
780
|
+
if (lowerValue.includes("javascript:") ||
|
|
781
|
+
lowerValue.includes("vbscript:") ||
|
|
782
|
+
lowerValue.includes("data:text/html")) {
|
|
783
|
+
attrsToRemove.push(attr.name);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
|
|
787
|
+
// Recurse into children
|
|
788
|
+
sanitizeNode(child);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
// Remove marked children
|
|
792
|
+
childrenToRemove.forEach((child) => {
|
|
793
|
+
node.removeChild(child);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Allowed tag names that contentToDOM may create from JSON. */
|
|
798
|
+
const ALLOWED_CONTENT_TAGS = new Set([
|
|
799
|
+
"p", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
800
|
+
"ul", "ol", "li", "a", "strong", "em", "u", "s", "del",
|
|
801
|
+
"sub", "sup", "code", "pre", "blockquote", "br", "hr",
|
|
802
|
+
"img", "table", "thead", "tbody", "tr", "th", "td",
|
|
803
|
+
"b", "i", "strike",
|
|
804
|
+
]);
|
|
805
|
+
/** Checks if an attribute key is safe to set on a DOM element. */
|
|
806
|
+
function isSafeAttribute(key) {
|
|
807
|
+
const lower = key.toLowerCase();
|
|
808
|
+
// Block all event handler attributes
|
|
809
|
+
if (lower.startsWith("on"))
|
|
810
|
+
return false;
|
|
811
|
+
// Block dangerous attributes
|
|
812
|
+
if (lower === "srcdoc" || lower === "formaction" || lower === "xlink:href" || lower === "ping")
|
|
813
|
+
return false;
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
/** Validates style values against expected patterns. */
|
|
817
|
+
function isSafeStyleValue(prop, value) {
|
|
818
|
+
const clean = value.trim().toLowerCase();
|
|
819
|
+
// Block expression(), url(), import, javascript, and other injection vectors
|
|
820
|
+
if (/expression\s*\(|url\s*\(|@import|javascript:|vbscript:|-moz-binding/i.test(clean)) {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
switch (prop) {
|
|
824
|
+
case "fontSize":
|
|
825
|
+
return /^[\d.]+(px|em|rem|pt|%|vw|vh)$/i.test(clean);
|
|
826
|
+
case "color":
|
|
827
|
+
case "backgroundColor":
|
|
828
|
+
return /^(#[0-9a-f]{3,8}|rgb\([\d\s,.%]+\)|rgba\([\d\s,.%]+\)|[a-z]+)$/i.test(clean);
|
|
829
|
+
case "textAlign":
|
|
830
|
+
return /^(left|right|center|justify|start|end)$/.test(clean);
|
|
831
|
+
default:
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
660
835
|
/**
|
|
661
836
|
* Converts a DOM element (editor root) to EditorContent JSON.
|
|
662
837
|
* Supports own format, Lexical HTML, and GitHub HTML.
|
|
@@ -977,8 +1152,13 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
|
|
|
977
1152
|
if (node.type === "image") {
|
|
978
1153
|
const img = document.createElement("img");
|
|
979
1154
|
if (node.attributes) {
|
|
980
|
-
if (node.attributes.src)
|
|
981
|
-
|
|
1155
|
+
if (node.attributes.src) {
|
|
1156
|
+
// Allow data:image/* for placeholders/uploads, otherwise validate
|
|
1157
|
+
const imgSrc = node.attributes.src;
|
|
1158
|
+
if (isUrlSafe(imgSrc) || imgSrc.startsWith("data:image/")) {
|
|
1159
|
+
img.setAttribute("src", imgSrc);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
982
1162
|
if (node.attributes.alt)
|
|
983
1163
|
img.setAttribute("alt", node.attributes.alt);
|
|
984
1164
|
if (node.attributes.uploading === "true") {
|
|
@@ -1005,23 +1185,32 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
|
|
|
1005
1185
|
node.attributes["data-custom-link"] = "true";
|
|
1006
1186
|
}
|
|
1007
1187
|
}
|
|
1188
|
+
// Whitelist check: fall back to span for unknown/dangerous tags
|
|
1189
|
+
if (!ALLOWED_CONTENT_TAGS.has(tagName)) {
|
|
1190
|
+
tagName = "span";
|
|
1191
|
+
}
|
|
1008
1192
|
const element = document.createElement(tagName);
|
|
1009
1193
|
if (node.attributes) {
|
|
1010
1194
|
Object.entries(node.attributes).forEach(([key, value]) => {
|
|
1011
1195
|
if (key === "fontSize") {
|
|
1012
|
-
|
|
1196
|
+
if (isSafeStyleValue("fontSize", value))
|
|
1197
|
+
element.style.fontSize = value;
|
|
1013
1198
|
}
|
|
1014
1199
|
else if (key === "color") {
|
|
1015
|
-
|
|
1200
|
+
if (isSafeStyleValue("color", value))
|
|
1201
|
+
element.style.color = value;
|
|
1016
1202
|
}
|
|
1017
1203
|
else if (key === "backgroundColor") {
|
|
1018
|
-
|
|
1204
|
+
if (isSafeStyleValue("backgroundColor", value))
|
|
1205
|
+
element.style.backgroundColor = value;
|
|
1019
1206
|
}
|
|
1020
1207
|
else if (key === "textAlign") {
|
|
1021
|
-
|
|
1208
|
+
if (isSafeStyleValue("textAlign", value))
|
|
1209
|
+
element.style.textAlign = value;
|
|
1022
1210
|
}
|
|
1023
1211
|
else if (key === "href" && tagName === "a") {
|
|
1024
|
-
|
|
1212
|
+
if (isUrlSafe(value))
|
|
1213
|
+
element.setAttribute("href", value);
|
|
1025
1214
|
}
|
|
1026
1215
|
else if (key === "target" && tagName === "a") {
|
|
1027
1216
|
element.setAttribute("target", value);
|
|
@@ -1051,8 +1240,16 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
|
|
|
1051
1240
|
element.className = value;
|
|
1052
1241
|
}
|
|
1053
1242
|
else if (key === "checkboxChecked") ;
|
|
1054
|
-
else {
|
|
1055
|
-
|
|
1243
|
+
else if (isSafeAttribute(key)) {
|
|
1244
|
+
// Validate URL-like attribute values
|
|
1245
|
+
const lowerKey = key.toLowerCase();
|
|
1246
|
+
if (["cite", "action", "poster", "background"].includes(lowerKey)) {
|
|
1247
|
+
if (isUrlSafe(value))
|
|
1248
|
+
element.setAttribute(key, value);
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
element.setAttribute(key, value);
|
|
1252
|
+
}
|
|
1056
1253
|
}
|
|
1057
1254
|
});
|
|
1058
1255
|
}
|
|
@@ -1099,10 +1296,12 @@ function contentToHTML(content) {
|
|
|
1099
1296
|
/**
|
|
1100
1297
|
* Converts an HTML string to EditorContent.
|
|
1101
1298
|
* Supports Lexical, GitHub, and standard HTML formats.
|
|
1299
|
+
* Sanitizes HTML before parsing to prevent XSS.
|
|
1102
1300
|
*/
|
|
1103
1301
|
function htmlToContent(htmlString) {
|
|
1302
|
+
const sanitized = sanitizeHtml(htmlString);
|
|
1104
1303
|
const tempDiv = document.createElement("div");
|
|
1105
|
-
tempDiv.innerHTML =
|
|
1304
|
+
tempDiv.innerHTML = sanitized;
|
|
1106
1305
|
return domToContent(tempDiv);
|
|
1107
1306
|
}
|
|
1108
1307
|
|
|
@@ -1112,7 +1311,7 @@ function htmlToContent(htmlString) {
|
|
|
1112
1311
|
* Detects URLs in text nodes and wraps them in <a> tags.
|
|
1113
1312
|
* Triggered after space/enter when the preceding word looks like a URL.
|
|
1114
1313
|
*/
|
|
1115
|
-
const URL_REGEX =
|
|
1314
|
+
const URL_REGEX = /^(?:https?:\/\/|www\.)[^\s<>'"]+\.[a-z]{2,}[^\s<>'"]*$/i;
|
|
1116
1315
|
/**
|
|
1117
1316
|
* Check if the word before the cursor is a URL and wrap it in an <a> tag.
|
|
1118
1317
|
* Called on space/enter keypress in the editor.
|
|
@@ -1150,6 +1349,9 @@ function handleAutoLink(editor, e) {
|
|
|
1150
1349
|
if (href.startsWith("www.")) {
|
|
1151
1350
|
href = "https://" + href;
|
|
1152
1351
|
}
|
|
1352
|
+
// Validate the URL is safe before creating a link
|
|
1353
|
+
if (!isUrlSafe(href))
|
|
1354
|
+
return false;
|
|
1153
1355
|
// Create the link element
|
|
1154
1356
|
const anchor = document.createElement("a");
|
|
1155
1357
|
anchor.href = href;
|
|
@@ -1211,22 +1413,22 @@ function indentListItem(selection) {
|
|
|
1211
1413
|
break;
|
|
1212
1414
|
}
|
|
1213
1415
|
}
|
|
1214
|
-
// Max depth: 6 (
|
|
1416
|
+
// Max depth: 6 (as per HTML standard)
|
|
1215
1417
|
if (depth >= 6)
|
|
1216
1418
|
return false;
|
|
1217
|
-
//
|
|
1419
|
+
// Find previous list item
|
|
1218
1420
|
const previousItem = listItem.previousElementSibling;
|
|
1219
1421
|
if (previousItem && previousItem.tagName === 'LI') {
|
|
1220
|
-
//
|
|
1422
|
+
// Create nested list in the previous item
|
|
1221
1423
|
let nestedList = previousItem.querySelector('ul, ol');
|
|
1222
1424
|
if (!nestedList) {
|
|
1223
|
-
//
|
|
1425
|
+
// Create new nested list
|
|
1224
1426
|
nestedList = document.createElement(list.tagName.toLowerCase());
|
|
1225
1427
|
previousItem.appendChild(nestedList);
|
|
1226
1428
|
}
|
|
1227
|
-
//
|
|
1429
|
+
// Move current item into nested list
|
|
1228
1430
|
nestedList.appendChild(listItem);
|
|
1229
|
-
//
|
|
1431
|
+
// Set cursor position
|
|
1230
1432
|
const textNode = listItem.firstChild;
|
|
1231
1433
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
|
1232
1434
|
range.setStart(textNode, 0);
|
|
@@ -1247,9 +1449,9 @@ function indentListItem(selection) {
|
|
|
1247
1449
|
return true;
|
|
1248
1450
|
}
|
|
1249
1451
|
else {
|
|
1250
|
-
//
|
|
1452
|
+
// No previous item — create new nested list in current item
|
|
1251
1453
|
const nestedList = document.createElement(list.tagName.toLowerCase());
|
|
1252
|
-
//
|
|
1454
|
+
// Move all following items into the nested list
|
|
1253
1455
|
let nextSibling = listItem.nextElementSibling;
|
|
1254
1456
|
while (nextSibling && nextSibling.tagName === 'LI') {
|
|
1255
1457
|
const toMove = nextSibling;
|
|
@@ -1260,11 +1462,11 @@ function indentListItem(selection) {
|
|
|
1260
1462
|
listItem.appendChild(nestedList);
|
|
1261
1463
|
}
|
|
1262
1464
|
else {
|
|
1263
|
-
//
|
|
1465
|
+
// If no following items, create empty sub-item
|
|
1264
1466
|
const subItem = document.createElement('li');
|
|
1265
1467
|
nestedList.appendChild(subItem);
|
|
1266
1468
|
listItem.appendChild(nestedList);
|
|
1267
|
-
//
|
|
1469
|
+
// Set cursor in sub-item
|
|
1268
1470
|
const newText = document.createTextNode('');
|
|
1269
1471
|
subItem.appendChild(newText);
|
|
1270
1472
|
range.setStart(newText, 0);
|
|
@@ -1294,17 +1496,17 @@ function outdentListItem(selection) {
|
|
|
1294
1496
|
// Check if in nested list
|
|
1295
1497
|
const parentListItem = list.parentElement;
|
|
1296
1498
|
if (!parentListItem || parentListItem.tagName !== 'LI') {
|
|
1297
|
-
//
|
|
1499
|
+
// Already at top level
|
|
1298
1500
|
return false;
|
|
1299
1501
|
}
|
|
1300
1502
|
const parentList = parentListItem.parentElement;
|
|
1301
1503
|
if (!parentList || (parentList.tagName !== 'UL' && parentList.tagName !== 'OL')) {
|
|
1302
1504
|
return false;
|
|
1303
1505
|
}
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1506
|
+
// Move item to parent level
|
|
1507
|
+
// Find position after the parent item
|
|
1306
1508
|
const insertAfter = parentListItem;
|
|
1307
|
-
//
|
|
1509
|
+
// Move item and all following items out of the nested list
|
|
1308
1510
|
const itemsToMove = [listItem];
|
|
1309
1511
|
let nextSibling = listItem.nextElementSibling;
|
|
1310
1512
|
while (nextSibling && nextSibling.tagName === 'LI') {
|
|
@@ -1315,11 +1517,11 @@ function outdentListItem(selection) {
|
|
|
1315
1517
|
itemsToMove.forEach(item => {
|
|
1316
1518
|
parentList.insertBefore(item, insertAfter.nextSibling);
|
|
1317
1519
|
});
|
|
1318
|
-
//
|
|
1520
|
+
// Remove empty nested list
|
|
1319
1521
|
if (list.children.length === 0) {
|
|
1320
1522
|
list.remove();
|
|
1321
1523
|
}
|
|
1322
|
-
//
|
|
1524
|
+
// Set cursor position
|
|
1323
1525
|
const textNode = listItem.firstChild;
|
|
1324
1526
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
|
1325
1527
|
range.setStart(textNode, 0);
|
|
@@ -1853,7 +2055,7 @@ function navigateTableCell(direction) {
|
|
|
1853
2055
|
/**
|
|
1854
2056
|
* Hook that sets up input and keydown event listeners on the editor.
|
|
1855
2057
|
*/
|
|
1856
|
-
function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
|
|
2058
|
+
function useEditorEvents({ editorRef, historyRef, isUpdatingRef, mountedRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
|
|
1857
2059
|
useEffect(() => {
|
|
1858
2060
|
const editor = editorRef.current;
|
|
1859
2061
|
if (!editor)
|
|
@@ -1861,11 +2063,15 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1861
2063
|
let inputTimeout = null;
|
|
1862
2064
|
const handleInput = () => {
|
|
1863
2065
|
setTimeout(() => {
|
|
2066
|
+
if (!mountedRef.current)
|
|
2067
|
+
return;
|
|
1864
2068
|
const content = domToContent(editor);
|
|
1865
2069
|
notifyChange(content);
|
|
1866
2070
|
if (inputTimeout)
|
|
1867
2071
|
clearTimeout(inputTimeout);
|
|
1868
2072
|
inputTimeout = setTimeout(() => {
|
|
2073
|
+
if (!mountedRef.current)
|
|
2074
|
+
return;
|
|
1869
2075
|
const sel = serializeSelection(editor);
|
|
1870
2076
|
historyRef.current.push(content, sel);
|
|
1871
2077
|
inputTimeout = null;
|
|
@@ -1877,6 +2083,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1877
2083
|
// Markdown-style shortcuts (e.g., # + space → heading)
|
|
1878
2084
|
if (!isModifierPressed && handleMarkdownShortcut(editor, e)) {
|
|
1879
2085
|
setTimeout(() => {
|
|
2086
|
+
if (!mountedRef.current)
|
|
2087
|
+
return;
|
|
1880
2088
|
const content = domToContent(editor);
|
|
1881
2089
|
notifyChange(content);
|
|
1882
2090
|
}, 0);
|
|
@@ -1901,6 +2109,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1901
2109
|
e.stopImmediatePropagation();
|
|
1902
2110
|
navigateTableCell(e.shiftKey ? "prev" : "next");
|
|
1903
2111
|
setTimeout(() => {
|
|
2112
|
+
if (!mountedRef.current)
|
|
2113
|
+
return;
|
|
1904
2114
|
if (editor) {
|
|
1905
2115
|
const content = domToContent(editor);
|
|
1906
2116
|
notifyChange(content);
|
|
@@ -1932,6 +2142,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1932
2142
|
indentListItem(selection);
|
|
1933
2143
|
}
|
|
1934
2144
|
setTimeout(() => {
|
|
2145
|
+
if (!mountedRef.current)
|
|
2146
|
+
return;
|
|
1935
2147
|
if (editor) {
|
|
1936
2148
|
const content = domToContent(editor);
|
|
1937
2149
|
notifyChange(content);
|
|
@@ -2043,6 +2255,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
2043
2255
|
document.execCommand(command, false);
|
|
2044
2256
|
}
|
|
2045
2257
|
setTimeout(() => {
|
|
2258
|
+
if (!mountedRef.current)
|
|
2259
|
+
return;
|
|
2046
2260
|
if (editor) {
|
|
2047
2261
|
const content = domToContent(editor);
|
|
2048
2262
|
notifyChange(content);
|
|
@@ -2092,6 +2306,7 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
|
|
|
2092
2306
|
// Normalize all checkbox lists (own + Lexical + GitHub formats)
|
|
2093
2307
|
ensureAllCheckboxes(editor);
|
|
2094
2308
|
// MutationObserver: ensure new checkbox list items get correct attributes
|
|
2309
|
+
let checkboxTimeout = null;
|
|
2095
2310
|
const observer = new MutationObserver((mutations) => {
|
|
2096
2311
|
if (isUpdatingRef.current)
|
|
2097
2312
|
return;
|
|
@@ -2121,11 +2336,15 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
|
|
|
2121
2336
|
break;
|
|
2122
2337
|
}
|
|
2123
2338
|
if (needsUpdate) {
|
|
2124
|
-
|
|
2339
|
+
if (checkboxTimeout)
|
|
2340
|
+
clearTimeout(checkboxTimeout);
|
|
2341
|
+
checkboxTimeout = setTimeout(() => ensureAllCheckboxes(editor), 0);
|
|
2125
2342
|
}
|
|
2126
2343
|
});
|
|
2127
2344
|
observer.observe(editor, { childList: true, subtree: true });
|
|
2128
2345
|
return () => {
|
|
2346
|
+
if (checkboxTimeout)
|
|
2347
|
+
clearTimeout(checkboxTimeout);
|
|
2129
2348
|
observer.disconnect();
|
|
2130
2349
|
};
|
|
2131
2350
|
// Only run once on mount
|
|
@@ -2166,7 +2385,7 @@ function createInlinePlugin(name, command, icon, label) {
|
|
|
2166
2385
|
},
|
|
2167
2386
|
canExecute: (editor) => {
|
|
2168
2387
|
// Formatting should also work without a selection
|
|
2169
|
-
// (
|
|
2388
|
+
// (e.g. when the editor is empty, a selection is created on click)
|
|
2170
2389
|
return true;
|
|
2171
2390
|
},
|
|
2172
2391
|
};
|
|
@@ -2937,7 +3156,7 @@ function getDisplayUrl(raw) {
|
|
|
2937
3156
|
return raw;
|
|
2938
3157
|
}
|
|
2939
3158
|
/**
|
|
2940
|
-
* Image
|
|
3159
|
+
* Image plugin with URL input and file upload support.
|
|
2941
3160
|
*/
|
|
2942
3161
|
function createImagePlugin(onImageUpload) {
|
|
2943
3162
|
return {
|
|
@@ -2960,7 +3179,17 @@ function createImagePlugin(onImageUpload) {
|
|
|
2960
3179
|
alert('Please enter an image URL');
|
|
2961
3180
|
return;
|
|
2962
3181
|
}
|
|
2963
|
-
|
|
3182
|
+
// Validate URL safety
|
|
3183
|
+
const srcWithoutMeta = src.split('|__aid__:')[0];
|
|
3184
|
+
if (!isUrlSafe(srcWithoutMeta)) {
|
|
3185
|
+
alert('Invalid image URL');
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
// Append alt text metadata if provided
|
|
3189
|
+
const fullValue = altText.trim()
|
|
3190
|
+
? `${src}|__alt__:${altText.trim()}`
|
|
3191
|
+
: src;
|
|
3192
|
+
props.editorAPI.executeCommand('insertImage', fullValue);
|
|
2964
3193
|
// Close modal
|
|
2965
3194
|
setShowModal(false);
|
|
2966
3195
|
setRawUrl('');
|
|
@@ -2998,11 +3227,11 @@ function createImagePlugin(onImageUpload) {
|
|
|
2998
3227
|
if (e.target === e.currentTarget) {
|
|
2999
3228
|
setShowModal(false);
|
|
3000
3229
|
}
|
|
3001
|
-
}, children: jsxs("div", { className: "rte-image-modal", children: [jsxs("div", { className: "rte-image-modal-header", children: [jsx("h3", { children: "Insert Image" }), jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsx("div", { className: "rte-image-upload-section", children: jsxs("label", { className: "rte-image-upload-label", children: [jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxs(Fragment, { children: [jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsx("span", { children: "
|
|
3230
|
+
}, children: jsxs("div", { className: "rte-image-modal", children: [jsxs("div", { className: "rte-image-modal-header", children: [jsx("h3", { children: "Insert Image" }), jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsx("div", { className: "rte-image-upload-section", children: jsxs("label", { className: "rte-image-upload-label", children: [jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxs(Fragment, { children: [jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsx("span", { children: "Uploading..." })] })) : (jsxs(Fragment, { children: [jsx(IconWrapper, { icon: "mdi:upload", width: 24, height: 24 }), jsx("span", { children: "Choose File" })] })) })] }) })), jsx("div", { className: "rte-image-url-section", children: jsxs("label", { children: ["Image URL", jsx("input", { type: "url", value: imageUrl, onChange: (e) => {
|
|
3002
3231
|
setImageUrl(e.target.value);
|
|
3003
3232
|
// Clear raw URL when user manually edits
|
|
3004
3233
|
setRawUrl('');
|
|
3005
|
-
}, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsx("div", { className: "rte-image-alt-section", children: jsxs("label", { children: ["Alt
|
|
3234
|
+
}, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsx("div", { className: "rte-image-alt-section", children: jsxs("label", { children: ["Alt Text (optional)", jsx("input", { type: "text", value: altText, onChange: (e) => setAltText(e.target.value), placeholder: "Image description", className: "rte-image-alt-input" })] }) }), imageUrl && (jsx("div", { className: "rte-image-preview", children: jsx("img", { src: imageUrl, alt: altText || 'Preview' }) }))] }), jsxs("div", { className: "rte-image-modal-footer", children: [jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-cancel", children: "Cancel" }), jsx("button", { type: "button", onClick: handleInsertImage, disabled: !imageUrl.trim() || isUploading, className: "rte-image-modal-insert", children: "Insert" })] })] }) }))] }));
|
|
3006
3235
|
},
|
|
3007
3236
|
execute: (editor) => {
|
|
3008
3237
|
// Handled via renderButton
|
|
@@ -3073,7 +3302,7 @@ function clearFormatting(selection) {
|
|
|
3073
3302
|
}
|
|
3074
3303
|
catch (error) {
|
|
3075
3304
|
console.error('Error clearing formatting:', error);
|
|
3076
|
-
// Fallback:
|
|
3305
|
+
// Fallback: simple method
|
|
3077
3306
|
document.execCommand('removeFormat', false);
|
|
3078
3307
|
document.execCommand('unlink', false);
|
|
3079
3308
|
}
|
|
@@ -3222,7 +3451,7 @@ class HistoryManager {
|
|
|
3222
3451
|
// Limit history size
|
|
3223
3452
|
if (this.history.length > this.maxHistorySize) {
|
|
3224
3453
|
this.history.shift();
|
|
3225
|
-
this.currentIndex
|
|
3454
|
+
this.currentIndex = Math.max(0, this.currentIndex - 1);
|
|
3226
3455
|
}
|
|
3227
3456
|
}
|
|
3228
3457
|
undo() {
|
|
@@ -3269,98 +3498,6 @@ class HistoryManager {
|
|
|
3269
3498
|
}
|
|
3270
3499
|
}
|
|
3271
3500
|
|
|
3272
|
-
/**
|
|
3273
|
-
* Lightweight HTML sanitizer (no external dependencies).
|
|
3274
|
-
*
|
|
3275
|
-
* Uses an allowlist approach to strip dangerous elements and attributes
|
|
3276
|
-
* from pasted/imported HTML before it enters the editor.
|
|
3277
|
-
*/
|
|
3278
|
-
/** Tags that are always removed (including their content). */
|
|
3279
|
-
const REMOVE_TAGS = new Set([
|
|
3280
|
-
"script",
|
|
3281
|
-
"style",
|
|
3282
|
-
"iframe",
|
|
3283
|
-
"object",
|
|
3284
|
-
"embed",
|
|
3285
|
-
"applet",
|
|
3286
|
-
"form",
|
|
3287
|
-
"input",
|
|
3288
|
-
"textarea",
|
|
3289
|
-
"select",
|
|
3290
|
-
"button",
|
|
3291
|
-
"noscript",
|
|
3292
|
-
"meta",
|
|
3293
|
-
"link",
|
|
3294
|
-
"base",
|
|
3295
|
-
]);
|
|
3296
|
-
/** Attributes that are always removed (event handlers, dangerous attrs). */
|
|
3297
|
-
const REMOVE_ATTRS_PATTERN = /^on|^data-(?!attachment-id$|placeholder$)/i;
|
|
3298
|
-
/** Specific dangerous attribute names. */
|
|
3299
|
-
const REMOVE_ATTRS = new Set([
|
|
3300
|
-
"srcdoc",
|
|
3301
|
-
"formaction",
|
|
3302
|
-
"xlink:href",
|
|
3303
|
-
"ping",
|
|
3304
|
-
]);
|
|
3305
|
-
/** Allowed URL schemes for href/src attributes. */
|
|
3306
|
-
const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/)/i;
|
|
3307
|
-
/**
|
|
3308
|
-
* Sanitize an HTML string by stripping dangerous tags and attributes.
|
|
3309
|
-
*
|
|
3310
|
-
* @param html - Raw HTML string
|
|
3311
|
-
* @returns Sanitized HTML string
|
|
3312
|
-
*/
|
|
3313
|
-
function sanitizeHtml(html) {
|
|
3314
|
-
// Use the browser's DOMParser to parse the HTML safely
|
|
3315
|
-
const parser = new DOMParser();
|
|
3316
|
-
const doc = parser.parseFromString(html, "text/html");
|
|
3317
|
-
sanitizeNode(doc.body);
|
|
3318
|
-
return doc.body.innerHTML;
|
|
3319
|
-
}
|
|
3320
|
-
/** Recursively sanitize a DOM node. */
|
|
3321
|
-
function sanitizeNode(node) {
|
|
3322
|
-
const childrenToRemove = [];
|
|
3323
|
-
node.childNodes.forEach((child) => {
|
|
3324
|
-
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
3325
|
-
const el = child;
|
|
3326
|
-
const tag = el.tagName.toLowerCase();
|
|
3327
|
-
// Remove dangerous tags entirely
|
|
3328
|
-
if (REMOVE_TAGS.has(tag)) {
|
|
3329
|
-
childrenToRemove.push(child);
|
|
3330
|
-
return;
|
|
3331
|
-
}
|
|
3332
|
-
// Remove dangerous attributes
|
|
3333
|
-
const attrsToRemove = [];
|
|
3334
|
-
for (let i = 0; i < el.attributes.length; i++) {
|
|
3335
|
-
const attr = el.attributes[i];
|
|
3336
|
-
const name = attr.name.toLowerCase();
|
|
3337
|
-
if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
|
|
3338
|
-
attrsToRemove.push(attr.name);
|
|
3339
|
-
continue;
|
|
3340
|
-
}
|
|
3341
|
-
// Validate URL attributes
|
|
3342
|
-
if (name === "href" || name === "src" || name === "action") {
|
|
3343
|
-
const value = attr.value.trim();
|
|
3344
|
-
if (value && !ALLOWED_SCHEMES.test(value)) {
|
|
3345
|
-
attrsToRemove.push(attr.name);
|
|
3346
|
-
}
|
|
3347
|
-
}
|
|
3348
|
-
// Remove javascript: in any attribute value
|
|
3349
|
-
if (attr.value.toLowerCase().includes("javascript:")) {
|
|
3350
|
-
attrsToRemove.push(attr.name);
|
|
3351
|
-
}
|
|
3352
|
-
}
|
|
3353
|
-
attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
|
|
3354
|
-
// Recurse into children
|
|
3355
|
-
sanitizeNode(child);
|
|
3356
|
-
}
|
|
3357
|
-
});
|
|
3358
|
-
// Remove marked children
|
|
3359
|
-
childrenToRemove.forEach((child) => {
|
|
3360
|
-
node.removeChild(child);
|
|
3361
|
-
});
|
|
3362
|
-
}
|
|
3363
|
-
|
|
3364
3501
|
const alignmentLabels = {
|
|
3365
3502
|
left: "Left",
|
|
3366
3503
|
center: "Center",
|
|
@@ -3578,9 +3715,9 @@ const FloatingLinkEditor = ({ linkData: initialData, options, anchorRect, isEdit
|
|
|
3578
3715
|
const urlDisabledByCustom = customFields.some((f) => f.disablesUrl && !!data.custom[f.key]);
|
|
3579
3716
|
const hasAdvancedSection = options.enableTarget || customFields.length > 0;
|
|
3580
3717
|
const pos = computePosition(anchorRect);
|
|
3581
|
-
return createPortal(jsxs("div", { ref: dialogRef, className: "rte-link-dialog rte-link-dialog-floating", style: { top: pos.top, left: pos.left, position: "fixed" }, onKeyDown: handleKeyDown, onMouseDown: (e) => e.stopPropagation(), children: [jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxs("div", { className: "rte-link-dialog-field", children: [jsx("label", { className: "rte-link-dialog-label", children: "URL" }), jsx("input", { ref: urlInputRef, type: "url", className: "rte-link-dialog-input", value: data.url, onChange: (e) => setField("url", e.target.value), placeholder: "https://...", disabled: urlDisabledByCustom })] }), hasAdvancedSection && (jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxs("span", { children: [showAdvanced ? "▾" : "▸", "
|
|
3718
|
+
return createPortal(jsxs("div", { ref: dialogRef, className: "rte-link-dialog rte-link-dialog-floating", style: { top: pos.top, left: pos.left, position: "fixed" }, onKeyDown: handleKeyDown, onMouseDown: (e) => e.stopPropagation(), children: [jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxs("div", { className: "rte-link-dialog-field", children: [jsx("label", { className: "rte-link-dialog-label", children: "URL" }), jsx("input", { ref: urlInputRef, type: "url", className: "rte-link-dialog-input", value: data.url, onChange: (e) => setField("url", e.target.value), placeholder: "https://...", disabled: urlDisabledByCustom })] }), hasAdvancedSection && (jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxs("span", { children: [showAdvanced ? "▾" : "▸", " Advanced"] }) }), showAdvanced && (jsxs("div", { className: "rte-link-dialog-advanced", children: [customFields.map((field) => (jsxs("div", { className: "rte-link-dialog-field", children: [jsx("label", { className: "rte-link-dialog-label", children: field.label }), jsx("input", { type: "text", className: "rte-link-dialog-input", value: data.custom[field.key] || "", onChange: (e) => setCustom(field.key, e.target.value), placeholder: field.placeholder })] }, field.key))), options.enableTarget && (jsxs("label", { className: "rte-link-dialog-checkbox-row", children: [jsx("input", { type: "checkbox", checked: data.target === "_blank", onChange: (e) => setField("target", e.target.checked
|
|
3582
3719
|
? "_blank"
|
|
3583
|
-
: "_self") }), jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "
|
|
3720
|
+
: "_self") }), jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Remove" })), jsx("div", { style: { flex: 1 } }), jsx("button", { type: "button", className: "rte-link-dialog-btn", onMouseDown: (e) => e.preventDefault(), onClick: onClose, children: "Cancel" }), jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-primary", onMouseDown: (e) => e.preventDefault(), onClick: () => onSave(data), disabled: !data.url.trim() || data.url.trim() === "https://", children: "Save" })] })] }), document.body);
|
|
3584
3721
|
};
|
|
3585
3722
|
/* ══════════════════════════════════════════════════════════════════════════
|
|
3586
3723
|
Helpers
|
|
@@ -3627,6 +3764,9 @@ function applyLinkData(link, data, customFields) {
|
|
|
3627
3764
|
href += data.custom[field.key];
|
|
3628
3765
|
}
|
|
3629
3766
|
}
|
|
3767
|
+
// Validate URL before setting href to prevent javascript: / data: injection
|
|
3768
|
+
if (!isUrlSafe(href))
|
|
3769
|
+
return;
|
|
3630
3770
|
link.setAttribute("href", href);
|
|
3631
3771
|
// Target
|
|
3632
3772
|
if (data.target && data.target !== "_self") {
|
|
@@ -3702,7 +3842,7 @@ const LinkToolbarButton = (props) => {
|
|
|
3702
3842
|
e.preventDefault();
|
|
3703
3843
|
e.stopPropagation();
|
|
3704
3844
|
const href = link.getAttribute("href");
|
|
3705
|
-
if (href)
|
|
3845
|
+
if (href && isUrlSafe(href))
|
|
3706
3846
|
window.open(href, "_blank");
|
|
3707
3847
|
return;
|
|
3708
3848
|
}
|
|
@@ -3742,6 +3882,8 @@ const LinkToolbarButton = (props) => {
|
|
|
3742
3882
|
href += data.custom[field.key];
|
|
3743
3883
|
}
|
|
3744
3884
|
}
|
|
3885
|
+
if (!isUrlSafe(href))
|
|
3886
|
+
return;
|
|
3745
3887
|
document.execCommand("createLink", false, href);
|
|
3746
3888
|
const newLink = getSelectedLink();
|
|
3747
3889
|
if (newLink) {
|
|
@@ -4305,7 +4447,7 @@ const LinkTooltip = ({ editorElement }) => {
|
|
|
4305
4447
|
scheduleHide();
|
|
4306
4448
|
};
|
|
4307
4449
|
const handleOpen = () => {
|
|
4308
|
-
if (state.href) {
|
|
4450
|
+
if (state.href && isUrlSafe(state.href)) {
|
|
4309
4451
|
window.open(state.href, "_blank", "noopener,noreferrer");
|
|
4310
4452
|
}
|
|
4311
4453
|
};
|
|
@@ -4331,14 +4473,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4331
4473
|
const toolbarRef = useRef(null);
|
|
4332
4474
|
useEffect(() => {
|
|
4333
4475
|
setIsClient(true);
|
|
4476
|
+
const timeoutIds = [];
|
|
4334
4477
|
const handleSelectionChange = () => {
|
|
4335
4478
|
setUpdateTrigger((prev) => prev + 1);
|
|
4336
4479
|
};
|
|
4337
4480
|
const handleMouseUp = () => {
|
|
4338
|
-
setTimeout(handleSelectionChange, 10);
|
|
4481
|
+
timeoutIds.push(setTimeout(handleSelectionChange, 10));
|
|
4339
4482
|
};
|
|
4340
4483
|
const handleKeyUp = () => {
|
|
4341
|
-
setTimeout(handleSelectionChange, 10);
|
|
4484
|
+
timeoutIds.push(setTimeout(handleSelectionChange, 10));
|
|
4342
4485
|
};
|
|
4343
4486
|
if (typeof document !== 'undefined') {
|
|
4344
4487
|
document.addEventListener("selectionchange", handleSelectionChange);
|
|
@@ -4346,6 +4489,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4346
4489
|
document.addEventListener("keyup", handleKeyUp);
|
|
4347
4490
|
}
|
|
4348
4491
|
return () => {
|
|
4492
|
+
timeoutIds.forEach(clearTimeout);
|
|
4349
4493
|
if (typeof document !== 'undefined') {
|
|
4350
4494
|
document.removeEventListener("selectionchange", handleSelectionChange);
|
|
4351
4495
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
@@ -4371,6 +4515,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4371
4515
|
const clearFormattingPlugin = plugins.find((p) => p.name === "clearFormatting");
|
|
4372
4516
|
// Roving tabindex keyboard navigation (ARIA toolbar pattern)
|
|
4373
4517
|
const handleToolbarKeyDown = useCallback((e) => {
|
|
4518
|
+
// Escape: return focus to the editor
|
|
4519
|
+
if (e.key === "Escape") {
|
|
4520
|
+
e.preventDefault();
|
|
4521
|
+
const container = toolbarRef.current?.closest(".rte-container");
|
|
4522
|
+
const editor = container?.querySelector(".rte-editor");
|
|
4523
|
+
if (editor)
|
|
4524
|
+
editor.focus();
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4374
4527
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight" && e.key !== "Home" && e.key !== "End")
|
|
4375
4528
|
return;
|
|
4376
4529
|
const toolbar = toolbarRef.current;
|
|
@@ -4402,7 +4555,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4402
4555
|
}
|
|
4403
4556
|
buttons[nextIndex].focus();
|
|
4404
4557
|
}, []);
|
|
4405
|
-
return (jsxs("div", { ref: toolbarRef, className: `rte-toolbar rte-toolbar-sticky ${className || ""}`, onMouseDown: (e) => e.preventDefault(), onKeyDown: handleToolbarKeyDown, role: "toolbar", "aria-label": "Text formatting", children: [jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
|
|
4558
|
+
return (jsxs("div", { ref: toolbarRef, className: `rte-toolbar rte-toolbar-sticky ${className || ""}`, onMouseDown: (e) => e.preventDefault(), onKeyDown: handleToolbarKeyDown, role: "toolbar", "aria-label": "Text formatting", "aria-orientation": "horizontal", children: [jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
|
|
4406
4559
|
if (!plugin.renderButton)
|
|
4407
4560
|
return null;
|
|
4408
4561
|
const isActive = isClient && plugin.isActive
|
|
@@ -4445,6 +4598,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4445
4598
|
const editorRef = useRef(null);
|
|
4446
4599
|
const historyRef = useRef(new HistoryManager());
|
|
4447
4600
|
const isUpdatingRef = useRef(false);
|
|
4601
|
+
const mountedRef = useRef(true);
|
|
4602
|
+
// Track mount status to guard async callbacks
|
|
4603
|
+
useEffect(() => {
|
|
4604
|
+
mountedRef.current = true;
|
|
4605
|
+
return () => {
|
|
4606
|
+
mountedRef.current = false;
|
|
4607
|
+
};
|
|
4608
|
+
}, []);
|
|
4448
4609
|
// --- Plugins ---
|
|
4449
4610
|
const plugins = useMemo(() => {
|
|
4450
4611
|
// When settings is provided and plugins is not, use buildPluginsFromSettings
|
|
@@ -4455,7 +4616,12 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4455
4616
|
};
|
|
4456
4617
|
return buildPluginsFromSettings(settings, opts);
|
|
4457
4618
|
}
|
|
4458
|
-
|
|
4619
|
+
// When plugins are explicitly provided, use them as-is
|
|
4620
|
+
if (providedPlugins) {
|
|
4621
|
+
return [...providedPlugins];
|
|
4622
|
+
}
|
|
4623
|
+
// Default plugins path: apply shorthand customizations
|
|
4624
|
+
const allPlugins = [...defaultPlugins];
|
|
4459
4625
|
if (headings && headings.length > 0) {
|
|
4460
4626
|
const blockFormatIndex = allPlugins.findIndex((p) => p.name === "blockFormat");
|
|
4461
4627
|
if (blockFormatIndex !== -1) {
|
|
@@ -4570,12 +4736,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4570
4736
|
return checkbox.insertCheckboxList(editor);
|
|
4571
4737
|
}
|
|
4572
4738
|
if (command === "insertImage" && value) {
|
|
4573
|
-
return handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange);
|
|
4739
|
+
return handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
4574
4740
|
}
|
|
4575
4741
|
// General commands via document.execCommand
|
|
4576
4742
|
ensureEditorFocused(editor);
|
|
4577
4743
|
document.execCommand(command, false, value);
|
|
4578
4744
|
setTimeout(() => {
|
|
4745
|
+
if (!mountedRef.current)
|
|
4746
|
+
return;
|
|
4579
4747
|
if (editor && !isUpdatingRef.current) {
|
|
4580
4748
|
ensureAllCheckboxes(editor);
|
|
4581
4749
|
const content = domToContent(editor);
|
|
@@ -4616,6 +4784,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4616
4784
|
const block = document.createElement(type);
|
|
4617
4785
|
if (attributes) {
|
|
4618
4786
|
Object.entries(attributes).forEach(([key, val]) => {
|
|
4787
|
+
// Filter out event handler attributes
|
|
4788
|
+
if (key.toLowerCase().startsWith("on"))
|
|
4789
|
+
return;
|
|
4619
4790
|
block.setAttribute(key, val);
|
|
4620
4791
|
});
|
|
4621
4792
|
}
|
|
@@ -4638,6 +4809,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4638
4809
|
const inline = document.createElement(type);
|
|
4639
4810
|
if (attributes) {
|
|
4640
4811
|
Object.entries(attributes).forEach(([key, val]) => {
|
|
4812
|
+
// Filter out event handler attributes
|
|
4813
|
+
if (key.toLowerCase().startsWith("on"))
|
|
4814
|
+
return;
|
|
4641
4815
|
inline.setAttribute(key, val);
|
|
4642
4816
|
});
|
|
4643
4817
|
}
|
|
@@ -4724,6 +4898,8 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4724
4898
|
historyRef.current.push(currentContent, sel);
|
|
4725
4899
|
operation(selection);
|
|
4726
4900
|
setTimeout(() => {
|
|
4901
|
+
if (!mountedRef.current)
|
|
4902
|
+
return;
|
|
4727
4903
|
if (editor)
|
|
4728
4904
|
notifyChange(domToContent(editor));
|
|
4729
4905
|
}, 0);
|
|
@@ -4750,6 +4926,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4750
4926
|
editorRef,
|
|
4751
4927
|
historyRef,
|
|
4752
4928
|
isUpdatingRef,
|
|
4929
|
+
mountedRef,
|
|
4753
4930
|
notifyChange,
|
|
4754
4931
|
handleCheckboxKeyDown: checkbox.handleCheckboxKeyDown,
|
|
4755
4932
|
handleCheckboxEnter: checkbox.handleCheckboxEnter,
|
|
@@ -4846,6 +5023,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4846
5023
|
}
|
|
4847
5024
|
// Upload
|
|
4848
5025
|
const url = await onImageUpload(file);
|
|
5026
|
+
// Guard against unmount during async upload
|
|
5027
|
+
if (!mountedRef.current)
|
|
5028
|
+
return;
|
|
4849
5029
|
// Parse the "url|__aid__:attachmentId" convention
|
|
4850
5030
|
let realUrl = url;
|
|
4851
5031
|
if (url.includes("|__aid__:")) {
|
|
@@ -4856,6 +5036,11 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4856
5036
|
placeholder.setAttribute("data-attachment-id", attachmentId);
|
|
4857
5037
|
}
|
|
4858
5038
|
}
|
|
5039
|
+
// Validate the returned URL before setting it
|
|
5040
|
+
if (!isUrlSafe(realUrl) && !realUrl.startsWith("data:image/")) {
|
|
5041
|
+
placeholder.remove();
|
|
5042
|
+
return;
|
|
5043
|
+
}
|
|
4859
5044
|
// Replace placeholder with final image
|
|
4860
5045
|
placeholder.src = realUrl;
|
|
4861
5046
|
placeholder.removeAttribute("data-uploading");
|
|
@@ -4976,7 +5161,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4976
5161
|
}, suppressContentEditableWarning: true }), !readOnly && (jsx(FloatingToolbar, { plugins: plugins, editorAPI: editorAPI, editorElement: editorRef.current })), jsx(LinkTooltip, { editorElement: editorRef.current }), showWordCount && (jsxs("div", { className: "rte-word-count", children: [wordCount.words, " words \u00B7 ", wordCount.characters, " characters"] }))] }));
|
|
4977
5162
|
};
|
|
4978
5163
|
// --- Helper: Insert Image ---
|
|
4979
|
-
function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange) {
|
|
5164
|
+
function handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange) {
|
|
4980
5165
|
let selection = window.getSelection();
|
|
4981
5166
|
if (!selection)
|
|
4982
5167
|
return false;
|
|
@@ -4998,7 +5183,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
4998
5183
|
newRange.collapse(true);
|
|
4999
5184
|
selection.removeAllRanges();
|
|
5000
5185
|
selection.addRange(newRange);
|
|
5001
|
-
saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
|
|
5186
|
+
saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
5002
5187
|
return true;
|
|
5003
5188
|
}
|
|
5004
5189
|
selection.removeAllRanges();
|
|
@@ -5039,7 +5224,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5039
5224
|
newRange.collapse(true);
|
|
5040
5225
|
selection.removeAllRanges();
|
|
5041
5226
|
selection.addRange(newRange);
|
|
5042
|
-
saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
|
|
5227
|
+
saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
5043
5228
|
return true;
|
|
5044
5229
|
}
|
|
5045
5230
|
/**
|
|
@@ -5053,23 +5238,36 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5053
5238
|
function createImageElement(src) {
|
|
5054
5239
|
const img = document.createElement("img");
|
|
5055
5240
|
let realSrc = src;
|
|
5241
|
+
let altText = "";
|
|
5242
|
+
// Parse the "url|__alt__:altText" convention
|
|
5243
|
+
if (realSrc.includes("|__alt__:")) {
|
|
5244
|
+
const altIdx = realSrc.indexOf("|__alt__:");
|
|
5245
|
+
altText = realSrc.substring(altIdx + "|__alt__:".length);
|
|
5246
|
+
realSrc = realSrc.substring(0, altIdx);
|
|
5247
|
+
}
|
|
5056
5248
|
// Parse the "url|__aid__:attachmentId" convention
|
|
5057
|
-
if (
|
|
5058
|
-
const idx =
|
|
5059
|
-
|
|
5060
|
-
|
|
5249
|
+
if (realSrc.includes("|__aid__:")) {
|
|
5250
|
+
const idx = realSrc.indexOf("|__aid__:");
|
|
5251
|
+
const attachmentId = realSrc.substring(idx + "|__aid__:".length);
|
|
5252
|
+
realSrc = realSrc.substring(0, idx);
|
|
5061
5253
|
if (attachmentId) {
|
|
5062
5254
|
img.setAttribute("data-attachment-id", attachmentId);
|
|
5063
5255
|
}
|
|
5064
5256
|
}
|
|
5257
|
+
// Validate URL safety — block javascript:, data:, etc.
|
|
5258
|
+
if (!isUrlSafe(realSrc) && !realSrc.startsWith("data:image/")) {
|
|
5259
|
+
realSrc = "";
|
|
5260
|
+
}
|
|
5065
5261
|
img.setAttribute("src", realSrc);
|
|
5066
|
-
img.setAttribute("alt",
|
|
5262
|
+
img.setAttribute("alt", altText);
|
|
5067
5263
|
img.className = "rte-image";
|
|
5068
5264
|
return img;
|
|
5069
5265
|
}
|
|
5070
|
-
function saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange) {
|
|
5266
|
+
function saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange) {
|
|
5071
5267
|
isUpdatingRef.current = true;
|
|
5072
5268
|
setTimeout(() => {
|
|
5269
|
+
if (!mountedRef.current)
|
|
5270
|
+
return;
|
|
5073
5271
|
const content = domToContent(editor);
|
|
5074
5272
|
const sel = serializeSelection(editor);
|
|
5075
5273
|
historyRef.current.push(content, sel);
|
|
@@ -5199,14 +5397,14 @@ function createLinkPlugin() {
|
|
|
5199
5397
|
// Check if a link already exists
|
|
5200
5398
|
const existingLink = element?.closest("a");
|
|
5201
5399
|
if (existingLink) {
|
|
5202
|
-
//
|
|
5400
|
+
// Remove link
|
|
5203
5401
|
const parent = existingLink.parentNode;
|
|
5204
5402
|
if (parent) {
|
|
5205
5403
|
while (existingLink.firstChild) {
|
|
5206
5404
|
parent.insertBefore(existingLink.firstChild, existingLink);
|
|
5207
5405
|
}
|
|
5208
5406
|
parent.removeChild(existingLink);
|
|
5209
|
-
//
|
|
5407
|
+
// Update content
|
|
5210
5408
|
const editorEl = editor.getSelection()?.anchorNode;
|
|
5211
5409
|
if (editorEl) {
|
|
5212
5410
|
const content = editor.getContent();
|
|
@@ -5217,7 +5415,7 @@ function createLinkPlugin() {
|
|
|
5217
5415
|
else {
|
|
5218
5416
|
// Insert new link
|
|
5219
5417
|
const url = prompt("Enter URL:");
|
|
5220
|
-
if (url) {
|
|
5418
|
+
if (url && isUrlSafe(url)) {
|
|
5221
5419
|
editor.executeCommand("createLink", url);
|
|
5222
5420
|
}
|
|
5223
5421
|
}
|
|
@@ -5344,5 +5542,5 @@ const orderedListPlugin = {
|
|
|
5344
5542
|
},
|
|
5345
5543
|
};
|
|
5346
5544
|
|
|
5347
|
-
export { Dropdown, Editor, FloatingToolbar, HistoryManager, TableContextMenuProvider, Toolbar, advancedLinkPlugin, alignmentPlugin, blockquotePlugin, boldPlugin, buildPluginsFromSettings, clearFormattingPlugin, codeInlinePlugin, contentToDOM, contentToHTML, createAdvancedLinkPlugin, createAlignmentPlugin, createBackgroundColorPlugin, createBlockFormatPlugin, createEmptyContent, createFontSizePlugin, createHeadingsPlugin, createImagePlugin, createLinkPlugin, createTextColorPlugin, Editor as default, defaultEditorSettings, defaultPlugins, domToContent, ensureAllCheckboxes, findClosestCheckboxList, getCurrentBackgroundColor, getCurrentFontSize, getCurrentHeading, getCurrentTextColor, horizontalRulePlugin, htmlToContent, indentListItem, indentListItemPlugin, isCheckboxList, italicPlugin, linkPlugin, orderedListPlugin, outdentListItem, outdentListItemPlugin, redoPlugin, restoreSerializedSelection, sanitizeHtml, serializeSelection, strikethroughPlugin, subscriptPlugin, superscriptPlugin, tablePlugin, underlinePlugin, undoPlugin, unorderedListPlugin };
|
|
5545
|
+
export { Dropdown, Editor, FloatingToolbar, HistoryManager, TableContextMenuProvider, Toolbar, advancedLinkPlugin, alignmentPlugin, blockquotePlugin, boldPlugin, buildPluginsFromSettings, clearFormattingPlugin, codeInlinePlugin, contentToDOM, contentToHTML, createAdvancedLinkPlugin, createAlignmentPlugin, createBackgroundColorPlugin, createBlockFormatPlugin, createEmptyContent, createFontSizePlugin, createHeadingsPlugin, createImagePlugin, createLinkPlugin, createTextColorPlugin, Editor as default, defaultEditorSettings, defaultPlugins, domToContent, ensureAllCheckboxes, findClosestCheckboxList, getCurrentBackgroundColor, getCurrentFontSize, getCurrentHeading, getCurrentTextColor, horizontalRulePlugin, htmlToContent, indentListItem, indentListItemPlugin, isCheckboxList, isUrlSafe, italicPlugin, linkPlugin, orderedListPlugin, outdentListItem, outdentListItemPlugin, redoPlugin, restoreSerializedSelection, sanitizeHtml, serializeSelection, strikethroughPlugin, subscriptPlugin, superscriptPlugin, tablePlugin, underlinePlugin, undoPlugin, unorderedListPlugin };
|
|
5348
5546
|
//# sourceMappingURL=index.esm.js.map
|