From 8b54bab5ba923736d66e1926ddedf5fc1d706d5e Mon Sep 17 00:00:00 2001 From: Patel Date: Wed, 7 Jan 2026 17:01:38 -0500 Subject: [PATCH 1/7] Address issues with styling when pasting from one RTE to another --- cp/richTextFieldWithTables/v1/index.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js index d24354b..85d1ee4 100644 --- a/cp/richTextFieldWithTables/v1/index.js +++ b/cp/richTextFieldWithTables/v1/index.js @@ -699,7 +699,10 @@ 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) { @@ -707,10 +710,10 @@ function cleanHtml(html, isPartialHtml) { 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, " "); return out; From 1a1b339b3b9c81c8fa6af69d98d67e7ca2e7c974 Mon Sep 17 00:00:00 2001 From: Patel Date: Thu, 8 Jan 2026 12:39:23 -0500 Subject: [PATCH 2/7] Update cleanHTML to handle orphan tables from Word --- cp/richTextFieldWithTables/v1/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js index 85d1ee4..a0852de 100644 --- a/cp/richTextFieldWithTables/v1/index.js +++ b/cp/richTextFieldWithTables/v1/index.js @@ -716,6 +716,11 @@ function cleanHtml(html, isPartialHtml) { // 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 = "" + out + "
"; + } + return out; } From 87ec987e2edfed25cf2a08bff3fef6899b1938b9 Mon Sep 17 00:00:00 2001 From: Patel Date: Fri, 13 Mar 2026 12:56:48 -0400 Subject: [PATCH 3/7] Address copy/pasting issues and file protocol --- cp/richTextFieldWithTables/v1/index.js | 152 +++++++++++++++++++++---- 1 file changed, 129 insertions(+), 23 deletions(-) diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js index a0852de..0b4383c 100644 --- a/cp/richTextFieldWithTables/v1/index.js +++ b/cp/richTextFieldWithTables/v1/index.js @@ -45,7 +45,7 @@ summernote.on( if (window.hasFocus) { setAppianValue(); } - }, 500) + }, 500), ); summernote.on("summernote.paste", function (we, e) { e.preventDefault(); @@ -57,7 +57,11 @@ summernote.on("summernote.paste", function (we, e) { return; } handleImagePasteFromFile(e); - summernote.summernote("pasteHTML", cleanHtml(clipboardHtml, true)); + var cleanedHtml = cleanHtml(clipboardHtml, true); + cleanedHtml = stripSummernoteDefaults(cleanedHtml); + /* Wrap HTML around tags to ensure it is pasted as a single node */ + cleanedHtml = "" + cleanedHtml + ""; + summernote.summernote("pasteHTML", cleanedHtml); }); // After investigating, we determined that only these tags & attributes are necessary/supported in order to render all supported styles of the editor @@ -90,7 +94,15 @@ const ALLOWED_TAGS = [ "td", "a", ]; -const ALLOWED_ATTRIBUTES = ["src", "style", "color", "href", "target", "colspan", "rowspan"]; +const ALLOWED_ATTRIBUTES = [ + "src", + "style", + "color", + "href", + "target", + "colspan", + "rowspan", +]; const ALLOWED_STYLE_ATTRIBUTES = [ "font-size", "background-color", @@ -174,14 +186,18 @@ function buildEditor() { if (!isReadOnly()) { // 72px is arbitrarily determined based on the height of the toolbar var height = - window.allParameters.height === "auto" ? "auto" : parseInt(window.allParameters.height) - 72; + window.allParameters.height === "auto" + ? "auto" + : parseInt(window.allParameters.height) - 72; // Code for the insertable items button var insertableItemsFiltered = []; if (window.allParameters.insertableItems) { - insertableItemsFiltered = window.allParameters.insertableItems.filter(function (i) { - return i.label && i.value; - }); + insertableItemsFiltered = window.allParameters.insertableItems.filter( + function (i) { + return i.label && i.value; + }, + ); } var insertableItemsButton = function (context) { var ui = $.summernote.ui; @@ -221,7 +237,17 @@ function buildEditor() { // Note, list of available buttons can be found here: https://summernote.org/deep-dive/#custom-toolbar-popover ["group0", ["style"]], ["group1", ["fontsize"]], - ["group2", ["bold", "italic", "underline", "strikethrough", "superscript", "subscript"]], + [ + "group2", + [ + "bold", + "italic", + "underline", + "strikethrough", + "superscript", + "subscript", + ], + ], ["group3", ["forecolor", "backcolor"]], ["group4", ["ol", "ul"]], ["group5", ["paragraph", "table"]], @@ -336,7 +362,9 @@ function buildEditor() { */ function isImageNewBase64(image) { const base64ImgSrcRegex = /^data:/g; - return base64ImgSrcRegex.test(image.src) && !image.classList.contains("loading"); + return ( + base64ImgSrcRegex.test(image.src) && !image.classList.contains("loading") + ); } function uploadBase64Img(imageSelector) { @@ -390,7 +418,11 @@ function uploadBase64Img(imageSelector) { base64: base64Str, }; - return Appian.Component.invokeClientApi(window.connectedSystem, CLIENT_API_FRIENDLY_NAME, payload) + return Appian.Component.invokeClientApi( + window.connectedSystem, + CLIENT_API_FRIENDLY_NAME, + payload, + ) .then(handleClientApiResponseForBase64) .then(function (docURL) { return docURL; @@ -406,7 +438,9 @@ function returnDisplayParams() { var displayParams = {}; for (var i = 0; i < DISPLAY_PARAMS.length; i++) { var param = DISPLAY_PARAMS[i]; - displayParams[param] = !window.allParameters ? "" : window.allParameters[param]; + displayParams[param] = !window.allParameters + ? "" + : window.allParameters[param]; } return displayParams; } @@ -436,7 +470,10 @@ function setAppianValue() { outputUploadedImages(); var newSaveOutValue = cleanHtml(getEditorContents()); // Always save-out unless the new value we would be saving out matches the last value we saved out - if (window.lastSaveOutValue !== newSaveOutValue && !doesBase64ImageExist()) { + if ( + window.lastSaveOutValue !== newSaveOutValue && + !doesBase64ImageExist() + ) { Appian.Component.saveValue("richText", newSaveOutValue); window.lastSaveOutValue = newSaveOutValue; } @@ -526,16 +563,18 @@ function setDynamicCss() { // LIGHT tableBorderWidth = "1px 0px"; cssStyles.push( - "table, table tr:last-child, table tr:last-child td {border-bottom: 0px !important}" + "table, table tr:last-child, table tr:last-child td {border-bottom: 0px !important}", ); cssStyles.push( - "table, th, table tr:first-child, table tr:first-child td {border-top: 0px !important}" + "table, th, table tr:first-child, table tr:first-child td {border-top: 0px !important}", ); } else { // STANDARD tableBorderWidth = "1px"; } - cssStyles.push("table, td, th, tr {border-width: " + tableBorderWidth + " !important}"); + cssStyles.push( + "table, td, th, tr {border-width: " + tableBorderWidth + " !important}", + ); // set styles styleEl.innerHTML = cssStyles.join("\n"); @@ -580,13 +619,18 @@ function validate(forceUpdate) { var maxSize = window.allParameters.maxSize || MAX_SIZE_DEFAULT; if (window.allowImages) { if (!window.connectedSystem) { - newValidations.push(getTranslation("validationImageStorageConnectedSystemEmpty")); + newValidations.push( + getTranslation("validationImageStorageConnectedSystemEmpty"), + ); } } if (!isReadOnly() && getEditorContents().length > maxSize) { newValidations.push(getTranslation("validationContentTooBig")); } - if (forceUpdate || newValidations.toString() !== window.currentValidations.toString()) { + if ( + forceUpdate || + newValidations.toString() !== window.currentValidations.toString() + ) { Appian.Component.setValidations(newValidations); } window.currentValidations = newValidations; @@ -682,9 +726,12 @@ function cleanHtml(html, isPartialHtml) { if ($1 === "style") { // Step 4: Remove all unnecessary HTML style attributes // Test this Regex here: https://regexr.com/64gqb - return $0.replace(/([\w-]+): ?(?:[^;]|")*?;? ?(?=[^;]*:|")/g, function ($0, $1) { - return ALLOWED_STYLE_ATTRIBUTES.indexOf($1) > -1 ? $0 : ""; - }); + return $0.replace( + /([\w-]+): ?(?:[^;]|")*?;? ?(?=[^;]*:|")/g, + function ($0, $1) { + return ALLOWED_STYLE_ATTRIBUTES.indexOf($1) > -1 ? $0 : ""; + }, + ); } else { return $0; } @@ -701,13 +748,17 @@ function cleanHtml(html, isPartialHtml) { // 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 7: Remove any HTML comments @@ -720,7 +771,62 @@ function cleanHtml(html, isPartialHtml) { if (/]/i.test(out) && !/]/i.test(out)) { 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 + for (var i = 0; i < 10; i++) { + var before = out; + + out = out.replace(/]*>\s*<\/span>/g, ""); + out = out.replace(/([^]*?)<\/span>/g, "$1"); + + // Break if nothing changed + if (before === out) break; + } + + // Final cleanup + out = out.replace(/\s+>/g, ">"); + return out; } From 5b9972012ab054ab08896a8210fd1cf92bd15b04 Mon Sep 17 00:00:00 2001 From: Patel Date: Thu, 19 Mar 2026 14:31:46 -0400 Subject: [PATCH 4/7] RTE Enhancements: Copy and Pasting functionality --- cp/richTextFieldWithTables/v1/index.js | 72 +++++++++++++++++++++----- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js index 0b4383c..6ef9c11 100644 --- a/cp/richTextFieldWithTables/v1/index.js +++ b/cp/richTextFieldWithTables/v1/index.js @@ -49,19 +49,58 @@ 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?:\/\/[^"']+\.(?:jpg|jpeg|png|gif)(?:\?[^"']*)?)["']/i; + if (EXTERNAL_WEB_IMAGE_REGEX.test(clipboardHtml)) { + return; } - handleImagePasteFromFile(e); - var cleanedHtml = cleanHtml(clipboardHtml, true); - cleanedHtml = stripSummernoteDefaults(cleanedHtml); - /* Wrap HTML around tags to ensure it is pasted as a single node */ - cleanedHtml = "" + cleanedHtml + ""; - summernote.summernote("pasteHTML", cleanedHtml); + + // 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){ + const 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); + } + + // handleImagePasteFromFile(e); + // summernote.summernote("pasteHTML", cleanedHtml); }); // After investigating, we determined that only these tags & attributes are necessary/supported in order to render all supported styles of the editor @@ -660,9 +699,14 @@ function cleanHtml(html, isPartialHtml) { out = out // Word sometimes uses \r\n to represent a space .replace(/\r\n/g, " ") - .replace(/\n/g, "
") - // Remove whitespace between tags + // 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) { @@ -769,7 +813,7 @@ function cleanHtml(html, isPartialHtml) { // Step 9: Repair orphan table rows (Word paste) if (/]/i.test(out) && !/]/i.test(out)) { - out = "" + out + "
"; + out = "" + out + "
"; } return out; From 1351a14812c23363bfb9e6deed75c19fc64973c0 Mon Sep 17 00:00:00 2001 From: Dan Tobias Date: Tue, 21 Apr 2026 19:10:48 -0400 Subject: [PATCH 5/7] test: add tests for paste enhancements and stripSummernoteDefaults --- cp/tests/helpers/browserScriptTransform.js | 1 + .../cleanHtml.pasteEnhancements.test.js | 154 ++++++++++++++++++ .../stripSummernoteDefaults.test.js | 109 +++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js create mode 100644 cp/tests/richTextFieldWithTables/stripSummernoteDefaults.test.js 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 = ""; + const result = cleanHtml(input); + expect(result).toMatch(/^
cell 1cell 2
.*<\/table>$/); + expect(result).toContain(""); + expect(result).toContain(""); + }); + + test("wraps multiple bare rows in
cell 1
", () => { + const input = ""; + const result = cleanHtml(input); + expect(result).toMatch(/^
A
B
.*<\/table>$/); + expect(result).toContain(""); + expect(result).toContain(""); + }); + + test("does not double-wrap when
AB
already exists", () => { + const input = "
cell
"; + 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("