@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.js
CHANGED
|
@@ -106,7 +106,20 @@ const Dropdown = ({ icon, label, options, onSelect, currentValue, disabled, show
|
|
|
106
106
|
setIsOpen(false);
|
|
107
107
|
};
|
|
108
108
|
const currentOption = options.find(opt => opt.value === currentValue);
|
|
109
|
-
|
|
109
|
+
// Close on Escape key
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
if (!isOpen)
|
|
112
|
+
return;
|
|
113
|
+
const handleKeyDown = (e) => {
|
|
114
|
+
if (e.key === "Escape") {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
setIsOpen(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
120
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
121
|
+
}, [isOpen]);
|
|
122
|
+
return (jsxRuntime.jsxs("div", { className: "rte-dropdown", ref: dropdownRef, onMouseDown: (e) => e.preventDefault(), children: [jsxRuntime.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: [jsxRuntime.jsx(Icon, { icon: icon, width: 18, height: 18 }), currentOption && (jsxRuntime.jsx("span", { className: "rte-dropdown-value", children: currentOption.label }))] }), isOpen && (jsxRuntime.jsxs("div", { className: "rte-dropdown-menu", role: "listbox", "aria-label": label, children: [options.map((option) => (jsxRuntime.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 && (jsxRuntime.jsx("span", { className: `rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`, style: { backgroundColor: option.color } })), option.preview && !option.headingPreview && (jsxRuntime.jsx("span", { className: "rte-dropdown-fontsize-preview", style: { fontSize: `${option.preview}px` }, children: "Aa" })), option.headingPreview && (jsxRuntime.jsx("span", { className: `rte-dropdown-heading-preview ${option.headingPreview}`, children: option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase() })), option.icon && jsxRuntime.jsx(Icon, { icon: option.icon, width: 16, height: 16 }), jsxRuntime.jsx("span", { style: { flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }, children: option.label })] }, option.value))), showCustomColorInput && (jsxRuntime.jsxs("div", { className: "rte-color-custom-input", onMouseDown: (e) => e.stopPropagation(), children: [jsxRuntime.jsx("input", { type: "color", value: customColor, onChange: (e) => setCustomColor(e.target.value), title: "Pick a color" }), jsxRuntime.jsx("input", { type: "text", value: customColor, onChange: (e) => {
|
|
110
123
|
const v = e.target.value;
|
|
111
124
|
setCustomColor(v);
|
|
112
125
|
}, placeholder: "#000000", maxLength: 7, onKeyDown: (e) => {
|
|
@@ -661,6 +674,168 @@ function useCheckbox({ editorRef, isUpdatingRef, pushToHistory, notifyChange, ge
|
|
|
661
674
|
};
|
|
662
675
|
}
|
|
663
676
|
|
|
677
|
+
/**
|
|
678
|
+
* Lightweight HTML sanitizer (no external dependencies).
|
|
679
|
+
*
|
|
680
|
+
* Uses an allowlist approach to strip dangerous elements and attributes
|
|
681
|
+
* from pasted/imported HTML before it enters the editor.
|
|
682
|
+
*/
|
|
683
|
+
/** Tags that are always removed (including their content). */
|
|
684
|
+
const REMOVE_TAGS = new Set([
|
|
685
|
+
"script",
|
|
686
|
+
"style",
|
|
687
|
+
"iframe",
|
|
688
|
+
"object",
|
|
689
|
+
"embed",
|
|
690
|
+
"applet",
|
|
691
|
+
"form",
|
|
692
|
+
"input",
|
|
693
|
+
"textarea",
|
|
694
|
+
"select",
|
|
695
|
+
"button",
|
|
696
|
+
"noscript",
|
|
697
|
+
"meta",
|
|
698
|
+
"link",
|
|
699
|
+
"base",
|
|
700
|
+
"svg",
|
|
701
|
+
"math",
|
|
702
|
+
"template",
|
|
703
|
+
"details",
|
|
704
|
+
"video",
|
|
705
|
+
"audio",
|
|
706
|
+
"marquee",
|
|
707
|
+
]);
|
|
708
|
+
/** Attributes that are always removed (event handlers, dangerous data-* attrs). */
|
|
709
|
+
const REMOVE_ATTRS_PATTERN = /^on|^data-(?!(attachment-id|placeholder)$)/i;
|
|
710
|
+
/** Specific dangerous attribute names. */
|
|
711
|
+
const REMOVE_ATTRS = new Set([
|
|
712
|
+
"srcdoc",
|
|
713
|
+
"formaction",
|
|
714
|
+
"xlink:href",
|
|
715
|
+
"ping",
|
|
716
|
+
]);
|
|
717
|
+
/** Allowed URL schemes for href/src attributes. */
|
|
718
|
+
const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/(?!\/))/i;
|
|
719
|
+
/**
|
|
720
|
+
* Check if a URL is safe to set as href/src.
|
|
721
|
+
* Blocks javascript:, data:, vbscript:, and unknown schemes.
|
|
722
|
+
* Strips control characters before checking.
|
|
723
|
+
*/
|
|
724
|
+
function isUrlSafe(url) {
|
|
725
|
+
if (!url)
|
|
726
|
+
return false;
|
|
727
|
+
// Strip control characters and whitespace that could obfuscate schemes
|
|
728
|
+
const cleaned = url.trim().replace(/[\x00-\x1f\x7f]/g, "");
|
|
729
|
+
if (!cleaned)
|
|
730
|
+
return false;
|
|
731
|
+
// Block protocol-relative URLs (//evil.com)
|
|
732
|
+
if (cleaned.startsWith("//"))
|
|
733
|
+
return false;
|
|
734
|
+
// Must match an allowed scheme or be a relative path
|
|
735
|
+
return ALLOWED_SCHEMES.test(cleaned);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Sanitize an HTML string by stripping dangerous tags and attributes.
|
|
739
|
+
*
|
|
740
|
+
* @param html - Raw HTML string
|
|
741
|
+
* @returns Sanitized HTML string
|
|
742
|
+
*/
|
|
743
|
+
function sanitizeHtml(html) {
|
|
744
|
+
// Use the browser's DOMParser to parse the HTML safely
|
|
745
|
+
const parser = new DOMParser();
|
|
746
|
+
const doc = parser.parseFromString(html, "text/html");
|
|
747
|
+
sanitizeNode(doc.body);
|
|
748
|
+
return doc.body.innerHTML;
|
|
749
|
+
}
|
|
750
|
+
/** Recursively sanitize a DOM node. */
|
|
751
|
+
function sanitizeNode(node) {
|
|
752
|
+
const childrenToRemove = [];
|
|
753
|
+
node.childNodes.forEach((child) => {
|
|
754
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
755
|
+
const el = child;
|
|
756
|
+
const tag = el.tagName.toLowerCase();
|
|
757
|
+
// Remove dangerous tags entirely
|
|
758
|
+
if (REMOVE_TAGS.has(tag)) {
|
|
759
|
+
childrenToRemove.push(child);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
// Remove dangerous attributes
|
|
763
|
+
const attrsToRemove = [];
|
|
764
|
+
for (let i = 0; i < el.attributes.length; i++) {
|
|
765
|
+
const attr = el.attributes[i];
|
|
766
|
+
const name = attr.name.toLowerCase();
|
|
767
|
+
if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
|
|
768
|
+
attrsToRemove.push(attr.name);
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
// Validate URL attributes
|
|
772
|
+
if (name === "href" || name === "src" || name === "action" || name === "cite" || name === "poster") {
|
|
773
|
+
const value = attr.value.trim();
|
|
774
|
+
if (value) {
|
|
775
|
+
// Allow data:image/* for img src (matches contentToDOM behavior)
|
|
776
|
+
const isDataImage = name === "src" && el.tagName === "IMG" && value.startsWith("data:image/");
|
|
777
|
+
if (!isDataImage && !ALLOWED_SCHEMES.test(value)) {
|
|
778
|
+
attrsToRemove.push(attr.name);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// Remove dangerous URI schemes in any attribute value
|
|
783
|
+
const lowerValue = attr.value.toLowerCase().replace(/[\x00-\x1f\x7f\s]/g, "");
|
|
784
|
+
if (lowerValue.includes("javascript:") ||
|
|
785
|
+
lowerValue.includes("vbscript:") ||
|
|
786
|
+
lowerValue.includes("data:text/html")) {
|
|
787
|
+
attrsToRemove.push(attr.name);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
|
|
791
|
+
// Recurse into children
|
|
792
|
+
sanitizeNode(child);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
// Remove marked children
|
|
796
|
+
childrenToRemove.forEach((child) => {
|
|
797
|
+
node.removeChild(child);
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Allowed tag names that contentToDOM may create from JSON. */
|
|
802
|
+
const ALLOWED_CONTENT_TAGS = new Set([
|
|
803
|
+
"p", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
804
|
+
"ul", "ol", "li", "a", "strong", "em", "u", "s", "del",
|
|
805
|
+
"sub", "sup", "code", "pre", "blockquote", "br", "hr",
|
|
806
|
+
"img", "table", "thead", "tbody", "tr", "th", "td",
|
|
807
|
+
"b", "i", "strike",
|
|
808
|
+
]);
|
|
809
|
+
/** Checks if an attribute key is safe to set on a DOM element. */
|
|
810
|
+
function isSafeAttribute(key) {
|
|
811
|
+
const lower = key.toLowerCase();
|
|
812
|
+
// Block all event handler attributes
|
|
813
|
+
if (lower.startsWith("on"))
|
|
814
|
+
return false;
|
|
815
|
+
// Block dangerous attributes
|
|
816
|
+
if (lower === "srcdoc" || lower === "formaction" || lower === "xlink:href" || lower === "ping")
|
|
817
|
+
return false;
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
/** Validates style values against expected patterns. */
|
|
821
|
+
function isSafeStyleValue(prop, value) {
|
|
822
|
+
const clean = value.trim().toLowerCase();
|
|
823
|
+
// Block expression(), url(), import, javascript, and other injection vectors
|
|
824
|
+
if (/expression\s*\(|url\s*\(|@import|javascript:|vbscript:|-moz-binding/i.test(clean)) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
switch (prop) {
|
|
828
|
+
case "fontSize":
|
|
829
|
+
return /^[\d.]+(px|em|rem|pt|%|vw|vh)$/i.test(clean);
|
|
830
|
+
case "color":
|
|
831
|
+
case "backgroundColor":
|
|
832
|
+
return /^(#[0-9a-f]{3,8}|rgb\([\d\s,.%]+\)|rgba\([\d\s,.%]+\)|[a-z]+)$/i.test(clean);
|
|
833
|
+
case "textAlign":
|
|
834
|
+
return /^(left|right|center|justify|start|end)$/.test(clean);
|
|
835
|
+
default:
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
664
839
|
/**
|
|
665
840
|
* Converts a DOM element (editor root) to EditorContent JSON.
|
|
666
841
|
* Supports own format, Lexical HTML, and GitHub HTML.
|
|
@@ -981,8 +1156,13 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
|
|
|
981
1156
|
if (node.type === "image") {
|
|
982
1157
|
const img = document.createElement("img");
|
|
983
1158
|
if (node.attributes) {
|
|
984
|
-
if (node.attributes.src)
|
|
985
|
-
|
|
1159
|
+
if (node.attributes.src) {
|
|
1160
|
+
// Allow data:image/* for placeholders/uploads, otherwise validate
|
|
1161
|
+
const imgSrc = node.attributes.src;
|
|
1162
|
+
if (isUrlSafe(imgSrc) || imgSrc.startsWith("data:image/")) {
|
|
1163
|
+
img.setAttribute("src", imgSrc);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
986
1166
|
if (node.attributes.alt)
|
|
987
1167
|
img.setAttribute("alt", node.attributes.alt);
|
|
988
1168
|
if (node.attributes.uploading === "true") {
|
|
@@ -1009,23 +1189,32 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
|
|
|
1009
1189
|
node.attributes["data-custom-link"] = "true";
|
|
1010
1190
|
}
|
|
1011
1191
|
}
|
|
1192
|
+
// Whitelist check: fall back to span for unknown/dangerous tags
|
|
1193
|
+
if (!ALLOWED_CONTENT_TAGS.has(tagName)) {
|
|
1194
|
+
tagName = "span";
|
|
1195
|
+
}
|
|
1012
1196
|
const element = document.createElement(tagName);
|
|
1013
1197
|
if (node.attributes) {
|
|
1014
1198
|
Object.entries(node.attributes).forEach(([key, value]) => {
|
|
1015
1199
|
if (key === "fontSize") {
|
|
1016
|
-
|
|
1200
|
+
if (isSafeStyleValue("fontSize", value))
|
|
1201
|
+
element.style.fontSize = value;
|
|
1017
1202
|
}
|
|
1018
1203
|
else if (key === "color") {
|
|
1019
|
-
|
|
1204
|
+
if (isSafeStyleValue("color", value))
|
|
1205
|
+
element.style.color = value;
|
|
1020
1206
|
}
|
|
1021
1207
|
else if (key === "backgroundColor") {
|
|
1022
|
-
|
|
1208
|
+
if (isSafeStyleValue("backgroundColor", value))
|
|
1209
|
+
element.style.backgroundColor = value;
|
|
1023
1210
|
}
|
|
1024
1211
|
else if (key === "textAlign") {
|
|
1025
|
-
|
|
1212
|
+
if (isSafeStyleValue("textAlign", value))
|
|
1213
|
+
element.style.textAlign = value;
|
|
1026
1214
|
}
|
|
1027
1215
|
else if (key === "href" && tagName === "a") {
|
|
1028
|
-
|
|
1216
|
+
if (isUrlSafe(value))
|
|
1217
|
+
element.setAttribute("href", value);
|
|
1029
1218
|
}
|
|
1030
1219
|
else if (key === "target" && tagName === "a") {
|
|
1031
1220
|
element.setAttribute("target", value);
|
|
@@ -1055,8 +1244,16 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
|
|
|
1055
1244
|
element.className = value;
|
|
1056
1245
|
}
|
|
1057
1246
|
else if (key === "checkboxChecked") ;
|
|
1058
|
-
else {
|
|
1059
|
-
|
|
1247
|
+
else if (isSafeAttribute(key)) {
|
|
1248
|
+
// Validate URL-like attribute values
|
|
1249
|
+
const lowerKey = key.toLowerCase();
|
|
1250
|
+
if (["cite", "action", "poster", "background"].includes(lowerKey)) {
|
|
1251
|
+
if (isUrlSafe(value))
|
|
1252
|
+
element.setAttribute(key, value);
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
element.setAttribute(key, value);
|
|
1256
|
+
}
|
|
1060
1257
|
}
|
|
1061
1258
|
});
|
|
1062
1259
|
}
|
|
@@ -1103,10 +1300,12 @@ function contentToHTML(content) {
|
|
|
1103
1300
|
/**
|
|
1104
1301
|
* Converts an HTML string to EditorContent.
|
|
1105
1302
|
* Supports Lexical, GitHub, and standard HTML formats.
|
|
1303
|
+
* Sanitizes HTML before parsing to prevent XSS.
|
|
1106
1304
|
*/
|
|
1107
1305
|
function htmlToContent(htmlString) {
|
|
1306
|
+
const sanitized = sanitizeHtml(htmlString);
|
|
1108
1307
|
const tempDiv = document.createElement("div");
|
|
1109
|
-
tempDiv.innerHTML =
|
|
1308
|
+
tempDiv.innerHTML = sanitized;
|
|
1110
1309
|
return domToContent(tempDiv);
|
|
1111
1310
|
}
|
|
1112
1311
|
|
|
@@ -1116,7 +1315,7 @@ function htmlToContent(htmlString) {
|
|
|
1116
1315
|
* Detects URLs in text nodes and wraps them in <a> tags.
|
|
1117
1316
|
* Triggered after space/enter when the preceding word looks like a URL.
|
|
1118
1317
|
*/
|
|
1119
|
-
const URL_REGEX =
|
|
1318
|
+
const URL_REGEX = /^(?:https?:\/\/|www\.)[^\s<>'"]+\.[a-z]{2,}[^\s<>'"]*$/i;
|
|
1120
1319
|
/**
|
|
1121
1320
|
* Check if the word before the cursor is a URL and wrap it in an <a> tag.
|
|
1122
1321
|
* Called on space/enter keypress in the editor.
|
|
@@ -1154,6 +1353,9 @@ function handleAutoLink(editor, e) {
|
|
|
1154
1353
|
if (href.startsWith("www.")) {
|
|
1155
1354
|
href = "https://" + href;
|
|
1156
1355
|
}
|
|
1356
|
+
// Validate the URL is safe before creating a link
|
|
1357
|
+
if (!isUrlSafe(href))
|
|
1358
|
+
return false;
|
|
1157
1359
|
// Create the link element
|
|
1158
1360
|
const anchor = document.createElement("a");
|
|
1159
1361
|
anchor.href = href;
|
|
@@ -1215,22 +1417,22 @@ function indentListItem(selection) {
|
|
|
1215
1417
|
break;
|
|
1216
1418
|
}
|
|
1217
1419
|
}
|
|
1218
|
-
// Max depth: 6 (
|
|
1420
|
+
// Max depth: 6 (as per HTML standard)
|
|
1219
1421
|
if (depth >= 6)
|
|
1220
1422
|
return false;
|
|
1221
|
-
//
|
|
1423
|
+
// Find previous list item
|
|
1222
1424
|
const previousItem = listItem.previousElementSibling;
|
|
1223
1425
|
if (previousItem && previousItem.tagName === 'LI') {
|
|
1224
|
-
//
|
|
1426
|
+
// Create nested list in the previous item
|
|
1225
1427
|
let nestedList = previousItem.querySelector('ul, ol');
|
|
1226
1428
|
if (!nestedList) {
|
|
1227
|
-
//
|
|
1429
|
+
// Create new nested list
|
|
1228
1430
|
nestedList = document.createElement(list.tagName.toLowerCase());
|
|
1229
1431
|
previousItem.appendChild(nestedList);
|
|
1230
1432
|
}
|
|
1231
|
-
//
|
|
1433
|
+
// Move current item into nested list
|
|
1232
1434
|
nestedList.appendChild(listItem);
|
|
1233
|
-
//
|
|
1435
|
+
// Set cursor position
|
|
1234
1436
|
const textNode = listItem.firstChild;
|
|
1235
1437
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
|
1236
1438
|
range.setStart(textNode, 0);
|
|
@@ -1251,9 +1453,9 @@ function indentListItem(selection) {
|
|
|
1251
1453
|
return true;
|
|
1252
1454
|
}
|
|
1253
1455
|
else {
|
|
1254
|
-
//
|
|
1456
|
+
// No previous item — create new nested list in current item
|
|
1255
1457
|
const nestedList = document.createElement(list.tagName.toLowerCase());
|
|
1256
|
-
//
|
|
1458
|
+
// Move all following items into the nested list
|
|
1257
1459
|
let nextSibling = listItem.nextElementSibling;
|
|
1258
1460
|
while (nextSibling && nextSibling.tagName === 'LI') {
|
|
1259
1461
|
const toMove = nextSibling;
|
|
@@ -1264,11 +1466,11 @@ function indentListItem(selection) {
|
|
|
1264
1466
|
listItem.appendChild(nestedList);
|
|
1265
1467
|
}
|
|
1266
1468
|
else {
|
|
1267
|
-
//
|
|
1469
|
+
// If no following items, create empty sub-item
|
|
1268
1470
|
const subItem = document.createElement('li');
|
|
1269
1471
|
nestedList.appendChild(subItem);
|
|
1270
1472
|
listItem.appendChild(nestedList);
|
|
1271
|
-
//
|
|
1473
|
+
// Set cursor in sub-item
|
|
1272
1474
|
const newText = document.createTextNode('');
|
|
1273
1475
|
subItem.appendChild(newText);
|
|
1274
1476
|
range.setStart(newText, 0);
|
|
@@ -1298,17 +1500,17 @@ function outdentListItem(selection) {
|
|
|
1298
1500
|
// Check if in nested list
|
|
1299
1501
|
const parentListItem = list.parentElement;
|
|
1300
1502
|
if (!parentListItem || parentListItem.tagName !== 'LI') {
|
|
1301
|
-
//
|
|
1503
|
+
// Already at top level
|
|
1302
1504
|
return false;
|
|
1303
1505
|
}
|
|
1304
1506
|
const parentList = parentListItem.parentElement;
|
|
1305
1507
|
if (!parentList || (parentList.tagName !== 'UL' && parentList.tagName !== 'OL')) {
|
|
1306
1508
|
return false;
|
|
1307
1509
|
}
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1510
|
+
// Move item to parent level
|
|
1511
|
+
// Find position after the parent item
|
|
1310
1512
|
const insertAfter = parentListItem;
|
|
1311
|
-
//
|
|
1513
|
+
// Move item and all following items out of the nested list
|
|
1312
1514
|
const itemsToMove = [listItem];
|
|
1313
1515
|
let nextSibling = listItem.nextElementSibling;
|
|
1314
1516
|
while (nextSibling && nextSibling.tagName === 'LI') {
|
|
@@ -1319,11 +1521,11 @@ function outdentListItem(selection) {
|
|
|
1319
1521
|
itemsToMove.forEach(item => {
|
|
1320
1522
|
parentList.insertBefore(item, insertAfter.nextSibling);
|
|
1321
1523
|
});
|
|
1322
|
-
//
|
|
1524
|
+
// Remove empty nested list
|
|
1323
1525
|
if (list.children.length === 0) {
|
|
1324
1526
|
list.remove();
|
|
1325
1527
|
}
|
|
1326
|
-
//
|
|
1528
|
+
// Set cursor position
|
|
1327
1529
|
const textNode = listItem.firstChild;
|
|
1328
1530
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
|
1329
1531
|
range.setStart(textNode, 0);
|
|
@@ -1857,7 +2059,7 @@ function navigateTableCell(direction) {
|
|
|
1857
2059
|
/**
|
|
1858
2060
|
* Hook that sets up input and keydown event listeners on the editor.
|
|
1859
2061
|
*/
|
|
1860
|
-
function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
|
|
2062
|
+
function useEditorEvents({ editorRef, historyRef, isUpdatingRef, mountedRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
|
|
1861
2063
|
React.useEffect(() => {
|
|
1862
2064
|
const editor = editorRef.current;
|
|
1863
2065
|
if (!editor)
|
|
@@ -1865,11 +2067,15 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1865
2067
|
let inputTimeout = null;
|
|
1866
2068
|
const handleInput = () => {
|
|
1867
2069
|
setTimeout(() => {
|
|
2070
|
+
if (!mountedRef.current)
|
|
2071
|
+
return;
|
|
1868
2072
|
const content = domToContent(editor);
|
|
1869
2073
|
notifyChange(content);
|
|
1870
2074
|
if (inputTimeout)
|
|
1871
2075
|
clearTimeout(inputTimeout);
|
|
1872
2076
|
inputTimeout = setTimeout(() => {
|
|
2077
|
+
if (!mountedRef.current)
|
|
2078
|
+
return;
|
|
1873
2079
|
const sel = serializeSelection(editor);
|
|
1874
2080
|
historyRef.current.push(content, sel);
|
|
1875
2081
|
inputTimeout = null;
|
|
@@ -1881,6 +2087,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1881
2087
|
// Markdown-style shortcuts (e.g., # + space → heading)
|
|
1882
2088
|
if (!isModifierPressed && handleMarkdownShortcut(editor, e)) {
|
|
1883
2089
|
setTimeout(() => {
|
|
2090
|
+
if (!mountedRef.current)
|
|
2091
|
+
return;
|
|
1884
2092
|
const content = domToContent(editor);
|
|
1885
2093
|
notifyChange(content);
|
|
1886
2094
|
}, 0);
|
|
@@ -1905,6 +2113,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1905
2113
|
e.stopImmediatePropagation();
|
|
1906
2114
|
navigateTableCell(e.shiftKey ? "prev" : "next");
|
|
1907
2115
|
setTimeout(() => {
|
|
2116
|
+
if (!mountedRef.current)
|
|
2117
|
+
return;
|
|
1908
2118
|
if (editor) {
|
|
1909
2119
|
const content = domToContent(editor);
|
|
1910
2120
|
notifyChange(content);
|
|
@@ -1936,6 +2146,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
1936
2146
|
indentListItem(selection);
|
|
1937
2147
|
}
|
|
1938
2148
|
setTimeout(() => {
|
|
2149
|
+
if (!mountedRef.current)
|
|
2150
|
+
return;
|
|
1939
2151
|
if (editor) {
|
|
1940
2152
|
const content = domToContent(editor);
|
|
1941
2153
|
notifyChange(content);
|
|
@@ -2047,6 +2259,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
|
|
|
2047
2259
|
document.execCommand(command, false);
|
|
2048
2260
|
}
|
|
2049
2261
|
setTimeout(() => {
|
|
2262
|
+
if (!mountedRef.current)
|
|
2263
|
+
return;
|
|
2050
2264
|
if (editor) {
|
|
2051
2265
|
const content = domToContent(editor);
|
|
2052
2266
|
notifyChange(content);
|
|
@@ -2096,6 +2310,7 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
|
|
|
2096
2310
|
// Normalize all checkbox lists (own + Lexical + GitHub formats)
|
|
2097
2311
|
ensureAllCheckboxes(editor);
|
|
2098
2312
|
// MutationObserver: ensure new checkbox list items get correct attributes
|
|
2313
|
+
let checkboxTimeout = null;
|
|
2099
2314
|
const observer = new MutationObserver((mutations) => {
|
|
2100
2315
|
if (isUpdatingRef.current)
|
|
2101
2316
|
return;
|
|
@@ -2125,11 +2340,15 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
|
|
|
2125
2340
|
break;
|
|
2126
2341
|
}
|
|
2127
2342
|
if (needsUpdate) {
|
|
2128
|
-
|
|
2343
|
+
if (checkboxTimeout)
|
|
2344
|
+
clearTimeout(checkboxTimeout);
|
|
2345
|
+
checkboxTimeout = setTimeout(() => ensureAllCheckboxes(editor), 0);
|
|
2129
2346
|
}
|
|
2130
2347
|
});
|
|
2131
2348
|
observer.observe(editor, { childList: true, subtree: true });
|
|
2132
2349
|
return () => {
|
|
2350
|
+
if (checkboxTimeout)
|
|
2351
|
+
clearTimeout(checkboxTimeout);
|
|
2133
2352
|
observer.disconnect();
|
|
2134
2353
|
};
|
|
2135
2354
|
// Only run once on mount
|
|
@@ -2170,7 +2389,7 @@ function createInlinePlugin(name, command, icon, label) {
|
|
|
2170
2389
|
},
|
|
2171
2390
|
canExecute: (editor) => {
|
|
2172
2391
|
// Formatting should also work without a selection
|
|
2173
|
-
// (
|
|
2392
|
+
// (e.g. when the editor is empty, a selection is created on click)
|
|
2174
2393
|
return true;
|
|
2175
2394
|
},
|
|
2176
2395
|
};
|
|
@@ -2941,7 +3160,7 @@ function getDisplayUrl(raw) {
|
|
|
2941
3160
|
return raw;
|
|
2942
3161
|
}
|
|
2943
3162
|
/**
|
|
2944
|
-
* Image
|
|
3163
|
+
* Image plugin with URL input and file upload support.
|
|
2945
3164
|
*/
|
|
2946
3165
|
function createImagePlugin(onImageUpload) {
|
|
2947
3166
|
return {
|
|
@@ -2964,7 +3183,17 @@ function createImagePlugin(onImageUpload) {
|
|
|
2964
3183
|
alert('Please enter an image URL');
|
|
2965
3184
|
return;
|
|
2966
3185
|
}
|
|
2967
|
-
|
|
3186
|
+
// Validate URL safety
|
|
3187
|
+
const srcWithoutMeta = src.split('|__aid__:')[0];
|
|
3188
|
+
if (!isUrlSafe(srcWithoutMeta)) {
|
|
3189
|
+
alert('Invalid image URL');
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
// Append alt text metadata if provided
|
|
3193
|
+
const fullValue = altText.trim()
|
|
3194
|
+
? `${src}|__alt__:${altText.trim()}`
|
|
3195
|
+
: src;
|
|
3196
|
+
props.editorAPI.executeCommand('insertImage', fullValue);
|
|
2968
3197
|
// Close modal
|
|
2969
3198
|
setShowModal(false);
|
|
2970
3199
|
setRawUrl('');
|
|
@@ -3002,11 +3231,11 @@ function createImagePlugin(onImageUpload) {
|
|
|
3002
3231
|
if (e.target === e.currentTarget) {
|
|
3003
3232
|
setShowModal(false);
|
|
3004
3233
|
}
|
|
3005
|
-
}, children: jsxRuntime.jsxs("div", { className: "rte-image-modal", children: [jsxRuntime.jsxs("div", { className: "rte-image-modal-header", children: [jsxRuntime.jsx("h3", { children: "Insert Image" }), jsxRuntime.jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxRuntime.jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsxRuntime.jsx("div", { className: "rte-image-upload-section", children: jsxRuntime.jsxs("label", { className: "rte-image-upload-label", children: [jsxRuntime.jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsxRuntime.jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsxRuntime.jsx("span", { children: "
|
|
3234
|
+
}, children: jsxRuntime.jsxs("div", { className: "rte-image-modal", children: [jsxRuntime.jsxs("div", { className: "rte-image-modal-header", children: [jsxRuntime.jsx("h3", { children: "Insert Image" }), jsxRuntime.jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxRuntime.jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsxRuntime.jsx("div", { className: "rte-image-upload-section", children: jsxRuntime.jsxs("label", { className: "rte-image-upload-label", children: [jsxRuntime.jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsxRuntime.jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsxRuntime.jsx("span", { children: "Uploading..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(IconWrapper, { icon: "mdi:upload", width: 24, height: 24 }), jsxRuntime.jsx("span", { children: "Choose File" })] })) })] }) })), jsxRuntime.jsx("div", { className: "rte-image-url-section", children: jsxRuntime.jsxs("label", { children: ["Image URL", jsxRuntime.jsx("input", { type: "url", value: imageUrl, onChange: (e) => {
|
|
3006
3235
|
setImageUrl(e.target.value);
|
|
3007
3236
|
// Clear raw URL when user manually edits
|
|
3008
3237
|
setRawUrl('');
|
|
3009
|
-
}, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsxRuntime.jsx("div", { className: "rte-image-alt-section", children: jsxRuntime.jsxs("label", { children: ["Alt
|
|
3238
|
+
}, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsxRuntime.jsx("div", { className: "rte-image-alt-section", children: jsxRuntime.jsxs("label", { children: ["Alt Text (optional)", jsxRuntime.jsx("input", { type: "text", value: altText, onChange: (e) => setAltText(e.target.value), placeholder: "Image description", className: "rte-image-alt-input" })] }) }), imageUrl && (jsxRuntime.jsx("div", { className: "rte-image-preview", children: jsxRuntime.jsx("img", { src: imageUrl, alt: altText || 'Preview' }) }))] }), jsxRuntime.jsxs("div", { className: "rte-image-modal-footer", children: [jsxRuntime.jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-cancel", children: "Cancel" }), jsxRuntime.jsx("button", { type: "button", onClick: handleInsertImage, disabled: !imageUrl.trim() || isUploading, className: "rte-image-modal-insert", children: "Insert" })] })] }) }))] }));
|
|
3010
3239
|
},
|
|
3011
3240
|
execute: (editor) => {
|
|
3012
3241
|
// Handled via renderButton
|
|
@@ -3077,7 +3306,7 @@ function clearFormatting(selection) {
|
|
|
3077
3306
|
}
|
|
3078
3307
|
catch (error) {
|
|
3079
3308
|
console.error('Error clearing formatting:', error);
|
|
3080
|
-
// Fallback:
|
|
3309
|
+
// Fallback: simple method
|
|
3081
3310
|
document.execCommand('removeFormat', false);
|
|
3082
3311
|
document.execCommand('unlink', false);
|
|
3083
3312
|
}
|
|
@@ -3226,7 +3455,7 @@ class HistoryManager {
|
|
|
3226
3455
|
// Limit history size
|
|
3227
3456
|
if (this.history.length > this.maxHistorySize) {
|
|
3228
3457
|
this.history.shift();
|
|
3229
|
-
this.currentIndex
|
|
3458
|
+
this.currentIndex = Math.max(0, this.currentIndex - 1);
|
|
3230
3459
|
}
|
|
3231
3460
|
}
|
|
3232
3461
|
undo() {
|
|
@@ -3273,98 +3502,6 @@ class HistoryManager {
|
|
|
3273
3502
|
}
|
|
3274
3503
|
}
|
|
3275
3504
|
|
|
3276
|
-
/**
|
|
3277
|
-
* Lightweight HTML sanitizer (no external dependencies).
|
|
3278
|
-
*
|
|
3279
|
-
* Uses an allowlist approach to strip dangerous elements and attributes
|
|
3280
|
-
* from pasted/imported HTML before it enters the editor.
|
|
3281
|
-
*/
|
|
3282
|
-
/** Tags that are always removed (including their content). */
|
|
3283
|
-
const REMOVE_TAGS = new Set([
|
|
3284
|
-
"script",
|
|
3285
|
-
"style",
|
|
3286
|
-
"iframe",
|
|
3287
|
-
"object",
|
|
3288
|
-
"embed",
|
|
3289
|
-
"applet",
|
|
3290
|
-
"form",
|
|
3291
|
-
"input",
|
|
3292
|
-
"textarea",
|
|
3293
|
-
"select",
|
|
3294
|
-
"button",
|
|
3295
|
-
"noscript",
|
|
3296
|
-
"meta",
|
|
3297
|
-
"link",
|
|
3298
|
-
"base",
|
|
3299
|
-
]);
|
|
3300
|
-
/** Attributes that are always removed (event handlers, dangerous attrs). */
|
|
3301
|
-
const REMOVE_ATTRS_PATTERN = /^on|^data-(?!attachment-id$|placeholder$)/i;
|
|
3302
|
-
/** Specific dangerous attribute names. */
|
|
3303
|
-
const REMOVE_ATTRS = new Set([
|
|
3304
|
-
"srcdoc",
|
|
3305
|
-
"formaction",
|
|
3306
|
-
"xlink:href",
|
|
3307
|
-
"ping",
|
|
3308
|
-
]);
|
|
3309
|
-
/** Allowed URL schemes for href/src attributes. */
|
|
3310
|
-
const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/)/i;
|
|
3311
|
-
/**
|
|
3312
|
-
* Sanitize an HTML string by stripping dangerous tags and attributes.
|
|
3313
|
-
*
|
|
3314
|
-
* @param html - Raw HTML string
|
|
3315
|
-
* @returns Sanitized HTML string
|
|
3316
|
-
*/
|
|
3317
|
-
function sanitizeHtml(html) {
|
|
3318
|
-
// Use the browser's DOMParser to parse the HTML safely
|
|
3319
|
-
const parser = new DOMParser();
|
|
3320
|
-
const doc = parser.parseFromString(html, "text/html");
|
|
3321
|
-
sanitizeNode(doc.body);
|
|
3322
|
-
return doc.body.innerHTML;
|
|
3323
|
-
}
|
|
3324
|
-
/** Recursively sanitize a DOM node. */
|
|
3325
|
-
function sanitizeNode(node) {
|
|
3326
|
-
const childrenToRemove = [];
|
|
3327
|
-
node.childNodes.forEach((child) => {
|
|
3328
|
-
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
3329
|
-
const el = child;
|
|
3330
|
-
const tag = el.tagName.toLowerCase();
|
|
3331
|
-
// Remove dangerous tags entirely
|
|
3332
|
-
if (REMOVE_TAGS.has(tag)) {
|
|
3333
|
-
childrenToRemove.push(child);
|
|
3334
|
-
return;
|
|
3335
|
-
}
|
|
3336
|
-
// Remove dangerous attributes
|
|
3337
|
-
const attrsToRemove = [];
|
|
3338
|
-
for (let i = 0; i < el.attributes.length; i++) {
|
|
3339
|
-
const attr = el.attributes[i];
|
|
3340
|
-
const name = attr.name.toLowerCase();
|
|
3341
|
-
if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
|
|
3342
|
-
attrsToRemove.push(attr.name);
|
|
3343
|
-
continue;
|
|
3344
|
-
}
|
|
3345
|
-
// Validate URL attributes
|
|
3346
|
-
if (name === "href" || name === "src" || name === "action") {
|
|
3347
|
-
const value = attr.value.trim();
|
|
3348
|
-
if (value && !ALLOWED_SCHEMES.test(value)) {
|
|
3349
|
-
attrsToRemove.push(attr.name);
|
|
3350
|
-
}
|
|
3351
|
-
}
|
|
3352
|
-
// Remove javascript: in any attribute value
|
|
3353
|
-
if (attr.value.toLowerCase().includes("javascript:")) {
|
|
3354
|
-
attrsToRemove.push(attr.name);
|
|
3355
|
-
}
|
|
3356
|
-
}
|
|
3357
|
-
attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
|
|
3358
|
-
// Recurse into children
|
|
3359
|
-
sanitizeNode(child);
|
|
3360
|
-
}
|
|
3361
|
-
});
|
|
3362
|
-
// Remove marked children
|
|
3363
|
-
childrenToRemove.forEach((child) => {
|
|
3364
|
-
node.removeChild(child);
|
|
3365
|
-
});
|
|
3366
|
-
}
|
|
3367
|
-
|
|
3368
3505
|
const alignmentLabels = {
|
|
3369
3506
|
left: "Left",
|
|
3370
3507
|
center: "Center",
|
|
@@ -3582,9 +3719,9 @@ const FloatingLinkEditor = ({ linkData: initialData, options, anchorRect, isEdit
|
|
|
3582
3719
|
const urlDisabledByCustom = customFields.some((f) => f.disablesUrl && !!data.custom[f.key]);
|
|
3583
3720
|
const hasAdvancedSection = options.enableTarget || customFields.length > 0;
|
|
3584
3721
|
const pos = computePosition(anchorRect);
|
|
3585
|
-
return reactDom.createPortal(jsxRuntime.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: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxRuntime.jsxs("div", { className: "rte-link-dialog-field", children: [jsxRuntime.jsx("label", { className: "rte-link-dialog-label", children: "URL" }), jsxRuntime.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 && (jsxRuntime.jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxRuntime.jsxs("span", { children: [showAdvanced ? "▾" : "▸", "
|
|
3722
|
+
return reactDom.createPortal(jsxRuntime.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: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxRuntime.jsxs("div", { className: "rte-link-dialog-field", children: [jsxRuntime.jsx("label", { className: "rte-link-dialog-label", children: "URL" }), jsxRuntime.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 && (jsxRuntime.jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxRuntime.jsxs("span", { children: [showAdvanced ? "▾" : "▸", " Advanced"] }) }), showAdvanced && (jsxRuntime.jsxs("div", { className: "rte-link-dialog-advanced", children: [customFields.map((field) => (jsxRuntime.jsxs("div", { className: "rte-link-dialog-field", children: [jsxRuntime.jsx("label", { className: "rte-link-dialog-label", children: field.label }), jsxRuntime.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 && (jsxRuntime.jsxs("label", { className: "rte-link-dialog-checkbox-row", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: data.target === "_blank", onChange: (e) => setField("target", e.target.checked
|
|
3586
3723
|
? "_blank"
|
|
3587
|
-
: "_self") }), jsxRuntime.jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxRuntime.jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "
|
|
3724
|
+
: "_self") }), jsxRuntime.jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxRuntime.jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Remove" })), jsxRuntime.jsx("div", { style: { flex: 1 } }), jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn", onMouseDown: (e) => e.preventDefault(), onClick: onClose, children: "Cancel" }), jsxRuntime.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);
|
|
3588
3725
|
};
|
|
3589
3726
|
/* ══════════════════════════════════════════════════════════════════════════
|
|
3590
3727
|
Helpers
|
|
@@ -3631,6 +3768,9 @@ function applyLinkData(link, data, customFields) {
|
|
|
3631
3768
|
href += data.custom[field.key];
|
|
3632
3769
|
}
|
|
3633
3770
|
}
|
|
3771
|
+
// Validate URL before setting href to prevent javascript: / data: injection
|
|
3772
|
+
if (!isUrlSafe(href))
|
|
3773
|
+
return;
|
|
3634
3774
|
link.setAttribute("href", href);
|
|
3635
3775
|
// Target
|
|
3636
3776
|
if (data.target && data.target !== "_self") {
|
|
@@ -3706,7 +3846,7 @@ const LinkToolbarButton = (props) => {
|
|
|
3706
3846
|
e.preventDefault();
|
|
3707
3847
|
e.stopPropagation();
|
|
3708
3848
|
const href = link.getAttribute("href");
|
|
3709
|
-
if (href)
|
|
3849
|
+
if (href && isUrlSafe(href))
|
|
3710
3850
|
window.open(href, "_blank");
|
|
3711
3851
|
return;
|
|
3712
3852
|
}
|
|
@@ -3746,6 +3886,8 @@ const LinkToolbarButton = (props) => {
|
|
|
3746
3886
|
href += data.custom[field.key];
|
|
3747
3887
|
}
|
|
3748
3888
|
}
|
|
3889
|
+
if (!isUrlSafe(href))
|
|
3890
|
+
return;
|
|
3749
3891
|
document.execCommand("createLink", false, href);
|
|
3750
3892
|
const newLink = getSelectedLink();
|
|
3751
3893
|
if (newLink) {
|
|
@@ -4309,7 +4451,7 @@ const LinkTooltip = ({ editorElement }) => {
|
|
|
4309
4451
|
scheduleHide();
|
|
4310
4452
|
};
|
|
4311
4453
|
const handleOpen = () => {
|
|
4312
|
-
if (state.href) {
|
|
4454
|
+
if (state.href && isUrlSafe(state.href)) {
|
|
4313
4455
|
window.open(state.href, "_blank", "noopener,noreferrer");
|
|
4314
4456
|
}
|
|
4315
4457
|
};
|
|
@@ -4335,14 +4477,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4335
4477
|
const toolbarRef = React.useRef(null);
|
|
4336
4478
|
React.useEffect(() => {
|
|
4337
4479
|
setIsClient(true);
|
|
4480
|
+
const timeoutIds = [];
|
|
4338
4481
|
const handleSelectionChange = () => {
|
|
4339
4482
|
setUpdateTrigger((prev) => prev + 1);
|
|
4340
4483
|
};
|
|
4341
4484
|
const handleMouseUp = () => {
|
|
4342
|
-
setTimeout(handleSelectionChange, 10);
|
|
4485
|
+
timeoutIds.push(setTimeout(handleSelectionChange, 10));
|
|
4343
4486
|
};
|
|
4344
4487
|
const handleKeyUp = () => {
|
|
4345
|
-
setTimeout(handleSelectionChange, 10);
|
|
4488
|
+
timeoutIds.push(setTimeout(handleSelectionChange, 10));
|
|
4346
4489
|
};
|
|
4347
4490
|
if (typeof document !== 'undefined') {
|
|
4348
4491
|
document.addEventListener("selectionchange", handleSelectionChange);
|
|
@@ -4350,6 +4493,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4350
4493
|
document.addEventListener("keyup", handleKeyUp);
|
|
4351
4494
|
}
|
|
4352
4495
|
return () => {
|
|
4496
|
+
timeoutIds.forEach(clearTimeout);
|
|
4353
4497
|
if (typeof document !== 'undefined') {
|
|
4354
4498
|
document.removeEventListener("selectionchange", handleSelectionChange);
|
|
4355
4499
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
@@ -4375,6 +4519,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4375
4519
|
const clearFormattingPlugin = plugins.find((p) => p.name === "clearFormatting");
|
|
4376
4520
|
// Roving tabindex keyboard navigation (ARIA toolbar pattern)
|
|
4377
4521
|
const handleToolbarKeyDown = React.useCallback((e) => {
|
|
4522
|
+
// Escape: return focus to the editor
|
|
4523
|
+
if (e.key === "Escape") {
|
|
4524
|
+
e.preventDefault();
|
|
4525
|
+
const container = toolbarRef.current?.closest(".rte-container");
|
|
4526
|
+
const editor = container?.querySelector(".rte-editor");
|
|
4527
|
+
if (editor)
|
|
4528
|
+
editor.focus();
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4378
4531
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight" && e.key !== "Home" && e.key !== "End")
|
|
4379
4532
|
return;
|
|
4380
4533
|
const toolbar = toolbarRef.current;
|
|
@@ -4406,7 +4559,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4406
4559
|
}
|
|
4407
4560
|
buttons[nextIndex].focus();
|
|
4408
4561
|
}, []);
|
|
4409
|
-
return (jsxRuntime.jsxs("div", { ref: toolbarRef, className: `rte-toolbar rte-toolbar-sticky ${className || ""}`, onMouseDown: (e) => e.preventDefault(), onKeyDown: handleToolbarKeyDown, role: "toolbar", "aria-label": "Text formatting", children: [jsxRuntime.jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
|
|
4562
|
+
return (jsxRuntime.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: [jsxRuntime.jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
|
|
4410
4563
|
if (!plugin.renderButton)
|
|
4411
4564
|
return null;
|
|
4412
4565
|
const isActive = isClient && plugin.isActive
|
|
@@ -4449,6 +4602,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4449
4602
|
const editorRef = React.useRef(null);
|
|
4450
4603
|
const historyRef = React.useRef(new HistoryManager());
|
|
4451
4604
|
const isUpdatingRef = React.useRef(false);
|
|
4605
|
+
const mountedRef = React.useRef(true);
|
|
4606
|
+
// Track mount status to guard async callbacks
|
|
4607
|
+
React.useEffect(() => {
|
|
4608
|
+
mountedRef.current = true;
|
|
4609
|
+
return () => {
|
|
4610
|
+
mountedRef.current = false;
|
|
4611
|
+
};
|
|
4612
|
+
}, []);
|
|
4452
4613
|
// --- Plugins ---
|
|
4453
4614
|
const plugins = React.useMemo(() => {
|
|
4454
4615
|
// When settings is provided and plugins is not, use buildPluginsFromSettings
|
|
@@ -4459,7 +4620,12 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4459
4620
|
};
|
|
4460
4621
|
return buildPluginsFromSettings(settings, opts);
|
|
4461
4622
|
}
|
|
4462
|
-
|
|
4623
|
+
// When plugins are explicitly provided, use them as-is
|
|
4624
|
+
if (providedPlugins) {
|
|
4625
|
+
return [...providedPlugins];
|
|
4626
|
+
}
|
|
4627
|
+
// Default plugins path: apply shorthand customizations
|
|
4628
|
+
const allPlugins = [...defaultPlugins];
|
|
4463
4629
|
if (headings && headings.length > 0) {
|
|
4464
4630
|
const blockFormatIndex = allPlugins.findIndex((p) => p.name === "blockFormat");
|
|
4465
4631
|
if (blockFormatIndex !== -1) {
|
|
@@ -4574,12 +4740,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4574
4740
|
return checkbox.insertCheckboxList(editor);
|
|
4575
4741
|
}
|
|
4576
4742
|
if (command === "insertImage" && value) {
|
|
4577
|
-
return handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange);
|
|
4743
|
+
return handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
4578
4744
|
}
|
|
4579
4745
|
// General commands via document.execCommand
|
|
4580
4746
|
ensureEditorFocused(editor);
|
|
4581
4747
|
document.execCommand(command, false, value);
|
|
4582
4748
|
setTimeout(() => {
|
|
4749
|
+
if (!mountedRef.current)
|
|
4750
|
+
return;
|
|
4583
4751
|
if (editor && !isUpdatingRef.current) {
|
|
4584
4752
|
ensureAllCheckboxes(editor);
|
|
4585
4753
|
const content = domToContent(editor);
|
|
@@ -4620,6 +4788,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4620
4788
|
const block = document.createElement(type);
|
|
4621
4789
|
if (attributes) {
|
|
4622
4790
|
Object.entries(attributes).forEach(([key, val]) => {
|
|
4791
|
+
// Filter out event handler attributes
|
|
4792
|
+
if (key.toLowerCase().startsWith("on"))
|
|
4793
|
+
return;
|
|
4623
4794
|
block.setAttribute(key, val);
|
|
4624
4795
|
});
|
|
4625
4796
|
}
|
|
@@ -4642,6 +4813,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4642
4813
|
const inline = document.createElement(type);
|
|
4643
4814
|
if (attributes) {
|
|
4644
4815
|
Object.entries(attributes).forEach(([key, val]) => {
|
|
4816
|
+
// Filter out event handler attributes
|
|
4817
|
+
if (key.toLowerCase().startsWith("on"))
|
|
4818
|
+
return;
|
|
4645
4819
|
inline.setAttribute(key, val);
|
|
4646
4820
|
});
|
|
4647
4821
|
}
|
|
@@ -4728,6 +4902,8 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4728
4902
|
historyRef.current.push(currentContent, sel);
|
|
4729
4903
|
operation(selection);
|
|
4730
4904
|
setTimeout(() => {
|
|
4905
|
+
if (!mountedRef.current)
|
|
4906
|
+
return;
|
|
4731
4907
|
if (editor)
|
|
4732
4908
|
notifyChange(domToContent(editor));
|
|
4733
4909
|
}, 0);
|
|
@@ -4754,6 +4930,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4754
4930
|
editorRef,
|
|
4755
4931
|
historyRef,
|
|
4756
4932
|
isUpdatingRef,
|
|
4933
|
+
mountedRef,
|
|
4757
4934
|
notifyChange,
|
|
4758
4935
|
handleCheckboxKeyDown: checkbox.handleCheckboxKeyDown,
|
|
4759
4936
|
handleCheckboxEnter: checkbox.handleCheckboxEnter,
|
|
@@ -4850,6 +5027,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4850
5027
|
}
|
|
4851
5028
|
// Upload
|
|
4852
5029
|
const url = await onImageUpload(file);
|
|
5030
|
+
// Guard against unmount during async upload
|
|
5031
|
+
if (!mountedRef.current)
|
|
5032
|
+
return;
|
|
4853
5033
|
// Parse the "url|__aid__:attachmentId" convention
|
|
4854
5034
|
let realUrl = url;
|
|
4855
5035
|
if (url.includes("|__aid__:")) {
|
|
@@ -4860,6 +5040,11 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4860
5040
|
placeholder.setAttribute("data-attachment-id", attachmentId);
|
|
4861
5041
|
}
|
|
4862
5042
|
}
|
|
5043
|
+
// Validate the returned URL before setting it
|
|
5044
|
+
if (!isUrlSafe(realUrl) && !realUrl.startsWith("data:image/")) {
|
|
5045
|
+
placeholder.remove();
|
|
5046
|
+
return;
|
|
5047
|
+
}
|
|
4863
5048
|
// Replace placeholder with final image
|
|
4864
5049
|
placeholder.src = realUrl;
|
|
4865
5050
|
placeholder.removeAttribute("data-uploading");
|
|
@@ -4980,7 +5165,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4980
5165
|
}, suppressContentEditableWarning: true }), !readOnly && (jsxRuntime.jsx(FloatingToolbar, { plugins: plugins, editorAPI: editorAPI, editorElement: editorRef.current })), jsxRuntime.jsx(LinkTooltip, { editorElement: editorRef.current }), showWordCount && (jsxRuntime.jsxs("div", { className: "rte-word-count", children: [wordCount.words, " words \u00B7 ", wordCount.characters, " characters"] }))] }));
|
|
4981
5166
|
};
|
|
4982
5167
|
// --- Helper: Insert Image ---
|
|
4983
|
-
function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange) {
|
|
5168
|
+
function handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange) {
|
|
4984
5169
|
let selection = window.getSelection();
|
|
4985
5170
|
if (!selection)
|
|
4986
5171
|
return false;
|
|
@@ -5002,7 +5187,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5002
5187
|
newRange.collapse(true);
|
|
5003
5188
|
selection.removeAllRanges();
|
|
5004
5189
|
selection.addRange(newRange);
|
|
5005
|
-
saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
|
|
5190
|
+
saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
5006
5191
|
return true;
|
|
5007
5192
|
}
|
|
5008
5193
|
selection.removeAllRanges();
|
|
@@ -5043,7 +5228,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5043
5228
|
newRange.collapse(true);
|
|
5044
5229
|
selection.removeAllRanges();
|
|
5045
5230
|
selection.addRange(newRange);
|
|
5046
|
-
saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
|
|
5231
|
+
saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
5047
5232
|
return true;
|
|
5048
5233
|
}
|
|
5049
5234
|
/**
|
|
@@ -5057,23 +5242,36 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5057
5242
|
function createImageElement(src) {
|
|
5058
5243
|
const img = document.createElement("img");
|
|
5059
5244
|
let realSrc = src;
|
|
5245
|
+
let altText = "";
|
|
5246
|
+
// Parse the "url|__alt__:altText" convention
|
|
5247
|
+
if (realSrc.includes("|__alt__:")) {
|
|
5248
|
+
const altIdx = realSrc.indexOf("|__alt__:");
|
|
5249
|
+
altText = realSrc.substring(altIdx + "|__alt__:".length);
|
|
5250
|
+
realSrc = realSrc.substring(0, altIdx);
|
|
5251
|
+
}
|
|
5060
5252
|
// Parse the "url|__aid__:attachmentId" convention
|
|
5061
|
-
if (
|
|
5062
|
-
const idx =
|
|
5063
|
-
|
|
5064
|
-
|
|
5253
|
+
if (realSrc.includes("|__aid__:")) {
|
|
5254
|
+
const idx = realSrc.indexOf("|__aid__:");
|
|
5255
|
+
const attachmentId = realSrc.substring(idx + "|__aid__:".length);
|
|
5256
|
+
realSrc = realSrc.substring(0, idx);
|
|
5065
5257
|
if (attachmentId) {
|
|
5066
5258
|
img.setAttribute("data-attachment-id", attachmentId);
|
|
5067
5259
|
}
|
|
5068
5260
|
}
|
|
5261
|
+
// Validate URL safety — block javascript:, data:, etc.
|
|
5262
|
+
if (!isUrlSafe(realSrc) && !realSrc.startsWith("data:image/")) {
|
|
5263
|
+
realSrc = "";
|
|
5264
|
+
}
|
|
5069
5265
|
img.setAttribute("src", realSrc);
|
|
5070
|
-
img.setAttribute("alt",
|
|
5266
|
+
img.setAttribute("alt", altText);
|
|
5071
5267
|
img.className = "rte-image";
|
|
5072
5268
|
return img;
|
|
5073
5269
|
}
|
|
5074
|
-
function saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange) {
|
|
5270
|
+
function saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange) {
|
|
5075
5271
|
isUpdatingRef.current = true;
|
|
5076
5272
|
setTimeout(() => {
|
|
5273
|
+
if (!mountedRef.current)
|
|
5274
|
+
return;
|
|
5077
5275
|
const content = domToContent(editor);
|
|
5078
5276
|
const sel = serializeSelection(editor);
|
|
5079
5277
|
historyRef.current.push(content, sel);
|
|
@@ -5203,14 +5401,14 @@ function createLinkPlugin() {
|
|
|
5203
5401
|
// Check if a link already exists
|
|
5204
5402
|
const existingLink = element?.closest("a");
|
|
5205
5403
|
if (existingLink) {
|
|
5206
|
-
//
|
|
5404
|
+
// Remove link
|
|
5207
5405
|
const parent = existingLink.parentNode;
|
|
5208
5406
|
if (parent) {
|
|
5209
5407
|
while (existingLink.firstChild) {
|
|
5210
5408
|
parent.insertBefore(existingLink.firstChild, existingLink);
|
|
5211
5409
|
}
|
|
5212
5410
|
parent.removeChild(existingLink);
|
|
5213
|
-
//
|
|
5411
|
+
// Update content
|
|
5214
5412
|
const editorEl = editor.getSelection()?.anchorNode;
|
|
5215
5413
|
if (editorEl) {
|
|
5216
5414
|
const content = editor.getContent();
|
|
@@ -5221,7 +5419,7 @@ function createLinkPlugin() {
|
|
|
5221
5419
|
else {
|
|
5222
5420
|
// Insert new link
|
|
5223
5421
|
const url = prompt("Enter URL:");
|
|
5224
|
-
if (url) {
|
|
5422
|
+
if (url && isUrlSafe(url)) {
|
|
5225
5423
|
editor.executeCommand("createLink", url);
|
|
5226
5424
|
}
|
|
5227
5425
|
}
|
|
@@ -5388,6 +5586,7 @@ exports.htmlToContent = htmlToContent;
|
|
|
5388
5586
|
exports.indentListItem = indentListItem;
|
|
5389
5587
|
exports.indentListItemPlugin = indentListItemPlugin;
|
|
5390
5588
|
exports.isCheckboxList = isCheckboxList;
|
|
5589
|
+
exports.isUrlSafe = isUrlSafe;
|
|
5391
5590
|
exports.italicPlugin = italicPlugin;
|
|
5392
5591
|
exports.linkPlugin = linkPlugin;
|
|
5393
5592
|
exports.orderedListPlugin = orderedListPlugin;
|