copy: Use a global copy event listener.

This attempts to use a global copy event listener instead of
triggering the copy handler on Ctrl+C, as this is more robust way to
use browser APIs, including not intefering if end users choose to
remap copying keys on their keyboard.

This has various beneficial side effects, including copying from other
Markdown elements like the preview or drafts UI using the same code as
copying from the message feed.

Fixes: #33949.
This commit is contained in:
apoorvapendse
2025-03-14 12:01:51 +05:30
committed by Tim Abbott
parent 353f57e518
commit 11028d5244
4 changed files with 30 additions and 62 deletions

View File

@@ -24,24 +24,22 @@ async function copy_messages(
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(selectedRange);
// Remove existing copy/paste divs, which may linger from the previous
// example. (The code clears these out with a zero-second timeout, which
// is probably sufficient for human users, but which causes problems here.)
document.querySelector("#copytempdiv")?.remove();
// emulate copy event
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "c",
code: "KeyC",
ctrlKey: true,
keyCode: 67,
which: 67,
}),
);
const clipboard_data = new DataTransfer();
const copy_event = new ClipboardEvent("copy", {
bubbles: true,
cancelable: true,
clipboardData: clipboard_data,
});
document.dispatchEvent(copy_event);
// find temp div with copied text
return [...document.querySelectorAll("#copytempdiv > p")].map((p) => p.textContent!);
const copied_html = clipboard_data.getData("text/html");
// Convert the copied HTML into separate message strings
const parser = new DOMParser();
const doc = parser.parseFromString(copied_html, "text/html");
return [...doc.body.children].map((el) => el.textContent!.trim());
},
start_message,
end_message,

View File

@@ -6,7 +6,6 @@ import assert from "minimalistic-assert";
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,
@@ -108,35 +107,6 @@ function construct_copy_div($div: JQuery, start_id: number, end_id: number): voi
}
}
function insert_and_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 restore_original_selection(ranges: Range[]): void {
// Should be called inside a setTimeout(..., 0).
const selection = window.getSelection();
assert(selection !== null);
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
}
// We want to grab the closest katex span up the tree
// in cases where we can resolve the selected katex expression
// from a math block into an inline expression.
@@ -199,7 +169,7 @@ function improve_katex_selection_range(selection: Selection): void {
}
}
export function copy_handler(): boolean {
export function copy_handler(ev: ClipboardEvent): boolean {
// 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
@@ -220,7 +190,6 @@ export function copy_handler(): boolean {
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;
@@ -245,7 +214,7 @@ export function copy_handler(): boolean {
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.
// same message and let the browser handle the copying.
return false;
}
@@ -261,16 +230,10 @@ export function copy_handler(): boolean {
const $div = $("<div>");
construct_copy_div($div, start_id, end_id);
// Select div so that the browser will copy it
// instead of copying the original selection
insert_and_select_div($div, selection);
// eslint-disable-next-line @typescript-eslint/no-deprecated
document.execCommand("copy");
setTimeout(() => {
restore_original_selection(ranges);
$div.remove();
}, 0);
const html_content = $div.html().trim();
const plain_text = $div.text().trim();
ev.clipboardData?.setData("text/html", html_content);
ev.clipboardData?.setData("text/plain", plain_text);
// Tell the keyboard code that we did the copy ourselves, and thus
// the browser should not handle the copy.
@@ -394,3 +357,11 @@ function get_end_tr_from_endc($endc: JQuery<Node>): JQuery {
return $endc.parents(".selectable_row").first();
}
export function initialize(): void {
document.addEventListener("copy", (ev) => {
if (copy_handler(ev)) {
ev.preventDefault();
}
});
}

View File

@@ -15,7 +15,6 @@ 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_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";
@@ -1143,8 +1142,6 @@ export function process_hotkey(e, hotkey) {
message_scroll_state.set_keyboard_triggered_current_scroll(true);
navigate.page_down();
return true;
case "copy_with_c":
return copy_messages.copy_handler();
}
if (

View File

@@ -37,6 +37,7 @@ import * as compose_tooltips from "./compose_tooltips.ts";
import * as compose_validate from "./compose_validate.ts";
import * as composebox_typeahead from "./composebox_typeahead.ts";
import * as condense from "./condense.ts";
import * as copy_messages from "./copy_messages.ts";
import * as desktop_integration from "./desktop_integration.ts";
import * as desktop_notifications from "./desktop_notifications.ts";
import * as drafts from "./drafts.ts";
@@ -599,6 +600,7 @@ export function initialize_everything(state_data) {
playground_data: realm.realm_playgrounds,
pygments_comparator_func: typeahead_helper.compare_language,
});
copy_messages.initialize();
compose_setup.initialize();
// Typeahead must be initialized after compose_setup.initialize()
composebox_typeahead.initialize({