copy_and_paste: Split copy and paste.

Note that the puppeteer tests only tested
copying whereas the node tests only tested
pasting, which is why the puppeteer tests
for pasting and node tests for copying are
absent after this split.
This commit is contained in:
apoorvapendse
2025-02-27 00:10:16 +05:30
committed by Tim Abbott
parent 803988d206
commit 8b9ba36465
10 changed files with 461 additions and 459 deletions

View File

@@ -210,7 +210,7 @@ js_rules = RuleList(
"exclude_pattern": r"(const |\S)style ?=",
"description": "Avoid using the `style=` attribute; we prefer styling in CSS files",
"exclude": {
"web/tests/copy_and_paste.test.cjs",
"web/tests/compose_paste.test.cjs",
},
"good_lines": ["#my-style {color: blue;}", "const style =", 'some_style = "test"'],
"bad_lines": ['<p style="color: blue;">Foo</p>', 'style = "color: blue;"'],

View File

@@ -73,6 +73,7 @@ EXEMPT_FILES = make_set(
"web/src/compose_closed_ui.ts",
"web/src/compose_fade.ts",
"web/src/compose_notifications.ts",
"web/src/compose_paste.ts",
"web/src/compose_recipient.ts",
"web/src/compose_reply.ts",
"web/src/compose_send_menu_popover.js",
@@ -86,7 +87,7 @@ EXEMPT_FILES = make_set(
"web/src/condense.ts",
"web/src/confirm_dialog.ts",
"web/src/copied_tooltip.ts",
"web/src/copy_and_paste.ts",
"web/src/copy_messages.ts",
"web/src/csrf.ts",
"web/src/css_variables.ts",
"web/src/custom_profile_fields_ui.ts",

View File

@@ -5,11 +5,8 @@ import assert from "minimalistic-assert";
import {insertTextIntoField} from "text-field-edit";
import TurndownService from "turndown";
import * as clipboard_handler from "./clipboard_handler.ts";
import * as compose_ui from "./compose_ui.ts";
import * as hash_util from "./hash_util.ts";
import * as message_lists from "./message_lists.ts";
import * as rows from "./rows.ts";
import * as stream_data from "./stream_data.ts";
import * as topic_link_util from "./topic_link_util.ts";
import * as util from "./util.ts";
@@ -22,418 +19,6 @@ declare global {
}
}
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;
// If the selection boundary is somewhere that does not have a
// parent tr, we should let the browser handle the copy-paste
// entirely on its own
if ($tr.length === 0) {
return undefined;
}
// If the selection boundary is on a table row that does not have an
// associated message id (because the user clicked between messages),
// then scan downwards until we hit a table row with a message id.
// To ensure we can't enter an infinite loop, bail out (and let the
// browser handle the copy-paste on its own) if we don't hit what we
// are looking for within 10 rows.
for (j = 0; !$tr.is(".message_row") && j < 10; j += 1) {
$tr = iterate_row($tr);
}
if (j === 10) {
return undefined;
} else if (j !== 0) {
// If we updated tr, then we are not dealing with a selection
// that is entirely within one td, and we can skip the same td
// check (In fact, we need to because it won't work correctly
// in this case)
skip_same_td_check = true;
}
return [rows.id($tr), skip_same_td_check];
}
function construct_recipient_header($message_row: JQuery): JQuery {
const message_header_content = rows
.get_message_recipient_header($message_row)
.text()
.replaceAll(/\s+/g, " ")
.replace(/^\s/, "")
.replace(/\s$/, "");
return $("<p>").append($("<strong>").text(message_header_content));
}
/*
The techniques we use in this code date back to
2013 and may be obsolete today (and may not have
been even the best workaround back then).
https://github.com/zulip/zulip/commit/fc0b7c00f16316a554349f0ad58c6517ebdd7ac4
The idea is that we build a temp div, let jQuery process the
selection, then restore the selection on a zero-second timer back
to the original selection.
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: JQuery, start_id: number, end_id: number): void {
if (message_lists.current === undefined) {
return;
}
const copy_rows = rows.visible_range(start_id, end_id);
const $start_row = copy_rows[0];
assert($start_row !== undefined);
const $start_recipient_row = rows.get_message_recipient_row($start_row);
const start_recipient_row_id = rows.id_for_recipient_row($start_recipient_row);
let should_include_start_recipient_header = false;
let last_recipient_row_id = start_recipient_row_id;
for (const $row of copy_rows) {
const recipient_row_id = rows.id_for_recipient_row(rows.get_message_recipient_row($row));
// if we found a message from another recipient,
// it means that we have messages from several recipients,
// so we have to add new recipient's bar to final copied message
// and wouldn't forget to add start_recipient's bar at the beginning of final message
if (recipient_row_id !== last_recipient_row_id) {
construct_recipient_header($row).appendTo($div);
last_recipient_row_id = recipient_row_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(
$("<span>")
.text(message.sender_full_name + ": ")
.contents(),
);
$div.append($content);
}
if (should_include_start_recipient_header) {
construct_recipient_header($start_row).prependTo($div);
}
}
function select_div($div: JQuery, selection: Selection): void {
$div.css({
position: "absolute",
left: "-99999px",
// Color and background is made according to "light theme"
// exclusively here because when copying the content
// into, say, Gmail compose box, the styles come along.
// This is done to avoid copying the content with dark
// background when using the app in dark theme.
// We can avoid other custom styles since they are wrapped
// inside another parent such as `.message_content`.
color: "#333",
background: "#FFF",
}).attr("id", "copytempdiv");
$("body").append($div);
selection.selectAllChildren(util.the($div));
}
function remove_div(_div: JQuery, ranges: Range[]): void {
window.setTimeout(() => {
const selection = window.getSelection();
assert(selection !== null);
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
$("#copytempdiv").remove();
}, 0);
}
async function copy_selection_to_clipboard(selection: Selection): Promise<void> {
const range = selection.getRangeAt(0);
const div = document.createElement("div");
div.append(range.cloneContents());
const html_content = div.innerHTML.trim();
const plain_text = selection.toString().trim();
// Reference: https://stackoverflow.com/a/77305170/21940401
if (typeof ClipboardItem !== "undefined") {
// Shiny new Clipboard API, not fully supported in Firefox.
// https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API#browser_compatibility
const html = new Blob([html_content], {type: "text/html"});
const text = new Blob([plain_text], {type: "text/plain"});
const data = new ClipboardItem({"text/html": html, "text/plain": text});
await navigator.clipboard.write([data]);
} else {
// Fallback using the deprecated `document.execCommand`.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#browser_compatibility
const cb = (e: ClipboardEvent): void => {
e.clipboardData?.setData("text/html", html_content);
e.clipboardData?.setData("text/plain", plain_text);
e.preventDefault();
};
clipboard_handler.execute_copy(cb);
}
}
// We want to grab the closest katex-display up the tree
// in cases where we can resolve the selected katex expression
// from a math block into an inline expression.
// The returned element from this function
// is the one we call 'closest' on.
function get_nearest_html_element(node: Node | null): Element | null {
if (node === null || node instanceof Element) {
return node;
}
return node.parentElement;
}
/*
This is done to make the copying within math blocks smarter.
We mutate the selection for selecting singular katex expressions
within math blocks when applicable, which will be
converted into the inline $$<expr>$$ syntax.
In case when a single expression or its subset is selected
within a math block, we adjust the selection so that it
selects the katex span which is parenting that expression.
This converts the selection into an inline expression as
per the turndown rules below.
We want to avoid this behavior if the selection
spreads across multiple katex displays i.e. the
focus and anchor are not part of the same katex-display.
*/
function improve_katex_selection_range(selection: Selection): void {
const anchor_element = get_nearest_html_element(selection.anchorNode);
const focus_element = get_nearest_html_element(selection.focusNode);
// If the anchor and focus end up in different katex-displays, this selection
// isn't meant to be an inline expression, so we perform an early return.
if (
focus_element &&
anchor_element &&
focus_element?.closest(".katex-display") !== anchor_element?.closest(".katex-display")
) {
return;
}
if (anchor_element) {
const parent = anchor_element.closest(".katex-display");
const is_math_block = parent !== null && parent !== selection.anchorNode;
if (is_math_block) {
const range = document.createRange();
range.selectNodeContents(parent);
selection.removeAllRanges();
selection.addRange(range);
}
} else if (focus_element) {
const parent = focus_element.closest(".katex-display");
const is_math_block = parent !== null && parent !== selection.focusNode;
if (is_math_block) {
const range = document.createRange();
range.selectNodeContents(parent);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
export async function copy_handler(): Promise<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
// entirely by the browser, our HTML layout, and our use of the
// no-select CSS classes). We put considerable effort
// into producing a nice result that pastes well into other tools.
// Our user-facing specification is the following:
//
// * If the selection is contained within a single message, we
// want to just copy the portion that was selected, which we
// implement by letting the browser handle the Ctrl+C event.
//
// * Otherwise, we want to copy the bodies of all messages that
// were partially covered by the selection.
const selection = window.getSelection();
assert(selection !== null);
improve_katex_selection_range(selection);
const analysis = analyze_selection(selection);
const ranges = analysis.ranges;
const start_id = analysis.start_id;
const end_id = analysis.end_id;
const skip_same_td_check = analysis.skip_same_td_check;
const $div = $("<div>");
if (start_id === undefined || end_id === undefined || start_id > end_id) {
// In this case either the starting message or the ending
// message is not defined, so this is definitely not a
// multi-message selection and we can let the browser handle
// the copy.
//
// Also, if our logic is not sound about the selection range
// (start_id > end_id), we let the browser handle the copy.
//
// NOTE: `startContainer (~ start_id)` and `endContainer (~ end_id)`
// of a `Range` are always from top to bottom in the DOM tree, independent
// of the direction of the selection.
// TODO: Add a reference for this statement, I just tested
// it in console for various selection directions and found this
// to be the case not sure why there is no online reference for it.
await copy_selection_to_clipboard(selection);
return;
}
if (!skip_same_td_check && start_id === end_id) {
// Check whether the selection both starts and ends in the
// same message. If so, Let the browser handle this.
await copy_selection_to_clipboard(selection);
return;
}
// We've now decided to handle the copy event ourselves.
//
// We construct a temporary div for what we want the copy to pick up.
// We construct the div only once, rather than for each range as we can
// determine the starting and ending point with more confidence for the
// whole selection. When constructing for each `Range`, there is a high
// chance for overlaps between same message ids, avoiding which is much
// more difficult since we can get a range (start_id and end_id) for
// each selection `Range`.
construct_copy_div($div, start_id, end_id);
// Select div so that the browser will copy it
// instead of copying the original selection
select_div($div, selection);
// eslint-disable-next-line @typescript-eslint/no-deprecated
document.execCommand("copy");
remove_div($div, ranges);
}
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.
//
// Firefox and Chrome handle selection of multiple messages
// differently. Firefox typically creates multiple ranges for the
// selection, whereas Chrome typically creates just one.
//
// Our goal in the below loop is to compute and be prepared to
// analyze the combined range of the selections, and copy their
// full content.
let i;
let range;
const ranges = [];
let $startc;
let $endc;
let $initial_end_tr;
let start_id;
let end_id;
let start_data;
let end_data;
// skip_same_td_check is true whenever we know for a fact that the
// selection covers multiple messages (and thus we should no
// longer consider letting the browser handle the copy event).
let skip_same_td_check = false;
for (i = 0; i < selection.rangeCount; i += 1) {
range = selection.getRangeAt(i);
ranges.push(range);
$startc = $(range.startContainer);
start_data = find_boundary_tr(
$startc.parents(".selectable_row, .message_header").first(),
($row) => $row.next(),
);
if (start_data === undefined) {
// Skip any selection sections that don't intersect a message.
continue;
}
if (start_id === undefined) {
// start_id is the Zulip message ID of the first message
// touched by the selection.
start_id = start_data[0];
}
$endc = $(range.endContainer);
$initial_end_tr = get_end_tr_from_endc($endc);
end_data = find_boundary_tr($initial_end_tr, ($row) => $row.prev());
if (end_data === undefined) {
// Skip any selection sections that don't intersect a message.
continue;
}
if (end_data[0] !== undefined) {
end_id = end_data[0];
}
if (start_data[1] || end_data[1]) {
// If the find_boundary_tr call for either the first or
// the last message covered by the selection
skip_same_td_check = true;
}
}
return {
ranges,
start_id,
end_id,
skip_same_td_check,
};
}
function get_end_tr_from_endc($endc: JQuery<Node>): 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.
// This handles the issue that Chrome seems to like selecting
// the compose_close button when you go off the end of the
// last message
return rows.last_visible();
}
// Sometimes (especially when three click selecting in Chrome) the selection
// can end in a hidden element in e.g. the next message, a date divider.
// We can tell this is the case because the selection isn't inside a
// `messagebox-content` div, which is where the message text itself is.
// TODO: Ideally make it so that the selection cannot end there.
// For now, we find the message row directly above wherever the
// selection ended.
if ($endc.closest(".messagebox-content").length === 0) {
// If the selection ends within the message following the selected
// messages, go back to use the actual last message.
if ($endc.parents(".message_row").length > 0) {
const $parent_msg = $endc.parents(".message_row").first();
return $parent_msg.prev(".message_row");
}
// If it's not in a .message_row, it's probably in a .message_header and
// we can use the last message from the previous recipient_row.
// NOTE: It is possible that the selection started and ended inside the
// message header and in that case we would be returning the message before
// the selected header if it exists, but that is not the purpose of this
// function to handle.
if ($endc.parents(".message_header").length > 0) {
const $overflow_recipient_row = $endc.parents(".recipient_row").first();
return $overflow_recipient_row.prev(".recipient_row").children(".message_row").last();
}
// If somehow we get here, do the default return.
}
return $endc.parents(".selectable_row").first();
}
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.

