@overlap/rte 0.1.11 → 1.0.2
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 +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +353 -151
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +353 -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/dist/utils/settings.d.ts +1 -0
- package/dist/utils/settings.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) {
|
|
@@ -3976,6 +4118,7 @@ const defaultEditorSettings = {
|
|
|
3976
4118
|
numberedList: true,
|
|
3977
4119
|
quote: true,
|
|
3978
4120
|
codeBlock: true,
|
|
4121
|
+
horizontalRule: true,
|
|
3979
4122
|
check: true,
|
|
3980
4123
|
typography: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
|
3981
4124
|
colors: [
|
|
@@ -4054,6 +4197,9 @@ function buildPluginsFromSettings(settings = defaultEditorSettings, options = {}
|
|
|
4054
4197
|
plugins.push(subscriptPlugin);
|
|
4055
4198
|
if (fmt.superscript)
|
|
4056
4199
|
plugins.push(superscriptPlugin);
|
|
4200
|
+
// Horizontal rule
|
|
4201
|
+
if (fmt.horizontalRule)
|
|
4202
|
+
plugins.push(horizontalRulePlugin);
|
|
4057
4203
|
// Table
|
|
4058
4204
|
if (settings.table?.enabled) {
|
|
4059
4205
|
plugins.push(tablePlugin);
|
|
@@ -4309,7 +4455,7 @@ const LinkTooltip = ({ editorElement }) => {
|
|
|
4309
4455
|
scheduleHide();
|
|
4310
4456
|
};
|
|
4311
4457
|
const handleOpen = () => {
|
|
4312
|
-
if (state.href) {
|
|
4458
|
+
if (state.href && isUrlSafe(state.href)) {
|
|
4313
4459
|
window.open(state.href, "_blank", "noopener,noreferrer");
|
|
4314
4460
|
}
|
|
4315
4461
|
};
|
|
@@ -4335,14 +4481,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4335
4481
|
const toolbarRef = React.useRef(null);
|
|
4336
4482
|
React.useEffect(() => {
|
|
4337
4483
|
setIsClient(true);
|
|
4484
|
+
const timeoutIds = [];
|
|
4338
4485
|
const handleSelectionChange = () => {
|
|
4339
4486
|
setUpdateTrigger((prev) => prev + 1);
|
|
4340
4487
|
};
|
|
4341
4488
|
const handleMouseUp = () => {
|
|
4342
|
-
setTimeout(handleSelectionChange, 10);
|
|
4489
|
+
timeoutIds.push(setTimeout(handleSelectionChange, 10));
|
|
4343
4490
|
};
|
|
4344
4491
|
const handleKeyUp = () => {
|
|
4345
|
-
setTimeout(handleSelectionChange, 10);
|
|
4492
|
+
timeoutIds.push(setTimeout(handleSelectionChange, 10));
|
|
4346
4493
|
};
|
|
4347
4494
|
if (typeof document !== 'undefined') {
|
|
4348
4495
|
document.addEventListener("selectionchange", handleSelectionChange);
|
|
@@ -4350,6 +4497,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4350
4497
|
document.addEventListener("keyup", handleKeyUp);
|
|
4351
4498
|
}
|
|
4352
4499
|
return () => {
|
|
4500
|
+
timeoutIds.forEach(clearTimeout);
|
|
4353
4501
|
if (typeof document !== 'undefined') {
|
|
4354
4502
|
document.removeEventListener("selectionchange", handleSelectionChange);
|
|
4355
4503
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
@@ -4375,6 +4523,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4375
4523
|
const clearFormattingPlugin = plugins.find((p) => p.name === "clearFormatting");
|
|
4376
4524
|
// Roving tabindex keyboard navigation (ARIA toolbar pattern)
|
|
4377
4525
|
const handleToolbarKeyDown = React.useCallback((e) => {
|
|
4526
|
+
// Escape: return focus to the editor
|
|
4527
|
+
if (e.key === "Escape") {
|
|
4528
|
+
e.preventDefault();
|
|
4529
|
+
const container = toolbarRef.current?.closest(".rte-container");
|
|
4530
|
+
const editor = container?.querySelector(".rte-editor");
|
|
4531
|
+
if (editor)
|
|
4532
|
+
editor.focus();
|
|
4533
|
+
return;
|
|
4534
|
+
}
|
|
4378
4535
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight" && e.key !== "Home" && e.key !== "End")
|
|
4379
4536
|
return;
|
|
4380
4537
|
const toolbar = toolbarRef.current;
|
|
@@ -4406,7 +4563,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
|
|
|
4406
4563
|
}
|
|
4407
4564
|
buttons[nextIndex].focus();
|
|
4408
4565
|
}, []);
|
|
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) => {
|
|
4566
|
+
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
4567
|
if (!plugin.renderButton)
|
|
4411
4568
|
return null;
|
|
4412
4569
|
const isActive = isClient && plugin.isActive
|
|
@@ -4449,6 +4606,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4449
4606
|
const editorRef = React.useRef(null);
|
|
4450
4607
|
const historyRef = React.useRef(new HistoryManager());
|
|
4451
4608
|
const isUpdatingRef = React.useRef(false);
|
|
4609
|
+
const mountedRef = React.useRef(true);
|
|
4610
|
+
// Track mount status to guard async callbacks
|
|
4611
|
+
React.useEffect(() => {
|
|
4612
|
+
mountedRef.current = true;
|
|
4613
|
+
return () => {
|
|
4614
|
+
mountedRef.current = false;
|
|
4615
|
+
};
|
|
4616
|
+
}, []);
|
|
4452
4617
|
// --- Plugins ---
|
|
4453
4618
|
const plugins = React.useMemo(() => {
|
|
4454
4619
|
// When settings is provided and plugins is not, use buildPluginsFromSettings
|
|
@@ -4459,7 +4624,12 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4459
4624
|
};
|
|
4460
4625
|
return buildPluginsFromSettings(settings, opts);
|
|
4461
4626
|
}
|
|
4462
|
-
|
|
4627
|
+
// When plugins are explicitly provided, use them as-is
|
|
4628
|
+
if (providedPlugins) {
|
|
4629
|
+
return [...providedPlugins];
|
|
4630
|
+
}
|
|
4631
|
+
// Default plugins path: apply shorthand customizations
|
|
4632
|
+
const allPlugins = [...defaultPlugins];
|
|
4463
4633
|
if (headings && headings.length > 0) {
|
|
4464
4634
|
const blockFormatIndex = allPlugins.findIndex((p) => p.name === "blockFormat");
|
|
4465
4635
|
if (blockFormatIndex !== -1) {
|
|
@@ -4574,12 +4744,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4574
4744
|
return checkbox.insertCheckboxList(editor);
|
|
4575
4745
|
}
|
|
4576
4746
|
if (command === "insertImage" && value) {
|
|
4577
|
-
return handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange);
|
|
4747
|
+
return handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
4578
4748
|
}
|
|
4579
4749
|
// General commands via document.execCommand
|
|
4580
4750
|
ensureEditorFocused(editor);
|
|
4581
4751
|
document.execCommand(command, false, value);
|
|
4582
4752
|
setTimeout(() => {
|
|
4753
|
+
if (!mountedRef.current)
|
|
4754
|
+
return;
|
|
4583
4755
|
if (editor && !isUpdatingRef.current) {
|
|
4584
4756
|
ensureAllCheckboxes(editor);
|
|
4585
4757
|
const content = domToContent(editor);
|
|
@@ -4620,6 +4792,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4620
4792
|
const block = document.createElement(type);
|
|
4621
4793
|
if (attributes) {
|
|
4622
4794
|
Object.entries(attributes).forEach(([key, val]) => {
|
|
4795
|
+
// Filter out event handler attributes
|
|
4796
|
+
if (key.toLowerCase().startsWith("on"))
|
|
4797
|
+
return;
|
|
4623
4798
|
block.setAttribute(key, val);
|
|
4624
4799
|
});
|
|
4625
4800
|
}
|
|
@@ -4642,6 +4817,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4642
4817
|
const inline = document.createElement(type);
|
|
4643
4818
|
if (attributes) {
|
|
4644
4819
|
Object.entries(attributes).forEach(([key, val]) => {
|
|
4820
|
+
// Filter out event handler attributes
|
|
4821
|
+
if (key.toLowerCase().startsWith("on"))
|
|
4822
|
+
return;
|
|
4645
4823
|
inline.setAttribute(key, val);
|
|
4646
4824
|
});
|
|
4647
4825
|
}
|
|
@@ -4728,6 +4906,8 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4728
4906
|
historyRef.current.push(currentContent, sel);
|
|
4729
4907
|
operation(selection);
|
|
4730
4908
|
setTimeout(() => {
|
|
4909
|
+
if (!mountedRef.current)
|
|
4910
|
+
return;
|
|
4731
4911
|
if (editor)
|
|
4732
4912
|
notifyChange(domToContent(editor));
|
|
4733
4913
|
}, 0);
|
|
@@ -4754,6 +4934,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4754
4934
|
editorRef,
|
|
4755
4935
|
historyRef,
|
|
4756
4936
|
isUpdatingRef,
|
|
4937
|
+
mountedRef,
|
|
4757
4938
|
notifyChange,
|
|
4758
4939
|
handleCheckboxKeyDown: checkbox.handleCheckboxKeyDown,
|
|
4759
4940
|
handleCheckboxEnter: checkbox.handleCheckboxEnter,
|
|
@@ -4850,6 +5031,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4850
5031
|
}
|
|
4851
5032
|
// Upload
|
|
4852
5033
|
const url = await onImageUpload(file);
|
|
5034
|
+
// Guard against unmount during async upload
|
|
5035
|
+
if (!mountedRef.current)
|
|
5036
|
+
return;
|
|
4853
5037
|
// Parse the "url|__aid__:attachmentId" convention
|
|
4854
5038
|
let realUrl = url;
|
|
4855
5039
|
if (url.includes("|__aid__:")) {
|
|
@@ -4860,6 +5044,11 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4860
5044
|
placeholder.setAttribute("data-attachment-id", attachmentId);
|
|
4861
5045
|
}
|
|
4862
5046
|
}
|
|
5047
|
+
// Validate the returned URL before setting it
|
|
5048
|
+
if (!isUrlSafe(realUrl) && !realUrl.startsWith("data:image/")) {
|
|
5049
|
+
placeholder.remove();
|
|
5050
|
+
return;
|
|
5051
|
+
}
|
|
4863
5052
|
// Replace placeholder with final image
|
|
4864
5053
|
placeholder.src = realUrl;
|
|
4865
5054
|
placeholder.removeAttribute("data-uploading");
|
|
@@ -4980,7 +5169,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
|
|
|
4980
5169
|
}, 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
5170
|
};
|
|
4982
5171
|
// --- Helper: Insert Image ---
|
|
4983
|
-
function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange) {
|
|
5172
|
+
function handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange) {
|
|
4984
5173
|
let selection = window.getSelection();
|
|
4985
5174
|
if (!selection)
|
|
4986
5175
|
return false;
|
|
@@ -5002,7 +5191,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5002
5191
|
newRange.collapse(true);
|
|
5003
5192
|
selection.removeAllRanges();
|
|
5004
5193
|
selection.addRange(newRange);
|
|
5005
|
-
saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
|
|
5194
|
+
saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
5006
5195
|
return true;
|
|
5007
5196
|
}
|
|
5008
5197
|
selection.removeAllRanges();
|
|
@@ -5043,7 +5232,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5043
5232
|
newRange.collapse(true);
|
|
5044
5233
|
selection.removeAllRanges();
|
|
5045
5234
|
selection.addRange(newRange);
|
|
5046
|
-
saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
|
|
5235
|
+
saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
|
|
5047
5236
|
return true;
|
|
5048
5237
|
}
|
|
5049
5238
|
/**
|
|
@@ -5057,23 +5246,36 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
|
|
|
5057
5246
|
function createImageElement(src) {
|
|
5058
5247
|
const img = document.createElement("img");
|
|
5059
5248
|
let realSrc = src;
|
|
5249
|
+
let altText = "";
|
|
5250
|
+
// Parse the "url|__alt__:altText" convention
|
|
5251
|
+
if (realSrc.includes("|__alt__:")) {
|
|
5252
|
+
const altIdx = realSrc.indexOf("|__alt__:");
|
|
5253
|
+
altText = realSrc.substring(altIdx + "|__alt__:".length);
|
|
5254
|
+
realSrc = realSrc.substring(0, altIdx);
|
|
5255
|
+
}
|
|
5060
5256
|
// Parse the "url|__aid__:attachmentId" convention
|
|
5061
|
-
if (
|
|
5062
|
-
const idx =
|
|
5063
|
-
|
|
5064
|
-
|
|
5257
|
+
if (realSrc.includes("|__aid__:")) {
|
|
5258
|
+
const idx = realSrc.indexOf("|__aid__:");
|
|
5259
|
+
const attachmentId = realSrc.substring(idx + "|__aid__:".length);
|
|
5260
|
+
realSrc = realSrc.substring(0, idx);
|
|
5065
5261
|
if (attachmentId) {
|
|
5066
5262
|
img.setAttribute("data-attachment-id", attachmentId);
|
|
5067
5263
|
}
|
|
5068
5264
|
}
|
|
5265
|
+
// Validate URL safety — block javascript:, data:, etc.
|
|
5266
|
+
if (!isUrlSafe(realSrc) && !realSrc.startsWith("data:image/")) {
|
|
5267
|
+
realSrc = "";
|
|
5268
|
+
}
|
|
5069
5269
|
img.setAttribute("src", realSrc);
|
|
5070
|
-
img.setAttribute("alt",
|
|
5270
|
+
img.setAttribute("alt", altText);
|
|
5071
5271
|
img.className = "rte-image";
|
|
5072
5272
|
return img;
|
|
5073
5273
|
}
|
|
5074
|
-
function saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange) {
|
|
5274
|
+
function saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange) {
|
|
5075
5275
|
isUpdatingRef.current = true;
|
|
5076
5276
|
setTimeout(() => {
|
|
5277
|
+
if (!mountedRef.current)
|
|
5278
|
+
return;
|
|
5077
5279
|
const content = domToContent(editor);
|
|
5078
5280
|
const sel = serializeSelection(editor);
|
|
5079
5281
|
historyRef.current.push(content, sel);
|
|
@@ -5203,14 +5405,14 @@ function createLinkPlugin() {
|
|
|
5203
5405
|
// Check if a link already exists
|
|
5204
5406
|
const existingLink = element?.closest("a");
|
|
5205
5407
|
if (existingLink) {
|
|
5206
|
-
//
|
|
5408
|
+
// Remove link
|
|
5207
5409
|
const parent = existingLink.parentNode;
|
|
5208
5410
|
if (parent) {
|
|
5209
5411
|
while (existingLink.firstChild) {
|
|
5210
5412
|
parent.insertBefore(existingLink.firstChild, existingLink);
|
|
5211
5413
|
}
|
|
5212
5414
|
parent.removeChild(existingLink);
|
|
5213
|
-
//
|
|
5415
|
+
// Update content
|
|
5214
5416
|
const editorEl = editor.getSelection()?.anchorNode;
|
|
5215
5417
|
if (editorEl) {
|
|
5216
5418
|
const content = editor.getContent();
|
|
@@ -5221,7 +5423,7 @@ function createLinkPlugin() {
|
|
|
5221
5423
|
else {
|
|
5222
5424
|
// Insert new link
|
|
5223
5425
|
const url = prompt("Enter URL:");
|
|
5224
|
-
if (url) {
|
|
5426
|
+
if (url && isUrlSafe(url)) {
|
|
5225
5427
|
editor.executeCommand("createLink", url);
|
|
5226
5428
|
}
|
|
5227
5429
|
}
|
|
@@ -5388,6 +5590,7 @@ exports.htmlToContent = htmlToContent;
|
|
|
5388
5590
|
exports.indentListItem = indentListItem;
|
|
5389
5591
|
exports.indentListItemPlugin = indentListItemPlugin;
|
|
5390
5592
|
exports.isCheckboxList = isCheckboxList;
|
|
5593
|
+
exports.isUrlSafe = isUrlSafe;
|
|
5391
5594
|
exports.italicPlugin = italicPlugin;
|
|
5392
5595
|
exports.linkPlugin = linkPlugin;
|
|
5393
5596
|
exports.orderedListPlugin = orderedListPlugin;
|