diff --git a/cp/appian-component-plugin.xml b/cp/appian-component-plugin.xml
index 95d8179..13dff81 100644
--- a/cp/appian-component-plugin.xml
+++ b/cp/appian-component-plugin.xml
@@ -4,7 +4,7 @@
A simple rich text editor
- 1.17.2
+ 1.18.0
2.0.0
diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js
index d24354b..f3280ae 100644
--- a/cp/richTextFieldWithTables/v1/index.js
+++ b/cp/richTextFieldWithTables/v1/index.js
@@ -49,15 +49,54 @@ summernote.on(
);
summernote.on("summernote.paste", function (we, e) {
e.preventDefault();
- /* Determine if clipboard contains an
tag.
- * If so - skip pasting images as it's handled by onImageUpload.
- */
- let clipboardHtml = readClipboard(e);
- if (clipboardHtml.indexOf("
]+src=["']https?:\/\//i.test(clipboardHtml)) {
return;
}
- handleImagePasteFromFile(e);
- summernote.summernote("pasteHTML", cleanHtml(clipboardHtml, true));
+
+ // Clear any newlines present in ordered lists from Word before the DOMParser splits the HTML into nodes and replaces them with
+ if (clipboardHtml.indexOf("mso-list") !== -1) {
+ var WORD_ORDERED_LIST_REGEX = /([\s\S]*?)/gi;
+ clipboardHtml = clipboardHtml.replace(WORD_ORDERED_LIST_REGEX, function (match, content) {
+ return content.replace(/\r?\n/g, "");
+ });
+ }
+
+ // Parse clipboard HTML into a DOM and iterate over top-level nodes
+ var parser = new DOMParser();
+ var doc = parser.parseFromString(clipboardHtml, "text/html");
+ var nodes = doc.body.childNodes;
+ var cleanedHtml = "";
+
+ nodes.forEach(function (node) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ var nodeHtml = node.outerHTML;
+ var cleaned = cleanHtml(nodeHtml, true);
+ cleaned = stripSummernoteDefaults(cleaned);
+ cleanedHtml += cleaned;
+ } else if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
+ cleanedHtml += node.textContent;
+ }
+ });
+
+ // Insert cleaned HTML at cursor position using insertNode to avoid splitting existing content
+ var insertParser = new DOMParser();
+ var insertDoc = insertParser.parseFromString(cleanedHtml, "text/html");
+ var insertNodes = Array.from(insertDoc.body.childNodes);
+
+ insertNodes.forEach(function (node) {
+ $("#summernote").summernote("insertNode", node);
+ });
+
+ // If the last inserted node was a table, add an empty paragraph after it so the cursor is below the table
+ var lastNode = insertNodes[insertNodes.length - 1];
+ if (lastNode && lastNode.nodeName.toLowerCase() === "table") {
+ var emptyPara = document.createElement("p");
+ emptyPara.innerHTML = "
";
+ summernote.summernote("editor.insertNode", emptyPara);
+ }
});
// After investigating, we determined that only these tags & attributes are necessary/supported in order to render all supported styles of the editor
@@ -616,9 +655,14 @@ function cleanHtml(html, isPartialHtml) {
out = out
// Word sometimes uses \r\n to represent a space
.replace(/\r\n/g, " ")
- .replace(/\n/g, "
")
+ // Remove newlines from within tag attributes, converting them to spaces so they don't become
tags
+ .replace(/<[^>]+>/g, function (tag) {
+ return tag.replace(/\n/g, " ");
+ })
// Remove whitespace between tags
.replace(/>\s+<")
+ // Convert any remaining newlines to
tags, these will only be newlines in actual text content at this point
+ .replace(/\n/g, "
")
// Remove Word-specific classes
.replace(/\sclass=["']?MsoNormal["']?/gi, "");
} else if (isPartialHtml && !isContentHtml) {
@@ -699,20 +743,79 @@ function cleanHtml(html, isPartialHtml) {
// END TEMPORARY REFACTOR FOR IE -- ABOVE WILL BE DELETED ONCE IE IS DEPRECATED
- // Step 5: Strip non-external links
+ // Step 5: Replace empty spans (introduce by paste event) with a space.
+ out = out.replace(/]*>\s*<\/span>/gi, " ");
+
+ // Step 6: Strip non-external links
// Any hyperlink that isn't to an external URL or file URL or mailto URL will not work as expected anyways, so this will strip those hyperlinks
// Test this Regex here: https://regexr.com/64iom
out = out.replace(/(.*?)<\/a>/g, function ($0, $1, $2) {
// Test this Regex here: https://regexr.com/6blub
- return $1.match(/^(?:[A-Za-z0-9+\-.]+:)?(?:https:\/\/|file:\/\/|mailto:).*$/g) ? $0 : $2;
+ return $1.match(/^(?:[A-Za-z0-9+\-.]+:)?(?:https:\/\/|file:(?:\/\/|\\\\)|mailto:).*$/g)
+ ? $0
+ : $2;
});
- // Step 6: Remove any HTML comments
+ // Step 7: Remove any HTML comments
out = out.replace(//g, "");
- // Step 7: Trim extra spaces
+ // Step 8: Trim extra spaces
out = out.trim().replace(/ +/g, " ");
+ // Step 9: Repair orphan table rows (Word paste)
+ if (/]/i.test(out) && !/]/i.test(out)) {
+ out = "";
+ }
+
+ return out;
+}
+
+/**
+ * Cleans an HTML string by removing default styles injected by Summernote and
+ * stripping out empty or redundant tags.
+ * @param {string} html - The HTML string to clean.
+ * @return {string} The cleaned HTML string.
+ */
+function stripSummernoteDefaults(html) {
+ if (!html) {
+ return "";
+ }
+
+ var out = html;
+
+ // 1. Clean all style attributes
+ out = out.replace(/style="([^"]*)"/g, function (match, styleContent) {
+ var cleaned = styleContent
+ .replace(/background-color:\s*rgb\(\s*255\s*,\s*255\s*,\s*255\s*\)\s*;?\s*/gi, "")
+ .replace(/font-size:\s*14px\s*;?\s*/gi, "")
+ .replace(/text-align:\s*start\s*;?\s*/gi, "")
+ .replace(/float:\s*none\s*;?\s*/gi, "")
+ .replace(/;\s*;+/g, ";")
+ .replace(/^\s*;+\s*/, "")
+ .replace(/\s*;+\s*$/, "")
+ .trim();
+
+ return cleaned ? 'style="' + cleaned + '"' : "";
+ });
+
+ // 2. Remove empty style attributes
+ out = out.replace(/\s*style=""\s*/g, "");
+
+ // 3. Clean up whitespace issues
+ out = out.replace(/\s+>/g, ">");
+ out = out.replace(/\s{2,}/g, " ");
+
+ // 4. Remove empty spans and unwrap attribute-less spans
+ var before;
+ do {
+ before = out;
+ out = out.replace(/]*>\s*<\/span>/g, "");
+ out = out.replace(/([\s\S]*?)<\/span>/g, "$1");
+ } while (before !== out);
+
+ // Final cleanup
+ out = out.replace(/\s+>/g, ">");
+
return out;
}
diff --git a/cp/tests/helpers/browserScriptTransform.js b/cp/tests/helpers/browserScriptTransform.js
index 280d228..09fec55 100644
--- a/cp/tests/helpers/browserScriptTransform.js
+++ b/cp/tests/helpers/browserScriptTransform.js
@@ -44,6 +44,7 @@ if (typeof module !== 'undefined' && module.exports) {
ALLOWED_STYLE_ATTRIBUTES: typeof ALLOWED_STYLE_ATTRIBUTES !== 'undefined' ? ALLOWED_STYLE_ATTRIBUTES : undefined,
MAX_SIZE_DEFAULT: typeof MAX_SIZE_DEFAULT !== 'undefined' ? MAX_SIZE_DEFAULT : undefined,
DISPLAY_PARAMS: typeof DISPLAY_PARAMS !== 'undefined' ? DISPLAY_PARAMS : undefined,
+ stripSummernoteDefaults: typeof stripSummernoteDefaults !== 'undefined' ? stripSummernoteDefaults : undefined,
};
}
`;
diff --git a/cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js b/cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js
new file mode 100644
index 0000000..7f4f681
--- /dev/null
+++ b/cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js
@@ -0,0 +1,154 @@
+/**
+ * Tests for cleanHtml() paste enhancements from richTextFieldWithTables/v1/index.js
+ *
+ * Covers: empty span replacement, orphan table row repair,
+ * newline-in-attributes handling, updated link regex, Word list cleanup.
+ */
+
+const { cleanHtml } = require("../../richTextFieldWithTables/v1/index.js");
+
+describe("cleanHtml - empty span replacement", () => {
+ test("replaces empty span with a space", () => {
+ const input = "beforeafter
";
+ const result = cleanHtml(input);
+ expect(result).not.toContain("");
+ expect(result).toContain("before");
+ expect(result).toContain("after");
+ });
+
+ test("replaces empty span with attributes with a space", () => {
+ const input = 'before after
';
+ const result = cleanHtml(input);
+ expect(result).toContain("before");
+ expect(result).toContain("after");
+ });
+
+ test("replaces multiple empty spans", () => {
+ const input = "text
";
+ const result = cleanHtml(input);
+ expect(result).toContain("text");
+ // Should not have any empty spans left
+ expect(result).not.toMatch(/]*>\s*<\/span>/);
+ });
+});
+
+describe("cleanHtml - orphan table row repair", () => {
+ test("wraps bare in tags", () => {
+ const input = "| cell 1 | cell 2 |
";
+ const result = cleanHtml(input);
+ expect(result).toMatch(/^.*<\/table>$/);
+ expect(result).toContain("");
+ expect(result).toContain("| cell 1 | ");
+ });
+
+ test("wraps multiple bare
rows in ", () => {
+ const input = "| A |
| B |
";
+ const result = cleanHtml(input);
+ expect(result).toMatch(/^.*<\/table>$/);
+ expect(result).toContain("| A | ");
+ expect(result).toContain("B | ");
+ });
+
+ test("does not double-wrap when already exists", () => {
+ const input = "";
+ const result = cleanHtml(input);
+ expect(result).not.toContain("");
+ expect(result).toContain("");
+ });
+
+ test("does not add when no present", () => {
+ const input = "no table here
";
+ const result = cleanHtml(input);
+ expect(result).not.toContain("");
+ });
+});
+
+describe("cleanHtml - newline inside tag attributes", () => {
+ test("strips newlines from inside tag attributes during partial paste", () => {
+ const input = 'text';
+ const result = cleanHtml(input, true);
+ // The newline inside the tag should become a space, not a
+ expect(result).not.toContain("
");
+ expect(result).toContain("text");
+ });
+
+ test("preserves newlines in text content as
during partial paste", () => {
+ const input = "line one\nline two
";
+ const result = cleanHtml(input, true);
+ expect(result).toContain("
");
+ expect(result).toContain("line one");
+ expect(result).toContain("line two");
+ });
+
+ test("handles mixed: newlines in attributes and in text", () => {
+ const input =
+ 'before\nafter
';
+ const result = cleanHtml(input, true);
+ // Newline in attribute should not produce
+ expect(result).not.toMatch(/style="[^"]*
[^"]*"/);
+ // Newline in text content should produce
+ expect(result).toContain("before
after");
+ });
+});
+
+describe("cleanHtml - updated link regex", () => {
+ test("preserves file:// links", () => {
+ const input = 'doc';
+ const result = cleanHtml(input);
+ expect(result).toContain('href="file://server/share/doc.pdf"');
+ });
+
+ test("preserves file:\\\\ UNC links", () => {
+ const input = 'doc';
+ const result = cleanHtml(input);
+ expect(result).toContain("href=");
+ expect(result).toContain("doc");
+ });
+
+ test("preserves https links", () => {
+ const input = 'link';
+ expect(cleanHtml(input)).toBe(input);
+ });
+
+ test("preserves mailto links", () => {
+ const input = 'email';
+ expect(cleanHtml(input)).toBe(input);
+ });
+
+ test("preserves links with custom protocol scheme", () => {
+ const input =
+ 'edge link';
+ const result = cleanHtml(input);
+ expect(result).toContain("href=");
+ expect(result).toContain("edge link");
+ });
+
+ test("strips http:// links (not https)", () => {
+ const input = 'link';
+ const result = cleanHtml(input);
+ expect(result).toBe("link");
+ });
+
+ test("strips relative links", () => {
+ const input = 'link';
+ expect(cleanHtml(input)).toBe("link");
+ });
+
+ test("strips javascript: links", () => {
+ const input = 'click';
+ expect(cleanHtml(input)).toBe("click");
+ });
+});
+
+describe("cleanHtml - Word ordered list cleanup", () => {
+ test("Word conditional comments are removed by cleanHtml", () => {
+ // The mso-list newline cleanup happens in the paste handler BEFORE cleanHtml.
+ // cleanHtml itself strips the HTML comments (...).
+ const input =
+ '1.Item one
';
+ const result = cleanHtml(input, true);
+ expect(result).toContain("Item one");
+ expect(result).not.toContain("