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