View File

@@ -7,10 +7,11 @@ import * as fenced_code from "../shared/src/fenced_code.ts";
import * as channel from "./channel.ts";
import * as compose_actions from "./compose_actions.ts";
import * as compose_paste from "./compose_paste.ts";
import * as compose_recipient from "./compose_recipient.ts";
import * as compose_state from "./compose_state.ts";
import * as compose_ui from "./compose_ui.ts";
import * as copy_and_paste from "./copy_and_paste.ts";
import * as copy_messages from "./copy_messages.ts";
import * as hash_util from "./hash_util.ts";
import {$t} from "./i18n.ts";
import * as inbox_ui from "./inbox_ui.ts";
@@ -181,7 +182,7 @@ export let selection_within_message_id = (
if (!selection.toString()) {
return undefined;
}
const {start_id, end_id} = copy_and_paste.analyze_selection(selection);
const {start_id, end_id} = copy_messages.analyze_selection(selection);
if (start_id === end_id) {
return start_id;
}
@@ -420,7 +421,7 @@ export function get_message_selection(selection = window.getSelection()): string
} else {
continue;
}
const markdown_text = copy_and_paste.paste_handler_converter(html_to_convert);
const markdown_text = compose_paste.paste_handler_converter(html_to_convert);
selected_message_content_raw = selected_message_content_raw + "\n" + markdown_text;
}
selected_message_content_raw = selected_message_content_raw.trim();

