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 = "" + 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 = ""; + 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("