diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 6943ce06c2..dc9b852c79 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -86,7 +86,7 @@ EXEMPT_FILES = make_set( "web/src/condense.ts", "web/src/confirm_dialog.ts", "web/src/copied_tooltip.ts", - "web/src/copy_and_paste.js", + "web/src/copy_and_paste.ts", "web/src/csrf.ts", "web/src/css_variables.d.ts", "web/src/css_variables.js", diff --git a/web/src/copy_and_paste.js b/web/src/copy_and_paste.ts similarity index 84% rename from web/src/copy_and_paste.js rename to web/src/copy_and_paste.ts index 51b5e40af6..e9d90777c6 100644 --- a/web/src/copy_and_paste.js +++ b/web/src/copy_and_paste.ts @@ -1,13 +1,25 @@ import isUrl from "is-url"; import $ from "jquery"; import _ from "lodash"; +import assert from "minimalistic-assert"; import TurndownService from "turndown"; import * as compose_ui from "./compose_ui"; import * as message_lists from "./message_lists"; import * as rows from "./rows"; -function find_boundary_tr($initial_tr, iterate_row) { +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + math: HTMLElement; + strike: HTMLElement; + } +} + +function find_boundary_tr( + $initial_tr: JQuery, + iterate_row: ($tr: JQuery) => JQuery, +): [number, boolean] | undefined { let j; let skip_same_td_check = false; let $tr = $initial_tr; @@ -40,7 +52,7 @@ function find_boundary_tr($initial_tr, iterate_row) { return [rows.id($tr), skip_same_td_check]; } -function construct_recipient_header($message_row) { +function construct_recipient_header($message_row: JQuery): JQuery { const message_header_content = rows .get_message_recipient_header($message_row) .text() @@ -65,7 +77,7 @@ Do not be afraid to change this code if you understand how modern browsers deal with copy/paste. Just test your changes carefully. */ -function construct_copy_div($div, start_id, end_id) { +function construct_copy_div($div: JQuery, start_id: number, end_id: number): void { if (message_lists.current === undefined) { return; } @@ -89,6 +101,7 @@ function construct_copy_div($div, start_id, end_id) { should_include_start_recipient_header = true; } const message = message_lists.current.get(rows.id($row)); + assert(message !== undefined); const $content = $(message.content); $content.first().prepend( $("") @@ -103,7 +116,7 @@ function construct_copy_div($div, start_id, end_id) { } } -function select_div($div, selection) { +function select_div($div: JQuery, selection: Selection): void { $div.css({ position: "absolute", left: "-99999px", @@ -121,9 +134,10 @@ function select_div($div, selection) { selection.selectAllChildren($div[0]); } -function remove_div(_div, ranges, selection) { +function remove_div(_div: JQuery, ranges: Range[]): void { window.setTimeout(() => { - selection = window.getSelection(); + const selection = window.getSelection(); + assert(selection !== null); selection.removeAllRanges(); for (const range of ranges) { @@ -134,7 +148,7 @@ function remove_div(_div, ranges, selection) { }, 0); } -export function copy_handler() { +export function copy_handler(): void { // This is the main handler for copying message content via // `Ctrl+C` in Zulip (note that this is totally independent of the // "select region" copy behavior on Linux; that is handled @@ -151,6 +165,7 @@ export function copy_handler() { // were partially covered by the selection. const selection = window.getSelection(); + assert(selection !== null); const analysis = analyze_selection(selection); const ranges = analysis.ranges; const start_id = analysis.start_id; @@ -189,10 +204,15 @@ export function copy_handler() { // instead of copying the original selection select_div($div, selection); document.execCommand("copy"); - remove_div($div, ranges, selection); + remove_div($div, ranges); } -export function analyze_selection(selection) { +export function analyze_selection(selection: Selection): { + ranges: Range[]; + start_id: number | undefined; + end_id: number | undefined; + skip_same_td_check: boolean; +} { // Here we analyze our selection to determine if part of a message // or multiple messages are selected. // @@ -265,7 +285,7 @@ export function analyze_selection(selection) { }; } -function get_end_tr_from_endc($endc) { +function get_end_tr_from_endc($endc: JQuery): JQuery { if ($endc.attr("id") === "bottom_whitespace" || $endc.attr("id") === "compose_close") { // If the selection ends in the bottom whitespace, we should // act as though the selection ends on the final message. @@ -293,7 +313,7 @@ function get_end_tr_from_endc($endc) { // we can use the last message from the previous recipient_row. if ($endc.parents(".message_header").length > 0) { const $overflow_recipient_row = $endc.parents(".recipient_row").first(); - return $overflow_recipient_row.prev(".recipient_row").last(".message_row"); + return $overflow_recipient_row.prev(".recipient_row").last(); } // If somehow we get here, do the default return. } @@ -301,45 +321,52 @@ function get_end_tr_from_endc($endc) { return $endc.parents(".selectable_row").first(); } -function deduplicate_newlines(attribute) { +function deduplicate_newlines(attribute: string): string { // We replace any occurrences of one or more consecutive newlines followed by // zero or more whitespace characters with a single newline character. return attribute ? attribute.replaceAll(/(\n+\s*)+/g, "\n") : ""; } -function image_to_zulip_markdown(_content, node) { +function image_to_zulip_markdown( + _content: string, + node: Element | Document | DocumentFragment, +): string { + assert(node instanceof Element); if (node.nodeName === "IMG" && node.classList.contains("emoji") && node.hasAttribute("alt")) { // For Zulip's custom emoji - return node.getAttribute("alt"); + return node.getAttribute("alt") ?? ""; } - const src = node.getAttribute("src") || node.getAttribute("href") || ""; - const title = deduplicate_newlines(node.getAttribute("title")) || ""; + const src = node.getAttribute("src") ?? node.getAttribute("href") ?? ""; + const title = deduplicate_newlines(node.getAttribute("title") ?? ""); // Using Zulip's link like syntax for images - return src ? "[" + title + "](" + src + ")" : node.getAttribute("alt") || ""; + return src ? "[" + title + "](" + src + ")" : node.getAttribute("alt") ?? ""; } -function within_single_element(html_fragment) { +function within_single_element(html_fragment: HTMLElement): boolean { return ( html_fragment.childNodes.length === 1 && - html_fragment.firstElementChild && - html_fragment.firstElementChild.innerHTML + html_fragment.firstElementChild !== null && + html_fragment.firstElementChild.innerHTML !== "" ); } -export function is_white_space_pre(paste_html) { +export function is_white_space_pre(paste_html: string): boolean { const html_fragment = new DOMParser() .parseFromString(paste_html, "text/html") .querySelector("body"); + assert(html_fragment !== null); return ( within_single_element(html_fragment) && + html_fragment.firstElementChild instanceof HTMLElement && html_fragment.firstElementChild.style.whiteSpace === "pre" ); } -export function paste_handler_converter(paste_html) { +export function paste_handler_converter(paste_html: string): string { const copied_html_fragment = new DOMParser() .parseFromString(paste_html, "text/html") .querySelector("body"); + assert(copied_html_fragment !== null); const copied_within_single_element = within_single_element(copied_html_fragment); const outer_elements_to_retain = ["PRE", "UL", "OL", "A", "CODE"]; // If the entire selection copied is within a single HTML element (like an @@ -347,9 +374,10 @@ export function paste_handler_converter(paste_html) { // identify the intended structure of the copied content. if ( copied_within_single_element && + copied_html_fragment.firstElementChild !== null && !outer_elements_to_retain.includes(copied_html_fragment.firstElementChild.nodeName) ) { - paste_html = copied_html_fragment.firstChild.innerHTML; + paste_html = copied_html_fragment.firstElementChild.innerHTML; } // turning off escaping (for now) to remove extra `/` @@ -376,11 +404,12 @@ export function paste_handler_converter(paste_html) { turndownService.addRule("links", { filter: ["a"], replacement(content, node) { + assert(node instanceof HTMLAnchorElement); if (node.href === content) { // Checks for raw links without custom text. return content; } - if (node.childNodes.length === 1 && node.firstChild.nodeName === "IMG") { + if (node.childNodes.length === 1 && node.firstChild!.nodeName === "IMG") { // ignore link's url if it only has an image return content; } @@ -398,20 +427,21 @@ export function paste_handler_converter(paste_html) { .replace(/\n+$/, "\n") // replace trailing newlines with just a single one .replaceAll(/\n/gm, "\n "); // custom 2 space indent let prefix = "* "; - const parent = node.parentNode; + const parent = node.parentElement; + assert(parent !== null); if (parent.nodeName === "OL") { const start = parent.getAttribute("start"); const index = Array.prototype.indexOf.call(parent.children, node); prefix = (start ? Number(start) + index : index + 1) + ". "; } - return prefix + content + (node.nextSibling && !/\n$/.test(content) ? "\n" : ""); + return prefix + content + (node.nextSibling && !content.endsWith("\n") ? "\n" : ""); }, }); turndownService.addRule("zulipImagePreview", { filter(node) { // select image previews in Zulip messages return ( - node.classList.contains("message_inline_image") && node.firstChild.nodeName === "A" + node.classList.contains("message_inline_image") && node.firstChild?.nodeName === "A" ); }, @@ -423,18 +453,19 @@ export function paste_handler_converter(paste_html) { // does not have the `message_inline_image` class, it means it is the generating // link, and not the preview, meaning the generating link is copied as well. const copied_html = new DOMParser().parseFromString(paste_html, "text/html"); + let href; if ( + node.firstElementChild === null || + (href = node.firstElementChild.getAttribute("href")) === null || !copied_html - .querySelector( - "a[href='" + CSS.escape(node.firstChild.getAttribute("href")) + "']", - ) - ?.parentNode?.classList.contains("message_inline_image") + .querySelector("a[href='" + CSS.escape(href) + "']") + ?.parentElement?.classList.contains("message_inline_image") ) { // We skip previews which have their generating link copied too, to avoid // double pasting the same link. return ""; } - return image_to_zulip_markdown(content, node.firstChild); + return image_to_zulip_markdown(content, node.firstElementChild); }, }); turndownService.addRule("images", { @@ -465,19 +496,23 @@ export function paste_handler_converter(paste_html) { // Everything else works the same. turndownService.addRule("fencedCodeBlock", { filter(node, options) { + let text_children; return ( options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && - [...node.childNodes].filter((child) => child.textContent.trim() !== "").length === - 1 && - [...node.childNodes].find((child) => child.textContent.trim() !== "").nodeName === - "CODE" + (text_children = [...node.childNodes].filter( + (child) => child.textContent !== null && child.textContent.trim() !== "", + )).length === 1 && + text_children[0].nodeName === "CODE" ); }, replacement(_content, node, options) { - const codeElement = [...node.childNodes].find((child) => child.nodeName === "CODE"); + assert(node instanceof HTMLElement); + const codeElement = [...node.children].find((child) => child.nodeName === "CODE"); + assert(codeElement !== undefined); const code = codeElement.textContent; + assert(code !== null); // We convert single line code inside a code block to inline markdown code, // and the code for this is taken from upstream's `code` rule. @@ -490,7 +525,7 @@ export function paste_handler_converter(paste_html) { // Pick the shortest sequence of backticks that is not found in the code // to be the delimiter. let delimiter = "`"; - const matches = code.match(/`+/gm) || []; + const matches: string[] = code.match(/`+/gm) ?? []; while (matches.includes(delimiter)) { delimiter = delimiter + "`"; } @@ -498,11 +533,12 @@ export function paste_handler_converter(paste_html) { return delimiter + extraSpace + code + extraSpace + delimiter; } - const className = codeElement.getAttribute("class") || ""; + const className = codeElement.getAttribute("class") ?? ""; const language = node.parentElement?.classList.contains("zulip-code-block") - ? node.closest(".codehilite")?.dataset?.codeLanguage || "" - : (className.match(/language-(\S+)/) || [null, ""])[1]; + ? node.closest(".codehilite")?.dataset?.codeLanguage ?? "" + : (className.match(/language-(\S+)/) ?? [null, ""])[1]; + assert(options.fence !== undefined); const fenceChar = options.fence.charAt(0); let fenceSize = 3; const fenceInCodeRegex = new RegExp("^" + fenceChar + "{3,}", "gm"); @@ -532,7 +568,7 @@ export function paste_handler_converter(paste_html) { return markdown_text; } -function is_safe_url_paste_target($textarea) { +function is_safe_url_paste_target($textarea: JQuery): boolean { const range = $textarea.range(); if (!range.text) { @@ -563,7 +599,7 @@ function is_safe_url_paste_target($textarea) { return true; } -export function maybe_transform_html(html, text) { +export function maybe_transform_html(html: string, text: string): string { if (is_white_space_pre(html)) { // Copied content styled with `white-space: pre` is pasted as is // but formatted as code. We need this for content copied from @@ -573,7 +609,8 @@ export function maybe_transform_html(html, text) { return html; } -export function paste_handler(event) { +export function paste_handler(this: HTMLTextAreaElement, event: JQuery.TriggeredEvent): void { + assert(event.originalEvent instanceof ClipboardEvent); const clipboardData = event.originalEvent.clipboardData; if (!clipboardData) { // On IE11, ClipboardData isn't defined. One can instead @@ -585,7 +622,7 @@ export function paste_handler(event) { } if (clipboardData.getData) { - const $textarea = $(event.currentTarget); + const $textarea = $(this); const paste_text = clipboardData.getData("text"); let paste_html = clipboardData.getData("text/html"); // Trim the paste_text to accommodate sloppy copying @@ -619,7 +656,7 @@ export function paste_handler(event) { } } -export function initialize() { - $("textarea#compose-textarea").on("paste", paste_handler); - $("body").on("paste", ".message_edit_content", paste_handler); +export function initialize(): void { + $("textarea#compose-textarea").on("paste", paste_handler); + $("body").on("paste", "textarea.message_edit_content", paste_handler); } diff --git a/web/src/rendered_markdown.ts b/web/src/rendered_markdown.ts index 7cca8b5970..246c05ca30 100644 --- a/web/src/rendered_markdown.ts +++ b/web/src/rendered_markdown.ts @@ -274,7 +274,7 @@ export const update_elements = ($content: JQuery): void => { }); // Display the view-code-in-playground and the copy-to-clipboard button inside the div.codehilite element, - // and add a `zulip-code-block` class to it to detect it easily in `copy_and_paste.js`. + // and add a `zulip-code-block` class to it to detect it easily in `copy_and_paste.ts`. $content.find("div.codehilite").each(function (): void { const $codehilite = $(this); const $pre = $codehilite.find("pre"); diff --git a/web/tests/lib/index.js b/web/tests/lib/index.js index 4b50e0cac7..262bf37096 100644 --- a/web/tests/lib/index.js +++ b/web/tests/lib/index.js @@ -22,6 +22,8 @@ process.env.NODE_ENV = "test"; const dom = new JSDOM("", {url: "http://zulip.zulipdev.com/"}); global.DOMParser = dom.window.DOMParser; +global.HTMLAnchorElement = dom.window.HTMLAnchorElement; +global.HTMLElement = dom.window.HTMLElement; global.navigator = { userAgent: "node.js", };