421
web/src/copy_messages.ts Normal file
View File

@@ -0,0 +1,421 @@
// Because this logic is heavily focused around managing browser quirks,
// this module is currently tested manually and via
// by web/e2e-tests/copy_messages.test.ts, not with node tests.
import $ from "jquery";
import assert from "minimalistic-assert";
import * as clipboard_handler from "./clipboard_handler.ts";
import * as message_lists from "./message_lists.ts";
import * as rows from "./rows.ts";
import * as util from "./util.ts";
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;
// If the selection boundary is somewhere that does not have a
// parent tr, we should let the browser handle the copy-paste
// entirely on its own
if ($tr.length === 0) {
return undefined;
}
// If the selection boundary is on a table row that does not have an
// associated message id (because the user clicked between messages),
// then scan downwards until we hit a table row with a message id.
// To ensure we can't enter an infinite loop, bail out (and let the
// browser handle the copy-paste on its own) if we don't hit what we
// are looking for within 10 rows.
for (j = 0; !$tr.is(".message_row") && j < 10; j += 1) {
$tr = iterate_row($tr);
}
if (j === 10) {
return undefined;
} else if (j !== 0) {
// If we updated tr, then we are not dealing with a selection
// that is entirely within one td, and we can skip the same td
// check (In fact, we need to because it won't work correctly
// in this case)
skip_same_td_check = true;
}
return [rows.id($tr), skip_same_td_check];
}
function construct_recipient_header($message_row: JQuery): JQuery {
const message_header_content = rows
.get_message_recipient_header($message_row)
.text()
.replaceAll(/\s+/g, " ")
.replace(/^\s/, "")
.replace(/\s$/, "");
return $("<p>").append($("<strong>").text(message_header_content));
}
/*
The techniques we use in this code date back to
2013 and may be obsolete today (and may not have
been even the best workaround back then).
https://github.com/zulip/zulip/commit/fc0b7c00f16316a554349f0ad58c6517ebdd7ac4
The idea is that we build a temp div, let jQuery process the
selection, then restore the selection on a zero-second timer back
to the original selection.
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: JQuery, start_id: number, end_id: number): void {
if (message_lists.current === undefined) {
return;
}
const copy_rows = rows.visible_range(start_id, end_id);
const $start_row = copy_rows[0];
assert($start_row !== undefined);
const $start_recipient_row = rows.get_message_recipient_row($start_row);
const start_recipient_row_id = rows.id_for_recipient_row($start_recipient_row);
let should_include_start_recipient_header = false;
let last_recipient_row_id = start_recipient_row_id;
for (const $row of copy_rows) {
const recipient_row_id = rows.id_for_recipient_row(rows.get_message_recipient_row($row));
// if we found a message from another recipient,
// it means that we have messages from several recipients,
// so we have to add new recipient's bar to final copied message
// and wouldn't forget to add start_recipient's bar at the beginning of final message
if (recipient_row_id !== last_recipient_row_id) {
construct_recipient_header($row).appendTo($div);
last_recipient_row_id = recipient_row_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(
$("<span>")
.text(message.sender_full_name + ": ")
.contents(),
);
$div.append($content);
}
if (should_include_start_recipient_header) {
construct_recipient_header($start_row).prependTo($div);
}
}
function select_div($div: JQuery, selection: Selection): void {
$div.css({
position: "absolute",
left: "-99999px",
// Color and background is made according to "light theme"
// exclusively here because when copying the content
// into, say, Gmail compose box, the styles come along.
// This is done to avoid copying the content with dark
// background when using the app in dark theme.
// We can avoid other custom styles since they are wrapped
// inside another parent such as `.message_content`.
color: "#333",
background: "#FFF",
}).attr("id", "copytempdiv");
$("body").append($div);
selection.selectAllChildren(util.the($div));
}
function remove_div(_div: JQuery, ranges: Range[]): void {
window.setTimeout(() => {
const selection = window.getSelection();
assert(selection !== null);
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
$("#copytempdiv").remove();
}, 0);
}
async function copy_selection_to_clipboard(selection: Selection): Promise<void> {
const range = selection.getRangeAt(0);
const div = document.createElement("div");
div.append(range.cloneContents());
const html_content = div.innerHTML.trim();
const plain_text = selection.toString().trim();
// Reference: https://stackoverflow.com/a/77305170/21940401
if (typeof ClipboardItem !== "undefined") {
// Shiny new Clipboard API, not fully supported in Firefox.
// https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API#browser_compatibility
const html = new Blob([html_content], {type: "text/html"});
const text = new Blob([plain_text], {type: "text/plain"});
const data = new ClipboardItem({"text/html": html, "text/plain": text});
await navigator.clipboard.write([data]);
} else {
// Fallback using the deprecated `document.execCommand`.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#browser_compatibility
const cb = (e: ClipboardEvent): void => {
e.clipboardData?.setData("text/html", html_content);
e.clipboardData?.setData("text/plain", plain_text);
e.preventDefault();
};
clipboard_handler.execute_copy(cb);
}
}
// We want to grab the closest katex-display up the tree
// in cases where we can resolve the selected katex expression
// from a math block into an inline expression.
// The returned element from this function
// is the one we call 'closest' on.
function get_nearest_html_element(node: Node | null): Element | null {
if (node === null || node instanceof Element) {
return node;
}
return node.parentElement;
}
/*
This is done to make the copying within math blocks smarter.
We mutate the selection for selecting singular katex expressions
within math blocks when applicable, which will be
converted into the inline $$<expr>$$ syntax.
In case when a single expression or its subset is selected
within a math block, we adjust the selection so that it
selects the katex span which is parenting that expression.
This converts the selection into an inline expression as
per the turndown rules below.
We want to avoid this behavior if the selection
spreads across multiple katex displays i.e. the
focus and anchor are not part of the same katex-display.
*/
function improve_katex_selection_range(selection: Selection): void {
const anchor_element = get_nearest_html_element(selection.anchorNode);
const focus_element = get_nearest_html_element(selection.focusNode);
// If the anchor and focus end up in different katex-displays, this selection
// isn't meant to be an inline expression, so we perform an early return.
if (
focus_element &&
anchor_element &&
focus_element?.closest(".katex-display") !== anchor_element?.closest(".katex-display")
) {
return;
}
if (anchor_element) {
const parent = anchor_element.closest(".katex-display");
const is_math_block = parent !== null && parent !== selection.anchorNode;
if (is_math_block) {
const range = document.createRange();
range.selectNodeContents(parent);
selection.removeAllRanges();
selection.addRange(range);
}
} else if (focus_element) {
const parent = focus_element.closest(".katex-display");
const is_math_block = parent !== null && parent !== selection.focusNode;
if (is_math_block) {
const range = document.createRange();
range.selectNodeContents(parent);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
export async function copy_handler(): Promise<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
// entirely by the browser, our HTML layout, and our use of the
// no-select CSS classes). We put considerable effort
// into producing a nice result that pastes well into other tools.
// Our user-facing specification is the following:
//
// * If the selection is contained within a single message, we
// want to just copy the portion that was selected, which we
// implement by letting the browser handle the Ctrl+C event.
//
// * Otherwise, we want to copy the bodies of all messages that
// were partially covered by the selection.
const selection = window.getSelection();
assert(selection !== null);
improve_katex_selection_range(selection);
const analysis = analyze_selection(selection);
const ranges = analysis.ranges;
const start_id = analysis.start_id;
const end_id = analysis.end_id;
const skip_same_td_check = analysis.skip_same_td_check;
const $div = $("<div>");
if (start_id === undefined || end_id === undefined || start_id > end_id) {
// In this case either the starting message or the ending
// message is not defined, so this is definitely not a
// multi-message selection and we can let the browser handle
// the copy.
//
// Also, if our logic is not sound about the selection range
// (start_id > end_id), we let the browser handle the copy.
//
// NOTE: `startContainer (~ start_id)` and `endContainer (~ end_id)`
// of a `Range` are always from top to bottom in the DOM tree, independent
// of the direction of the selection.
// TODO: Add a reference for this statement, I just tested
// it in console for various selection directions and found this
// to be the case not sure why there is no online reference for it.
await copy_selection_to_clipboard(selection);
return;
}
if (!skip_same_td_check && start_id === end_id) {
// Check whether the selection both starts and ends in the
// same message. If so, Let the browser handle this.
await copy_selection_to_clipboard(selection);
return;
}
// We've now decided to handle the copy event ourselves.
//
// We construct a temporary div for what we want the copy to pick up.
// We construct the div only once, rather than for each range as we can
// determine the starting and ending point with more confidence for the
// whole selection. When constructing for each `Range`, there is a high
// chance for overlaps between same message ids, avoiding which is much
// more difficult since we can get a range (start_id and end_id) for
// each selection `Range`.
construct_copy_div($div, start_id, end_id);
// Select div so that the browser will copy it
// instead of copying the original selection
select_div($div, selection);
// eslint-disable-next-line @typescript-eslint/no-deprecated
document.execCommand("copy");
remove_div($div, ranges);
}
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.
//
// Firefox and Chrome handle selection of multiple messages
// differently. Firefox typically creates multiple ranges for the
// selection, whereas Chrome typically creates just one.
//
// Our goal in the below loop is to compute and be prepared to
// analyze the combined range of the selections, and copy their
// full content.
let i;
let range;
const ranges = [];
let $startc;
let $endc;
let $initial_end_tr;
let start_id;
let end_id;
let start_data;
let end_data;
// skip_same_td_check is true whenever we know for a fact that the
// selection covers multiple messages (and thus we should no
// longer consider letting the browser handle the copy event).
let skip_same_td_check = false;
for (i = 0; i < selection.rangeCount; i += 1) {
range = selection.getRangeAt(i);
ranges.push(range);
$startc = $(range.startContainer);
start_data = find_boundary_tr(
$startc.parents(".selectable_row, .message_header").first(),
($row) => $row.next(),
);
if (start_data === undefined) {
// Skip any selection sections that don't intersect a message.
continue;
}
if (start_id === undefined) {
// start_id is the Zulip message ID of the first message
// touched by the selection.
start_id = start_data[0];
}
$endc = $(range.endContainer);
$initial_end_tr = get_end_tr_from_endc($endc);
end_data = find_boundary_tr($initial_end_tr, ($row) => $row.prev());
if (end_data === undefined) {
// Skip any selection sections that don't intersect a message.
continue;
}
if (end_data[0] !== undefined) {
end_id = end_data[0];
}
if (start_data[1] || end_data[1]) {
// If the find_boundary_tr call for either the first or
// the last message covered by the selection
skip_same_td_check = true;
}
}
return {
ranges,
start_id,
end_id,
skip_same_td_check,
};
}
function get_end_tr_from_endc($endc: JQuery<Node>): 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.
// This handles the issue that Chrome seems to like selecting
// the compose_close button when you go off the end of the
// last message
return rows.last_visible();
}
// Sometimes (especially when three click selecting in Chrome) the selection
// can end in a hidden element in e.g. the next message, a date divider.
// We can tell this is the case because the selection isn't inside a
// `messagebox-content` div, which is where the message text itself is.
// TODO: Ideally make it so that the selection cannot end there.
// For now, we find the message row directly above wherever the
// selection ended.
if ($endc.closest(".messagebox-content").length === 0) {
// If the selection ends within the message following the selected
// messages, go back to use the actual last message.
if ($endc.parents(".message_row").length > 0) {
const $parent_msg = $endc.parents(".message_row").first();
return $parent_msg.prev(".message_row");
}
// If it's not in a .message_row, it's probably in a .message_header and
// we can use the last message from the previous recipient_row.
// NOTE: It is possible that the selection started and ended inside the
// message header and in that case we would be returning the message before
// the selected header if it exists, but that is not the purpose of this
// function to handle.
if ($endc.parents(".message_header").length > 0) {
const $overflow_recipient_row = $endc.parents(".recipient_row").first();
return $overflow_recipient_row.prev(".recipient_row").children(".message_row").last();
}
// If somehow we get here, do the default return.
}
return $endc.parents(".selectable_row").first();
}

