mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
popovers: Add x-gfm content type for copied Zulip links.
Copying links to a Zulip element now generate 3 content types: - plain url, pasteable in address bar and non rich text editor - HTML, for rich text editors. - x-gfm, for pasting into GitHub and other services that support Markdown, new in this PR. Fixes: #31813.
This commit is contained in:
committed by
Tim Abbott
parent
6619b3b589
commit
c8983c0d31
69
web/src/clipboard_handler.ts
Normal file
69
web/src/clipboard_handler.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from "minimalistic-assert";
|
||||
|
||||
import * as blueslip from "./blueslip.ts";
|
||||
import * as hash_util from "./hash_util.ts";
|
||||
import * as popover_menus from "./popover_menus.ts";
|
||||
import * as stream_data from "./stream_data.ts";
|
||||
import * as topic_link_util from "./topic_link_util.ts";
|
||||
|
||||
// The standard Clipboard API do not support custom mime types like
|
||||
// text/x-gfm, but this approach does.
|
||||
function execute_copy(handle_copy_event: (e: ClipboardEvent) => void): void {
|
||||
document.addEventListener("copy", handle_copy_event);
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
document.execCommand("copy");
|
||||
document.removeEventListener("copy", handle_copy_event);
|
||||
}
|
||||
|
||||
export async function copy_link_to_clipboard(link: string): Promise<void> {
|
||||
// The caller is responsible for making sure what it is passes in
|
||||
// to this function is a Zulip internal link.
|
||||
return new Promise((resolve) => {
|
||||
const stream_topic_details = hash_util.decode_stream_topic_from_url(link);
|
||||
|
||||
function handle_copy_event(e: ClipboardEvent): void {
|
||||
if (stream_topic_details === null) {
|
||||
e.clipboardData?.setData("text/plain", link);
|
||||
} else {
|
||||
const stream = stream_data.get_sub_by_id(stream_topic_details.stream_id);
|
||||
assert(stream !== undefined);
|
||||
const {text} = topic_link_util.get_topic_link_content(
|
||||
stream.name,
|
||||
stream_topic_details.topic_name,
|
||||
stream_topic_details.message_id,
|
||||
);
|
||||
|
||||
const copy_in_html_syntax = topic_link_util.as_html_link_syntax_unsafe(text, link);
|
||||
const copy_in_markdown_syntax = topic_link_util.as_markdown_link_syntax(text, link);
|
||||
|
||||
e.clipboardData?.setData("text/plain", link);
|
||||
e.clipboardData?.setData("text/html", copy_in_html_syntax);
|
||||
e.clipboardData?.setData("text/x-gfm", copy_in_markdown_syntax);
|
||||
}
|
||||
e.preventDefault();
|
||||
resolve();
|
||||
}
|
||||
execute_copy(handle_copy_event);
|
||||
});
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export function popover_copy_link_to_clipboard(
|
||||
instance: typeof popover_menus.popover_instances.message_actions,
|
||||
$element: JQuery,
|
||||
success_callback?: () => void,
|
||||
): void {
|
||||
// Wrapper for copy_link_to_clipboard handling closing a popover
|
||||
// and error handling.
|
||||
const clipboard_text = String($element.attr("data-clipboard-text"));
|
||||
void copy_link_to_clipboard(clipboard_text)
|
||||
.then(() => {
|
||||
popover_menus.hide_current_popover_if_visible(instance);
|
||||
if (success_callback !== undefined) {
|
||||
success_callback();
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
blueslip.error("Failed to copy to clipboard: ", {error: String(error)});
|
||||
});
|
||||
}
|
@@ -1,9 +1,9 @@
|
||||
import ClipboardJS from "clipboard";
|
||||
import $ from "jquery";
|
||||
import assert from "minimalistic-assert";
|
||||
|
||||
import render_message_actions_popover from "../templates/popovers/message_actions_popover.hbs";
|
||||
|
||||
import * as clipboard_handler from "./clipboard_handler.ts";
|
||||
import * as compose_reply from "./compose_reply.ts";
|
||||
import * as condense from "./condense.ts";
|
||||
import {show_copied_confirmation} from "./copied_tooltip.ts";
|
||||
@@ -228,14 +228,17 @@ export function initialize(): void {
|
||||
popover_menus.hide_current_popover_if_visible(instance);
|
||||
});
|
||||
|
||||
new ClipboardJS(the($popper.find(".copy_link"))).on("success", () => {
|
||||
show_copied_confirmation(the($(instance.reference).closest(".message_controls")));
|
||||
setTimeout(() => {
|
||||
// The Clipboard library works by focusing to a hidden textarea.
|
||||
// We unfocus this so keyboard shortcuts, etc., will work again.
|
||||
$(":focus").trigger("blur");
|
||||
}, 0);
|
||||
popover_menus.hide_current_popover_if_visible(instance);
|
||||
$popper.on("click", ".copy_link", (e) => {
|
||||
assert(e.currentTarget instanceof HTMLElement);
|
||||
clipboard_handler.popover_copy_link_to_clipboard(
|
||||
instance,
|
||||
$(e.currentTarget),
|
||||
() => {
|
||||
show_copied_confirmation(
|
||||
the($(instance.reference).closest(".message_controls")),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
onHidden(instance) {
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import ClipboardJS from "clipboard";
|
||||
import $ from "jquery";
|
||||
import assert from "minimalistic-assert";
|
||||
import type * as tippy from "tippy.js";
|
||||
@@ -12,6 +11,7 @@ import render_left_sidebar_stream_actions_popover from "../templates/popovers/le
|
||||
import * as blueslip from "./blueslip.ts";
|
||||
import type {Typeahead} from "./bootstrap_typeahead.ts";
|
||||
import * as browser_history from "./browser_history.ts";
|
||||
import * as clipboard_handler from "./clipboard_handler.ts";
|
||||
import * as composebox_typeahead from "./composebox_typeahead.ts";
|
||||
import * as dialog_widget from "./dialog_widget.ts";
|
||||
import * as dropdown_widget from "./dropdown_widget.ts";
|
||||
@@ -195,8 +195,9 @@ function build_stream_popover(opts: {elt: HTMLElement; stream_id: number}): void
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
new ClipboardJS(util.the($popper.find(".copy_stream_link"))).on("success", () => {
|
||||
popover_menus.hide_current_popover_if_visible(instance);
|
||||
$popper.on("click", ".copy_stream_link", (e) => {
|
||||
assert(e.currentTarget instanceof HTMLElement);
|
||||
clipboard_handler.popover_copy_link_to_clipboard(instance, $(e.currentTarget));
|
||||
});
|
||||
},
|
||||
onHidden(instance) {
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import ClipboardJS from "clipboard";
|
||||
import $ from "jquery";
|
||||
import assert from "minimalistic-assert";
|
||||
import type * as tippy from "tippy.js";
|
||||
@@ -6,6 +5,7 @@ import type * as tippy from "tippy.js";
|
||||
import render_delete_topic_modal from "../templates/confirm_dialog/confirm_delete_topic.hbs";
|
||||
import render_left_sidebar_topic_actions_popover from "../templates/popovers/left_sidebar/left_sidebar_topic_actions_popover.hbs";
|
||||
|
||||
import * as clipboard_handler from "./clipboard_handler.ts";
|
||||
import * as confirm_dialog from "./confirm_dialog.ts";
|
||||
import {$t_html} from "./i18n.ts";
|
||||
import * as message_edit from "./message_edit.ts";
|
||||
@@ -198,12 +198,10 @@ export function initialize(): void {
|
||||
popover_menus.hide_current_popover_if_visible(instance);
|
||||
});
|
||||
|
||||
new ClipboardJS(util.the($popper.find(".sidebar-popover-copy-link-to-topic"))).on(
|
||||
"success",
|
||||
() => {
|
||||
popover_menus.hide_current_popover_if_visible(instance);
|
||||
},
|
||||
);
|
||||
$popper.on("click", ".sidebar-popover-copy-link-to-topic", (e) => {
|
||||
assert(e.currentTarget instanceof HTMLElement);
|
||||
clipboard_handler.popover_copy_link_to_clipboard(instance, $(e.currentTarget));
|
||||
});
|
||||
},
|
||||
onHidden(instance) {
|
||||
const $elt = $(instance.reference).closest(".recent_view_focusable");
|
||||
|
121
web/tests/clipboard_handler.test.cjs
Normal file
121
web/tests/clipboard_handler.test.cjs
Normal file
@@ -0,0 +1,121 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {JSDOM} = require("jsdom");
|
||||
|
||||
const {zrequire} = require("./lib/namespace.cjs");
|
||||
const {run_test} = require("./lib/test.cjs");
|
||||
|
||||
const clipboard_handler = zrequire("clipboard_handler");
|
||||
const stream_data = zrequire("stream_data");
|
||||
const people = zrequire("people");
|
||||
|
||||
const hamlet = {
|
||||
user_id: 15,
|
||||
email: "hamlet@example.com",
|
||||
full_name: "Hamlet",
|
||||
};
|
||||
|
||||
people.add_active_user(hamlet);
|
||||
|
||||
run_test("copy_link_to_clipboard", async ({override}) => {
|
||||
const stream = {
|
||||
name: "Stream",
|
||||
description: "Color and Lights",
|
||||
stream_id: 1,
|
||||
subscribed: true,
|
||||
type: "stream",
|
||||
};
|
||||
stream_data.add_sub(stream);
|
||||
const {window} = new JSDOM();
|
||||
global.document = window.document;
|
||||
|
||||
// Mock DataTransfer for testing purposes
|
||||
class MockDataTransfer {
|
||||
constructor() {
|
||||
this.data = {};
|
||||
}
|
||||
|
||||
setData(type, value) {
|
||||
this.data[type] = value;
|
||||
}
|
||||
|
||||
getData(type) {
|
||||
return this.data[type] || "";
|
||||
}
|
||||
}
|
||||
|
||||
// Store the copy event callback and clipboardData
|
||||
let clipboardData;
|
||||
let copyEventCallback;
|
||||
override(window.document, "addEventListener", (event, callback) => {
|
||||
if (event === "copy") {
|
||||
copyEventCallback = callback;
|
||||
}
|
||||
});
|
||||
|
||||
// Stub execCommand to trigger the copy event
|
||||
override(window.document, "execCommand", (command) => {
|
||||
if (command === "copy" && copyEventCallback) {
|
||||
const copyEvent = new window.Event("copy", {bubbles: true, cancelable: true});
|
||||
copyEvent.clipboardData = new MockDataTransfer();
|
||||
copyEventCallback(copyEvent);
|
||||
clipboardData = copyEvent.clipboardData;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Helper function to simulate clipboard handling
|
||||
async function simulateClipboardData(link) {
|
||||
await clipboard_handler.copy_link_to_clipboard(link);
|
||||
return {
|
||||
plainText: clipboardData.getData("text/plain"),
|
||||
htmlText: clipboardData.getData("text/html"),
|
||||
markdownText: clipboardData.getData("text/x-gfm"),
|
||||
};
|
||||
}
|
||||
|
||||
const normal_stream_with_topic =
|
||||
"http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic";
|
||||
const normal_stream_with_topic_and_message =
|
||||
"http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic/near/1";
|
||||
const normal_stream = "http://zulip.zulipdev.com/#narrow/channel/1-Stream/";
|
||||
const dm_message = "http://zulip.zulipdev.com/#narrow/dm/15-dm/near/43";
|
||||
|
||||
let clipboardDataResult = await simulateClipboardData(normal_stream_with_topic);
|
||||
assert.equal(clipboardDataResult.plainText, normal_stream_with_topic);
|
||||
assert.equal(
|
||||
clipboardDataResult.htmlText,
|
||||
`<a href="http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic">#Stream > normal topic</a>`,
|
||||
);
|
||||
assert.equal(
|
||||
clipboardDataResult.markdownText,
|
||||
`[#Stream > normal topic](http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic)`,
|
||||
);
|
||||
|
||||
clipboardDataResult = await simulateClipboardData(normal_stream_with_topic_and_message);
|
||||
assert.equal(clipboardDataResult.plainText, normal_stream_with_topic_and_message);
|
||||
assert.equal(
|
||||
clipboardDataResult.htmlText,
|
||||
`<a href="http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic/near/1">#Stream > normal topic @ 💬</a>`,
|
||||
);
|
||||
assert.equal(
|
||||
clipboardDataResult.markdownText,
|
||||
`[#Stream > normal topic @ 💬](http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic/near/1)`,
|
||||
);
|
||||
|
||||
clipboardDataResult = await simulateClipboardData(normal_stream);
|
||||
assert.equal(clipboardDataResult.plainText, normal_stream);
|
||||
assert.equal(
|
||||
clipboardDataResult.htmlText,
|
||||
`<a href="http://zulip.zulipdev.com/#narrow/channel/1-Stream/">#Stream</a>`,
|
||||
);
|
||||
assert.equal(
|
||||
clipboardDataResult.markdownText,
|
||||
`[#Stream](http://zulip.zulipdev.com/#narrow/channel/1-Stream/)`,
|
||||
);
|
||||
|
||||
clipboardDataResult = await simulateClipboardData(dm_message);
|
||||
assert.equal(clipboardDataResult.plainText, dm_message);
|
||||
});
|
Reference in New Issue
Block a user