Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cp/appian-component-plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<description>A simple rich text editor</description>
<support supported="false" email="richtext-componentpluginsupport@appian.com" />
<vendor name="Appian" url="https://www.appian.com"/>
<version>1.17.2</version>
<version>1.18.0</version>
</plugin-info>
<component rule-name="richTextField" version="1.0.0">
<sdk-version>2.0.0</sdk-version>
Expand Down
127 changes: 115 additions & 12 deletions cp/richTextFieldWithTables/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,54 @@ summernote.on(
);
summernote.on("summernote.paste", function (we, e) {
e.preventDefault();
/* Determine if clipboard contains an <img> tag.
* If so - skip pasting images as it's handled by onImageUpload.
*/
let clipboardHtml = readClipboard(e);
if (clipboardHtml.indexOf("<img") !== -1) {
let clipboardHtml = readClipboard(e) || "";

// If clipboard contains an external image, let the onImageUpload callback handle it to avoid duplicate pasting
if (/<img[^>]+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 <br>
if (clipboardHtml.indexOf("mso-list") !== -1) {
var WORD_ORDERED_LIST_REGEX = /<!\[if !supportLists\]>([\s\S]*?)<!\[endif\]>/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 = "<br>";
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
Expand Down Expand Up @@ -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, "<br>")
// Remove newlines from within tag attributes, converting them to spaces so they don't become <br> tags
.replace(/<[^>]+>/g, function (tag) {
return tag.replace(/\n/g, " ");
})
// Remove whitespace between tags
.replace(/>\s+</g, "><")
// Convert any remaining newlines to <br> tags, these will only be newlines in actual text content at this point
.replace(/\n/g, "<br>")
// Remove Word-specific classes
.replace(/\sclass=["']?MsoNormal["']?/gi, "");
} else if (isPartialHtml && !isContentHtml) {
Expand Down Expand Up @@ -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(/<span[^>]*>\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.*?href="(.*?)">(.*?)<\/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 (/<tr[\s>]/i.test(out) && !/<table[\s>]/i.test(out)) {
out = "<table>" + out + "</table>";
}

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(/<span[^>]*>\s*<\/span>/g, "");
out = out.replace(/<span\s*>([\s\S]*?)<\/span>/g, "$1");
} while (before !== out);

// Final cleanup
out = out.replace(/\s+>/g, ">");

return out;
}

Expand Down
1 change: 1 addition & 0 deletions cp/tests/helpers/browserScriptTransform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
`;
Expand Down
154 changes: 154 additions & 0 deletions cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js
Original file line number Diff line number Diff line change
@@ -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 = "<p>before<span></span>after</p>";
const result = cleanHtml(input);
expect(result).not.toContain("<span></span>");
expect(result).toContain("before");
expect(result).toContain("after");
});

test("replaces empty span with attributes with a space", () => {
const input = '<p>before<span style="font-size: 14px;"> </span>after</p>';
const result = cleanHtml(input);
expect(result).toContain("before");
expect(result).toContain("after");
});

test("replaces multiple empty spans", () => {
const input = "<p><span></span>text<span></span></p>";
const result = cleanHtml(input);
expect(result).toContain("text");
// Should not have any empty spans left
expect(result).not.toMatch(/<span[^>]*>\s*<\/span>/);
});
});

describe("cleanHtml - orphan table row repair", () => {
test("wraps bare <tr> in <table> tags", () => {
const input = "<tr><td>cell 1</td><td>cell 2</td></tr>";
const result = cleanHtml(input);
expect(result).toMatch(/^<table>.*<\/table>$/);
expect(result).toContain("<tr>");
expect(result).toContain("<td>cell 1</td>");
});

test("wraps multiple bare <tr> rows in <table>", () => {
const input = "<tr><td>A</td></tr><tr><td>B</td></tr>";
const result = cleanHtml(input);
expect(result).toMatch(/^<table>.*<\/table>$/);
expect(result).toContain("<td>A</td>");
expect(result).toContain("<td>B</td>");
});

test("does not double-wrap when <table> already exists", () => {
const input = "<table><tr><td>cell</td></tr></table>";
const result = cleanHtml(input);
expect(result).not.toContain("<table><table>");
expect(result).toContain("<table><tr>");
});

test("does not add <table> when no <tr> present", () => {
const input = "<p>no table here</p>";
const result = cleanHtml(input);
expect(result).not.toContain("<table>");
});
});

describe("cleanHtml - newline inside tag attributes", () => {
test("strips newlines from inside tag attributes during partial paste", () => {
const input = '<span style="font-size: 14px;\ncolor: red;">text</span>';
const result = cleanHtml(input, true);
// The newline inside the tag should become a space, not a <br>
expect(result).not.toContain("<br>");
expect(result).toContain("text");
});

test("preserves newlines in text content as <br> during partial paste", () => {
const input = "<p>line one\nline two</p>";
const result = cleanHtml(input, true);
expect(result).toContain("<br>");
expect(result).toContain("line one");
expect(result).toContain("line two");
});

test("handles mixed: newlines in attributes and in text", () => {
const input =
'<p style="margin-left: 1em;\ntext-align: center;">before\nafter</p>';
const result = cleanHtml(input, true);
// Newline in attribute should not produce <br>
expect(result).not.toMatch(/style="[^"]*<br>[^"]*"/);
// Newline in text content should produce <br>
expect(result).toContain("before<br>after");
});
});

describe("cleanHtml - updated link regex", () => {
test("preserves file:// links", () => {
const input = '<a href="file://server/share/doc.pdf">doc</a>';
const result = cleanHtml(input);
expect(result).toContain('href="file://server/share/doc.pdf"');
});

test("preserves file:\\\\ UNC links", () => {
const input = '<a href="file:\\\\server\\share\\doc.pdf">doc</a>';
const result = cleanHtml(input);
expect(result).toContain("href=");
expect(result).toContain("doc</a>");
});

test("preserves https links", () => {
const input = '<a href="https://example.com">link</a>';
expect(cleanHtml(input)).toBe(input);
});

test("preserves mailto links", () => {
const input = '<a href="mailto:user@example.com">email</a>';
expect(cleanHtml(input)).toBe(input);
});

test("preserves links with custom protocol scheme", () => {
const input =
'<a href="Microsoft-edge:https://www.google.com">edge link</a>';
const result = cleanHtml(input);
expect(result).toContain("href=");
expect(result).toContain("edge link</a>");
});

test("strips http:// links (not https)", () => {
const input = '<a href="http://example.com">link</a>';
const result = cleanHtml(input);
expect(result).toBe("link");
});

test("strips relative links", () => {
const input = '<a href="/page">link</a>';
expect(cleanHtml(input)).toBe("link");
});

test("strips javascript: links", () => {
const input = '<a href="javascript:alert(1)">click</a>';
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 (<!--[if]-->...<!--[endif]-->).
const input =
'<p style="margin-left: 1em;"><!--[if !supportLists]--><span>1.</span><!--[endif]-->Item one</p>';
const result = cleanHtml(input, true);
expect(result).toContain("Item one");
expect(result).not.toContain("<!--");
expect(result).not.toContain("[if");
});
});
Loading
Loading