View File

@@ -15,7 +15,7 @@ import * as compose_send_menu_popover from "./compose_send_menu_popover.js";
import * as compose_state from "./compose_state.ts";
import * as compose_textarea from "./compose_textarea.ts";
import * as condense from "./condense.ts";
import * as copy_and_paste from "./copy_and_paste.ts";
import * as copy_messages from "./copy_messages.ts";
import * as deprecated_feature_notice from "./deprecated_feature_notice.ts";
import * as drafts_overlay_ui from "./drafts_overlay_ui.ts";
import * as emoji from "./emoji.ts";
@@ -1144,7 +1144,7 @@ export function process_hotkey(e, hotkey) {
navigate.page_down();
return true;
case "copy_with_c":
copy_and_paste.copy_handler();
copy_messages.copy_handler();
return true;
}

View File

@@ -318,7 +318,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.ts`.
// and add a `zulip-code-block` class to it to detect it easily in `compose_paste.ts`.
$content.find("div.codehilite").each(function (): void {
const $codehilite = $(this);
const $pre = $codehilite.find("pre");

View File

@@ -26,6 +26,7 @@ import * as common from "./common.ts";
import * as compose from "./compose.js";
import * as compose_closed_ui from "./compose_closed_ui.ts";
import * as compose_notifications from "./compose_notifications.ts";
import * as compose_paste from "./compose_paste.ts";
import * as compose_pm_pill from "./compose_pm_pill.ts";
import * as compose_recipient from "./compose_recipient.ts";
import * as compose_reply from "./compose_reply.ts";
@@ -35,7 +36,6 @@ import * as compose_textarea from "./compose_textarea.ts";
import * as compose_tooltips from "./compose_tooltips.ts";
import * as composebox_typeahead from "./composebox_typeahead.ts";
import * as condense from "./condense.ts";
import * as copy_and_paste from "./copy_and_paste.ts";
import * as desktop_integration from "./desktop_integration.ts";
import * as desktop_notifications from "./desktop_notifications.ts";
import * as drafts from "./drafts.ts";
@@ -560,7 +560,7 @@ export function initialize_everything(state_data) {
add_stream_options_popover.initialize();
click_handlers.initialize();
scheduled_messages_overlay_ui.initialize();
copy_and_paste.initialize();
compose_paste.initialize();
overlays.initialize();
invite.initialize();
message_view_header.initialize();

View File

@@ -12,7 +12,7 @@ const {run_test} = require("./lib/test.cjs");
const {window} = new JSDOM();
const copy_and_paste = zrequire("copy_and_paste");
const compose_paste = zrequire("compose_paste");
const stream_data = zrequire("stream_data");
set_global("document", {});
@@ -90,7 +90,7 @@ run_test("try_stream_topic_syntax_text", () => {
];
for (const test_case of test_cases) {
const result = copy_and_paste.try_stream_topic_syntax_text(test_case[0]);
const result = compose_paste.try_stream_topic_syntax_text(test_case[0]);
const expected = test_case[1] ?? null;
assert.equal(result, expected, "Failed for url: " + test_case[0]);
}
@@ -102,12 +102,12 @@ run_test("maybe_transform_html", () => {
let paste_text = `if ($preview_src.endsWith("&size=full"))`;
const escaped_paste_text = "if ($preview_src.endsWith(&quot;&amp;size=full&quot;))";
const expected_output = "<pre><code>" + escaped_paste_text + "</code></pre>";
assert.equal(copy_and_paste.maybe_transform_html(paste_html, paste_text), expected_output);
assert.equal(compose_paste.maybe_transform_html(paste_html, paste_text), expected_output);
// Untransformed HTML
paste_html = "<div><div>Hello</div><div>World!</div></div>";
paste_text = "Hello\nWorld!";
assert.equal(copy_and_paste.maybe_transform_html(paste_html, paste_text), paste_html);
assert.equal(compose_paste.maybe_transform_html(paste_html, paste_text), paste_html);
});
run_test("paste_handler_converter", () => {
@@ -119,14 +119,14 @@ run_test("paste_handler_converter", () => {
let input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><span style="color: hsl(0, 0%, 13%); font-family: arial, sans-serif; font-size: 12.8px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;"><span> </span>love the<span> </span><b>Zulip</b><b> </b></span><b style="color: hsl(0, 0%, 13%); font-family: arial, sans-serif; font-size: 12.8px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">Organization</b><span style="color: hsl(0, 0%, 13%); font-family: arial, sans-serif; font-size: 12.8px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">.</span>';
assert.equal(
copy_and_paste.paste_handler_converter(input),
compose_paste.paste_handler_converter(input),
" love the **Zulip** **Organization**.",
);
// Inline code
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><span style="color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">The<span> </span></span><code style="box-sizing: border-box; font-family: SFMono-Regular, Consolas, &quot;Liberation Mono&quot;, Menlo, Courier, monospace; font-size: 13.6px; padding: 0.2em 0.4em; margin: 0px; background-color: hsla(210, 13%, 12%, 0.05); border-radius: 3px; color: hsl(210, 12%, 16%); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">JSDOM</code><span style="color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"><span> </span>constructor</span>';
assert.equal(copy_and_paste.paste_handler_converter(input), "The `JSDOM` constructor");
assert.equal(compose_paste.paste_handler_converter(input), "The `JSDOM` constructor");
// A python code block
global.document = window.document;
@@ -134,20 +134,20 @@ run_test("paste_handler_converter", () => {
global.Node = window.Node;
input = `<meta http-equiv="content-type" content="text/html; charset=utf-8"><p>zulip code block in python</p><div class="codehilite zulip-code-block" data-code-language="Python"><pre><span></span><code><span class="nb">print</span><span class="p">(</span><span class="s2">"hello"</span><span class="p">)</span>\n<span class="nb">print</span><span class="p">(</span><span class="s2">"world"</span><span class="p">)</span></code></pre></div></meta>`;
assert.equal(
copy_and_paste.paste_handler_converter(input),
compose_paste.paste_handler_converter(input),
'zulip code block in python\n\n```Python\nprint("hello")\nprint("world")\n```',
);
// Single line in a code block
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(copy_and_paste.paste_handler_converter(input), "`single line`");
assert.equal(compose_paste.paste_handler_converter(input), "`single line`");
// No code formatting if the given text area has a backtick at the cursor position
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
copy_and_paste.paste_handler_converter(input, {
compose_paste.paste_handler_converter(input, {
caret: () => 6,
val: () => "e.g. `",
}),
@@ -158,7 +158,7 @@ run_test("paste_handler_converter", () => {
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
copy_and_paste.paste_handler_converter(input, {
compose_paste.paste_handler_converter(input, {
caret: () => 0,
}),
"`single line`",
@@ -168,7 +168,7 @@ run_test("paste_handler_converter", () => {
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><a href="https://zulip.readthedocs.io/en/latest/subsystems/logging.html" target="_blank" title="https://zulip.readthedocs.io/en/latest/subsystems/logging.html" style="color: hsl(200, 100%, 40%); text-decoration: none; cursor: pointer; font-family: &quot;Source Sans 3&quot;, &quot;Helvetica Neue&quot;, Helvetica, Arial, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%);">https://zulip.readthedocs.io/en/latest/subsystems/logging.html</a>';
assert.equal(
copy_and_paste.paste_handler_converter(input),
compose_paste.paste_handler_converter(input),
"https://zulip.readthedocs.io/en/latest/subsystems/logging.html",
);
@@ -176,37 +176,34 @@ run_test("paste_handler_converter", () => {
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><a class="reference external" href="https://zulip.readthedocs.io/en/latest/contributing/contributing.html" style="box-sizing: border-box; color: hsl(283, 39%, 53%); text-decoration: none; cursor: pointer; outline: 0px; font-family: Lato, proxima-nova, &quot;Helvetica Neue&quot;, Arial, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 99%);">Contributing guide</a>';
assert.equal(
copy_and_paste.paste_handler_converter(input),
compose_paste.paste_handler_converter(input),
"[Contributing guide](https://zulip.readthedocs.io/en/latest/contributing/contributing.html)",
);
// Only numbered list (list style retained)
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><ol><li>text</li></ol>';
assert.equal(copy_and_paste.paste_handler_converter(input), "1. text");
assert.equal(compose_paste.paste_handler_converter(input), "1. text");
// Heading
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><h1 style="box-sizing: border-box; font-size: 2em; margin-top: 0px !important; margin-right: 0px; margin-bottom: 16px; margin-left: 0px; font-weight: 600; line-height: 1.25; padding-bottom: 0.3em; border-bottom: 1px solid hsl(216, 14%, 93%); color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">Zulip overview</h1><p>normal text</p>';
assert.equal(copy_and_paste.paste_handler_converter(input), "# Zulip overview\n\nnormal text");
assert.equal(compose_paste.paste_handler_converter(input), "# Zulip overview\n\nnormal text");
// Only heading (strip heading style)
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><h1 style="box-sizing: border-box; font-size: 2em; margin-top: 0px !important; margin-right: 0px; margin-bottom: 16px; margin-left: 0px; font-weight: 600; line-height: 1.25; padding-bottom: 0.3em; border-bottom: 1px solid hsl(216, 14%, 93%); color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">Zulip overview</h1>';
assert.equal(copy_and_paste.paste_handler_converter(input), "Zulip overview");
assert.equal(compose_paste.paste_handler_converter(input), "Zulip overview");
// Italic text
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8">normal text <i style="box-sizing: inherit; color: hsl(0, 0%, 0%); font-family: Verdana, sans-serif; font-size: 15px; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">This text is italic</i>';
assert.equal(
copy_and_paste.paste_handler_converter(input),
"normal text *This text is italic*",
);
assert.equal(compose_paste.paste_handler_converter(input), "normal text *This text is italic*");
// Strikethrough text
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8">normal text <del style="box-sizing: inherit; color: hsl(0, 0%, 0%); font-family: Verdana, sans-serif; font-size: 15px; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">This text is struck through</del>';
assert.equal(
copy_and_paste.paste_handler_converter(input),
compose_paste.paste_handler_converter(input),
"normal text ~~This text is struck through~~",
);
@@ -214,7 +211,7 @@ run_test("paste_handler_converter", () => {
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><span style="color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">emojis:<span> </span></span><span aria-label="smile" class="emoji emoji-1f642" role="img" title="smile" style="height: 20px; width: 20px; position: relative; margin-top: -7px; vertical-align: middle; top: 3px; background-position: 55% 46.667%; display: inline-block; background-image: url(&quot;http://localhost:9991/webpack/files/generated/emoji/google.webp&quot;); background-size: 6100%; background-repeat: no-repeat; text-indent: 100%; white-space: nowrap; overflow: hidden; color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">:smile:</span><span style="color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"><span> </span></span><span aria-label="family man woman girl" class="emoji emoji-1f468-200d-1f469-200d-1f467" role="img" title="family man woman girl" style="height: 20px; width: 20px; position: relative; margin-top: -7px; vertical-align: middle; top: 3px; background-position: 23.333% 75%; display: inline-block; background-image: url(&quot;http://localhost:9991/webpack/files/generated/emoji/google.webp&quot;); background-size: 6100%; background-repeat: no-repeat; text-indent: 100%; white-space: nowrap; overflow: hidden; color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">:family_man_woman_girl:</span>';
assert.equal(
copy_and_paste.paste_handler_converter(input),
compose_paste.paste_handler_converter(input),
"emojis: :smile: :family_man_woman_girl:",
);
@@ -222,39 +219,39 @@ run_test("paste_handler_converter", () => {
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><ul style="padding: 0px; margin: 0px 0px 5px 20px; color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><li style="line-height: inherit;">bulleted</li><li style="line-height: inherit;">nested<ul style="padding: 0px; margin: 2px 0px 5px 20px;"><li style="line-height: inherit;">nested level 1</li><li style="line-height: inherit;">nested level 1 continue<ul style="padding: 0px; margin: 2px 0px 5px 20px;"><li style="line-height: inherit;">nested level 2</li><li style="line-height: inherit;">nested level 2 continue</li></ul></li></ul></li></ul>';
assert.equal(
copy_and_paste.paste_handler_converter(input),
compose_paste.paste_handler_converter(input),
"* bulleted\n* nested\n * nested level 1\n * nested level 1 continue\n * nested level 2\n * nested level 2 continue",
);
// 2 paragraphs with line break/s in between
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><p>paragraph 1</p><br><p>paragraph 2</p>';
assert.equal(copy_and_paste.paste_handler_converter(input), "paragraph 1\n\nparagraph 2");
assert.equal(compose_paste.paste_handler_converter(input), "paragraph 1\n\nparagraph 2");
// Pasting from external sources
// Pasting list from GitHub
input =
'<div class="preview-content"><div class="comment"><div class="comment-body markdown-body js-preview-body" style="min-height: 131px;"><p>Test list:</p><ul><li>Item 1</li><li>Item 2</li></ul></div></div></div>';
assert.equal(copy_and_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2");
assert.equal(compose_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2");
// Pasting list from VS Code
input =
'<div class="ace-line gutter-author-d-iz88z86z86za0dz67zz78zz78zz74zz68zjz80zz71z9iz90za3z66zs0z65zz65zq8z75zlaz81zcz66zj6g2mz78zz76zmz66z22z75zfcz69zz66z ace-ltr focused-line" dir="auto" id="editor-3-ace-line-41"><span>Test list:</span></div><div class="ace-line gutter-author-d-iz88z86z86za0dz67zz78zz78zz74zz68zjz80zz71z9iz90za3z66zs0z65zz65zq8z75zlaz81zcz66zj6g2mz78zz76zmz66z22z75zfcz69zz66z line-list-type-bullet ace-ltr" dir="auto" id="editor-3-ace-line-42"><ul class="listtype-bullet listindent1 list-bullet1"><li><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="end"></span><span class="ace-line-pocket" data-faketext="" contenteditable="false"></span><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="start"></span><span>Item 1</span></li></ul></div><div class="ace-line gutter-author-d-iz88z86z86za0dz67zz78zz78zz74zz68zjz80zz71z9iz90za3z66zs0z65zz65zq8z75zlaz81zcz66zj6g2mz78zz76zmz66z22z75zfcz69zz66z line-list-type-bullet ace-ltr" dir="auto" id="editor-3-ace-line-43"><ul class="listtype-bullet listindent1 list-bullet1"><li><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="end"></span><span class="ace-line-pocket" data-faketext="" contenteditable="false"></span><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="start"></span><span>Item 2</span></li></ul></div>';
assert.equal(copy_and_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2");
assert.equal(compose_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2");
// Pasting from Google Sheets (remove <style> elements completely)
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><span style="font-size:10pt;font-family:Arial;font-style:normal;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:123}" data-sheets-userformat="{&quot;2&quot;:769,&quot;3&quot;:{&quot;1&quot;:0},&quot;11&quot;:3,&quot;12&quot;:0}">123</span>';
assert.equal(copy_and_paste.paste_handler_converter(input), "123");
assert.equal(compose_paste.paste_handler_converter(input), "123");
// Pasting from Excel
input = `<html xmlns:v="urn:schemas-microsoft-com:vml"\nxmlns:o="urn:schemas-microsoft-com:office:office"\nxmlns:x="urn:schemas-microsoft-com:office:excel"\nxmlns="http://www.w3.org/TR/REC-html40">\n<head>\n<meta http-equiv=Content-Type content="text/html; charset=utf-8">\n<meta name=ProgId content=Excel.Sheet>\n<meta name=Generator content="Microsoft Excel 15">\n<link id=Main-File rel=Main-File\nhref="file:///C:/Users/ADMINI~1/AppData/Local/Temp/msohtmlclip1/01/clip.htm">\n<link rel=File-List\nhref="file:///C:/Users/ADMINI~1/AppData/Local/Temp/msohtmlclip1/01/clip_filelist.xml">\n<style>\n<!--table\n {mso-displayed-decimal-separator:"\\.";\n mso-displayed-thousand-separator:"\\,";}\n@page\n {margin:.75in .7in .75in .7in;\n mso-header-margin:.3in;\n mso-footer-margin:.3in;}\ntr\n {mso-height-source:auto;}\ncol\n {mso-width-source:auto;}\nbr\n {mso-data-placement:same-cell;}\ntd\n {padding-top:1px;\n padding-right:1px;\n padding-left:1px;\n mso-ignore:padding;\n color:black;\n font-size:11.0pt;\n font-weight:400;\n font-style:normal;\n text-decoration:none;\n font-family:Calibri, sans-serif;\n mso-font-charset:0;\n mso-number-format:General;\n text-align:general;\n vertical-align:bottom;\n border:none;\n mso-background-source:auto;\n mso-pattern:auto;\n mso-protection:locked visible;\n white-space:nowrap;\n mso-rotate:0;}\n.xl65\n {mso-number-format:"_\\(\\0022$\\0022* \\#\\,\\#\\#0\\.00_\\)\\;_\\(\\0022$\\0022* \\\\\\(\\#\\,\\#\\#0\\.00\\\\\\)\\;_\\(\\0022$\\0022* \\0022-\\0022??_\\)\\;_\\(\\@_\\)";}\n-->\n</style>\n</head>\n<body link="#0563C1" vlink="#954F72">\n<table border=0 cellpadding=0 cellspacing=0 width=88 style='border-collapse:\n collapse;width:66pt'>\n<!--StartFragment-->\n <col width=88 style='mso-width-source:userset;mso-width-alt:3218;width:66pt'>\n <tr height=20 style='height:15.0pt'>\n <td height=20 class=xl65 width=88 style='height:15.0pt;width:66pt;font-size:\n 11.0pt;color:black;font-weight:400;text-decoration:none;text-underline-style:\n none;text-line-through:none;font-family:Calibri, sans-serif;border-top:.5pt solid #5B9BD5;\n border-right:none;border-bottom:none;border-left:none'><span\n style='mso-spacerun:yes'> </span>$<span style='mso-spacerun:yes'>\n </span>20.00 </td>\n </tr>\n <tr height=20 style='height:15.0pt'>\n <td height=20 class=xl65 style='height:15.0pt;font-size:11.0pt;color:black;\n font-weight:400;text-decoration:none;text-underline-style:none;text-line-through:\n none;font-family:Calibri, sans-serif;border-top:.5pt solid #5B9BD5;\n border-right:none;border-bottom:none;border-left:none'><span\n style='mso-spacerun:yes'> </span>$<span\n style='mso-spacerun:yes'> </span>7.00 </td>\n </tr>\n<!--EndFragment-->\n</table>\n</body>\n</html>`;
// Pasting from Excel using ^V should paste an image.
assert.ok(copy_and_paste.is_single_image(input));
assert.ok(compose_paste.is_single_image(input));
// Pasting from Excel using ^⇧V should paste formatted text.
assert.equal(copy_and_paste.paste_handler_converter(input), " \n\n$ 20.00\n\n$ 7.00");
assert.equal(compose_paste.paste_handler_converter(input), " \n\n$ 20.00\n\n$ 7.00");
// Math block tests
@@ -268,10 +265,7 @@ run_test("paste_handler_converter", () => {
for (const math_block_test of katex_tests.math_block_tests) {
input = math_block_test.input;
assert.equal(
copy_and_paste.paste_handler_converter(input),
math_block_test.expected_output,
);
assert.equal(compose_paste.paste_handler_converter(input), math_block_test.expected_output);
}
// This next batch of tests round-trips the LaTeX syntax through
@@ -289,7 +283,7 @@ run_test("paste_handler_converter", () => {
helper_config: dummy_helper_config,
}).content;
assert.equal(
copy_and_paste.paste_handler_converter(paste_html),
compose_paste.paste_handler_converter(paste_html),
inline_math_expression_test.expected_output,
);
}
@@ -300,7 +294,7 @@ run_test("paste_handler_converter", () => {
helper_config: dummy_helper_config,
}).content;
assert.equal(
copy_and_paste.paste_handler_converter(paste_html),
compose_paste.paste_handler_converter(paste_html),
span_conversion_test.expected_output,
);
}