2023-08-03 20:01:10 +02:00
|
|
|
import isUrl from "is-url";
|
2021-03-11 05:43:45 +01:00
|
|
|
import $ from "jquery";
|
2021-04-23 21:01:56 +02:00
|
|
|
import TurndownService from "turndown";
|
2019-11-11 23:29:45 +01:00
|
|
|
|
2021-02-28 01:26:48 +01:00
|
|
|
import * as compose_ui from "./compose_ui";
|
2021-03-30 02:21:21 +02:00
|
|
|
import * as message_lists from "./message_lists";
|
2021-03-25 22:35:45 +01:00
|
|
|
import {page_params} from "./page_params";
|
2021-02-28 00:42:30 +01:00
|
|
|
import * as rows from "./rows";
|
|
|
|
|
2022-01-25 11:36:19 +01:00
|
|
|
function find_boundary_tr($initial_tr, iterate_row) {
|
2019-11-02 00:06:25 +01:00
|
|
|
let j;
|
|
|
|
let skip_same_td_check = false;
|
2022-01-25 11:36:19 +01:00
|
|
|
let $tr = $initial_tr;
|
2014-03-13 22:07:56 +01:00
|
|
|
|
|
|
|
// 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
|
2022-01-25 11:36:19 +01:00
|
|
|
if ($tr.length === 0) {
|
2020-09-24 07:50:36 +02:00
|
|
|
return undefined;
|
2014-03-13 22:07:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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.
|
2022-01-25 11:36:19 +01:00
|
|
|
for (j = 0; !$tr.is(".message_row") && j < 10; j += 1) {
|
|
|
|
$tr = iterate_row($tr);
|
2014-03-13 22:07:56 +01:00
|
|
|
}
|
|
|
|
if (j === 10) {
|
2020-09-24 07:50:36 +02:00
|
|
|
return undefined;
|
2014-03-13 22:07:56 +01:00
|
|
|
} 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;
|
|
|
|
}
|
2022-01-25 11:36:19 +01:00
|
|
|
return [rows.id($tr), skip_same_td_check];
|
2014-03-13 22:07:56 +01:00
|
|
|
}
|
|
|
|
|
2022-01-25 11:36:19 +01:00
|
|
|
function construct_recipient_header($message_row) {
|
2020-07-15 00:34:28 +02:00
|
|
|
const message_header_content = rows
|
2022-01-25 11:36:19 +01:00
|
|
|
.get_message_recipient_header($message_row)
|
2018-05-06 21:43:17 +02:00
|
|
|
.text()
|
2023-05-11 21:49:10 +02:00
|
|
|
.replaceAll(/\s+/g, " ")
|
2020-07-15 00:34:28 +02:00
|
|
|
.replace(/^\s/, "")
|
|
|
|
.replace(/\s$/, "");
|
2020-07-15 01:29:15 +02:00
|
|
|
return $("<p>").append($("<strong>").text(message_header_content));
|
2018-03-20 19:25:01 +01:00
|
|
|
}
|
|
|
|
|
2019-03-30 00:30:39 +01:00
|
|
|
/*
|
|
|
|
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.
|
|
|
|
*/
|
2022-01-25 11:36:19 +01:00
|
|
|
function construct_copy_div($div, start_id, end_id) {
|
2020-04-02 23:34:54 +02:00
|
|
|
const copy_rows = rows.visible_range(start_id, end_id);
|
|
|
|
|
2022-01-25 11:36:19 +01:00
|
|
|
const $start_row = copy_rows[0];
|
|
|
|
const $start_recipient_row = rows.get_message_recipient_row($start_row);
|
|
|
|
const start_recipient_row_id = rows.id_for_recipient_row($start_recipient_row);
|
2019-11-02 00:06:25 +01:00
|
|
|
let should_include_start_recipient_header = false;
|
|
|
|
let last_recipient_row_id = start_recipient_row_id;
|
2020-04-02 23:34:54 +02:00
|
|
|
|
2022-01-25 11:36:19 +01:00
|
|
|
for (const $row of copy_rows) {
|
|
|
|
const recipient_row_id = rows.id_for_recipient_row(rows.get_message_recipient_row($row));
|
2018-03-20 19:32:38 +01:00
|
|
|
// 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) {
|
2022-01-25 11:36:19 +01:00
|
|
|
$div.append(construct_recipient_header($row));
|
2018-03-20 19:32:38 +01:00
|
|
|
last_recipient_row_id = recipient_row_id;
|
|
|
|
should_include_start_recipient_header = true;
|
2018-03-20 18:49:42 +01:00
|
|
|
}
|
2022-01-25 11:36:19 +01:00
|
|
|
const message = message_lists.current.get(rows.id($row));
|
2022-08-26 08:01:32 +02:00
|
|
|
const $content = $(message.content);
|
|
|
|
$content.first().prepend(message.sender_full_name + ": ");
|
|
|
|
$div.append($content);
|
2018-03-20 18:49:42 +01:00
|
|
|
}
|
2018-03-20 19:32:38 +01:00
|
|
|
|
|
|
|
if (should_include_start_recipient_header) {
|
2022-01-25 11:36:19 +01:00
|
|
|
$div.prepend(construct_recipient_header($start_row));
|
2018-03-20 19:32:38 +01:00
|
|
|
}
|
2018-03-20 18:49:42 +01:00
|
|
|
}
|
2014-03-13 22:07:56 +01:00
|
|
|
|
2022-01-25 11:36:19 +01:00
|
|
|
function select_div($div, selection) {
|
|
|
|
$div.css({
|
2020-07-15 01:29:15 +02:00
|
|
|
position: "absolute",
|
|
|
|
left: "-99999px",
|
2021-11-26 10:29:05 +01:00
|
|
|
// Color and background is made according to "light theme"
|
2019-03-30 00:30:39 +01:00
|
|
|
// 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
|
2021-11-26 08:32:40 +01:00
|
|
|
// background when using the app in dark theme.
|
2019-03-30 00:30:39 +01:00
|
|
|
// We can avoid other custom styles since they are wrapped
|
|
|
|
// inside another parent such as `.message_content`.
|
2020-07-15 01:29:15 +02:00
|
|
|
color: "#333",
|
|
|
|
background: "#FFF",
|
2020-07-15 00:34:28 +02:00
|
|
|
}).attr("id", "copytempdiv");
|
2022-01-25 11:36:19 +01:00
|
|
|
$("body").append($div);
|
|
|
|
selection.selectAllChildren($div[0]);
|
2019-03-30 00:30:39 +01:00
|
|
|
}
|
|
|
|
|
2023-06-29 21:59:08 +02:00
|
|
|
function remove_div(_div, ranges, selection) {
|
2020-07-02 01:45:54 +02:00
|
|
|
window.setTimeout(() => {
|
2019-03-30 00:30:39 +01:00
|
|
|
selection = window.getSelection();
|
|
|
|
selection.removeAllRanges();
|
js: Automatically convert _.each to for…of.
This commit was automatically generated by the following script,
followed by lint --fix and a few small manual lint-related cleanups.
import * as babelParser from "recast/parsers/babel";
import * as recast from "recast";
import * as tsParser from "recast/parsers/typescript";
import { builders as b, namedTypes as n } from "ast-types";
import { Context } from "ast-types/lib/path-visitor";
import K from "ast-types/gen/kinds";
import { NodePath } from "ast-types/lib/node-path";
import assert from "assert";
import fs from "fs";
import path from "path";
import process from "process";
const checkExpression = (node: n.Node): node is K.ExpressionKind =>
n.Expression.check(node);
const checkStatement = (node: n.Node): node is K.StatementKind =>
n.Statement.check(node);
for (const file of process.argv.slice(2)) {
console.log("Parsing", file);
const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), {
parser: path.extname(file) === ".ts" ? tsParser : babelParser,
});
let changed = false;
let inLoop = false;
let replaceReturn = false;
const visitLoop = (...args: string[]) =>
function(this: Context, path: NodePath) {
for (const arg of args) {
this.visit(path.get(arg));
}
const old = { inLoop };
inLoop = true;
this.visit(path.get("body"));
inLoop = old.inLoop;
return false;
};
recast.visit(ast, {
visitDoWhileStatement: visitLoop("test"),
visitExpressionStatement(path) {
const { expression, comments } = path.node;
let valueOnly;
if (
n.CallExpression.check(expression) &&
n.MemberExpression.check(expression.callee) &&
!expression.callee.computed &&
n.Identifier.check(expression.callee.object) &&
expression.callee.object.name === "_" &&
n.Identifier.check(expression.callee.property) &&
["each", "forEach"].includes(expression.callee.property.name) &&
[2, 3].includes(expression.arguments.length) &&
checkExpression(expression.arguments[0]) &&
(n.FunctionExpression.check(expression.arguments[1]) ||
n.ArrowFunctionExpression.check(expression.arguments[1])) &&
[1, 2].includes(expression.arguments[1].params.length) &&
n.Identifier.check(expression.arguments[1].params[0]) &&
((valueOnly = expression.arguments[1].params[1] === undefined) ||
n.Identifier.check(expression.arguments[1].params[1])) &&
(expression.arguments[2] === undefined ||
n.ThisExpression.check(expression.arguments[2]))
) {
const old = { inLoop, replaceReturn };
inLoop = false;
replaceReturn = true;
this.visit(
path
.get("expression")
.get("arguments")
.get(1)
.get("body")
);
inLoop = old.inLoop;
replaceReturn = old.replaceReturn;
const [right, { body, params }] = expression.arguments;
const loop = b.forOfStatement(
b.variableDeclaration("let", [
b.variableDeclarator(
valueOnly ? params[0] : b.arrayPattern([params[1], params[0]])
),
]),
valueOnly
? right
: b.callExpression(
b.memberExpression(right, b.identifier("entries")),
[]
),
checkStatement(body) ? body : b.expressionStatement(body)
);
loop.comments = comments;
path.replace(loop);
changed = true;
}
this.traverse(path);
},
visitForStatement: visitLoop("init", "test", "update"),
visitForInStatement: visitLoop("left", "right"),
visitForOfStatement: visitLoop("left", "right"),
visitFunction(path) {
this.visit(path.get("params"));
const old = { replaceReturn };
replaceReturn = false;
this.visit(path.get("body"));
replaceReturn = old.replaceReturn;
return false;
},
visitReturnStatement(path) {
if (replaceReturn) {
assert(!inLoop); // could use labeled continue if this ever fires
const { argument, comments } = path.node;
if (argument === null) {
const s = b.continueStatement();
s.comments = comments;
path.replace(s);
} else {
const s = b.expressionStatement(argument);
s.comments = comments;
path.replace(s, b.continueStatement());
}
return false;
}
this.traverse(path);
},
visitWhileStatement: visitLoop("test"),
});
if (changed) {
console.log("Writing", file);
fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" });
}
}
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
|
|
|
|
|
|
|
for (const range of ranges) {
|
2019-03-30 00:30:39 +01:00
|
|
|
selection.addRange(range);
|
js: Automatically convert _.each to for…of.
This commit was automatically generated by the following script,
followed by lint --fix and a few small manual lint-related cleanups.
import * as babelParser from "recast/parsers/babel";
import * as recast from "recast";
import * as tsParser from "recast/parsers/typescript";
import { builders as b, namedTypes as n } from "ast-types";
import { Context } from "ast-types/lib/path-visitor";
import K from "ast-types/gen/kinds";
import { NodePath } from "ast-types/lib/node-path";
import assert from "assert";
import fs from "fs";
import path from "path";
import process from "process";
const checkExpression = (node: n.Node): node is K.ExpressionKind =>
n.Expression.check(node);
const checkStatement = (node: n.Node): node is K.StatementKind =>
n.Statement.check(node);
for (const file of process.argv.slice(2)) {
console.log("Parsing", file);
const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), {
parser: path.extname(file) === ".ts" ? tsParser : babelParser,
});
let changed = false;
let inLoop = false;
let replaceReturn = false;
const visitLoop = (...args: string[]) =>
function(this: Context, path: NodePath) {
for (const arg of args) {
this.visit(path.get(arg));
}
const old = { inLoop };
inLoop = true;
this.visit(path.get("body"));
inLoop = old.inLoop;
return false;
};
recast.visit(ast, {
visitDoWhileStatement: visitLoop("test"),
visitExpressionStatement(path) {
const { expression, comments } = path.node;
let valueOnly;
if (
n.CallExpression.check(expression) &&
n.MemberExpression.check(expression.callee) &&
!expression.callee.computed &&
n.Identifier.check(expression.callee.object) &&
expression.callee.object.name === "_" &&
n.Identifier.check(expression.callee.property) &&
["each", "forEach"].includes(expression.callee.property.name) &&
[2, 3].includes(expression.arguments.length) &&
checkExpression(expression.arguments[0]) &&
(n.FunctionExpression.check(expression.arguments[1]) ||
n.ArrowFunctionExpression.check(expression.arguments[1])) &&
[1, 2].includes(expression.arguments[1].params.length) &&
n.Identifier.check(expression.arguments[1].params[0]) &&
((valueOnly = expression.arguments[1].params[1] === undefined) ||
n.Identifier.check(expression.arguments[1].params[1])) &&
(expression.arguments[2] === undefined ||
n.ThisExpression.check(expression.arguments[2]))
) {
const old = { inLoop, replaceReturn };
inLoop = false;
replaceReturn = true;
this.visit(
path
.get("expression")
.get("arguments")
.get(1)
.get("body")
);
inLoop = old.inLoop;
replaceReturn = old.replaceReturn;
const [right, { body, params }] = expression.arguments;
const loop = b.forOfStatement(
b.variableDeclaration("let", [
b.variableDeclarator(
valueOnly ? params[0] : b.arrayPattern([params[1], params[0]])
),
]),
valueOnly
? right
: b.callExpression(
b.memberExpression(right, b.identifier("entries")),
[]
),
checkStatement(body) ? body : b.expressionStatement(body)
);
loop.comments = comments;
path.replace(loop);
changed = true;
}
this.traverse(path);
},
visitForStatement: visitLoop("init", "test", "update"),
visitForInStatement: visitLoop("left", "right"),
visitForOfStatement: visitLoop("left", "right"),
visitFunction(path) {
this.visit(path.get("params"));
const old = { replaceReturn };
replaceReturn = false;
this.visit(path.get("body"));
replaceReturn = old.replaceReturn;
return false;
},
visitReturnStatement(path) {
if (replaceReturn) {
assert(!inLoop); // could use labeled continue if this ever fires
const { argument, comments } = path.node;
if (argument === null) {
const s = b.continueStatement();
s.comments = comments;
path.replace(s);
} else {
const s = b.expressionStatement(argument);
s.comments = comments;
path.replace(s, b.continueStatement());
}
return false;
}
this.traverse(path);
},
visitWhileStatement: visitLoop("test"),
});
if (changed) {
console.log("Writing", file);
fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" });
}
}
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
|
|
|
}
|
|
|
|
|
2020-07-15 01:29:15 +02:00
|
|
|
$("#copytempdiv").remove();
|
2019-03-30 00:30:39 +01:00
|
|
|
}, 0);
|
|
|
|
}
|
|
|
|
|
2021-02-10 16:58:56 +01:00
|
|
|
export function copy_handler() {
|
2019-03-12 11:24:32 +01:00
|
|
|
// This is the main handler for copying message content via
|
2020-08-11 02:09:14 +02:00
|
|
|
// `Ctrl+C` in Zulip (note that this is totally independent of the
|
2019-03-12 11:24:32 +01:00
|
|
|
// "select region" copy behavior on Linux; that is handled
|
|
|
|
// entirely by the browser, our HTML layout, and our use of the
|
2023-09-21 04:38:10 +02:00
|
|
|
// no-select CSS classes). We put considerable effort
|
2019-03-12 11:24:32 +01:00
|
|
|
// 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
|
2020-08-11 02:09:14 +02:00
|
|
|
// implement by letting the browser handle the Ctrl+C event.
|
2019-03-12 11:24:32 +01:00
|
|
|
//
|
|
|
|
// * Otherwise, we want to copy the bodies of all messages that
|
|
|
|
// were partially covered by the selection.
|
2019-03-23 12:30:31 +01:00
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
const selection = window.getSelection();
|
2021-02-10 16:58:56 +01:00
|
|
|
const analysis = analyze_selection(selection);
|
2019-11-02 00:06:25 +01:00
|
|
|
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;
|
2022-01-25 11:36:19 +01:00
|
|
|
const $div = $("<div>");
|
2019-03-23 12:30:31 +01:00
|
|
|
|
|
|
|
if (start_id === undefined || end_id === undefined) {
|
|
|
|
// 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.
|
2020-07-15 01:29:15 +02:00
|
|
|
document.execCommand("copy");
|
2019-03-23 12:30:31 +01:00
|
|
|
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.
|
2020-07-15 01:29:15 +02:00
|
|
|
document.execCommand("copy");
|
2019-03-23 12:30:31 +01:00
|
|
|
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`.
|
2022-01-25 11:36:19 +01:00
|
|
|
construct_copy_div($div, start_id, end_id);
|
2019-03-23 12:30:31 +01:00
|
|
|
|
|
|
|
// Select div so that the browser will copy it
|
|
|
|
// instead of copying the original selection
|
2022-01-25 11:36:19 +01:00
|
|
|
select_div($div, selection);
|
2020-07-15 01:29:15 +02:00
|
|
|
document.execCommand("copy");
|
2022-01-25 11:36:19 +01:00
|
|
|
remove_div($div, ranges, selection);
|
2021-02-10 16:58:56 +01:00
|
|
|
}
|
2019-03-23 12:30:31 +01:00
|
|
|
|
2021-02-10 16:58:56 +01:00
|
|
|
export function analyze_selection(selection) {
|
2019-03-23 12:30:31 +01:00
|
|
|
// Here we analyze our selection to determine if part of a message
|
|
|
|
// or multiple messages are selected.
|
2019-03-12 11:24:32 +01:00
|
|
|
//
|
|
|
|
// 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.
|
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
let i;
|
|
|
|
let range;
|
|
|
|
const ranges = [];
|
2022-01-25 11:36:19 +01:00
|
|
|
let $startc;
|
|
|
|
let $endc;
|
|
|
|
let $initial_end_tr;
|
2019-11-02 00:06:25 +01:00
|
|
|
let start_id;
|
|
|
|
let end_id;
|
|
|
|
let start_data;
|
|
|
|
let end_data;
|
2019-03-12 11:24:32 +01:00
|
|
|
// 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).
|
2019-11-02 00:06:25 +01:00
|
|
|
let skip_same_td_check = false;
|
2019-03-12 11:24:32 +01:00
|
|
|
|
2016-11-30 19:05:04 +01:00
|
|
|
for (i = 0; i < selection.rangeCount; i += 1) {
|
2014-03-13 22:07:56 +01:00
|
|
|
range = selection.getRangeAt(i);
|
|
|
|
ranges.push(range);
|
|
|
|
|
2022-01-25 11:36:19 +01:00
|
|
|
$startc = $(range.startContainer);
|
2020-07-15 00:34:28 +02:00
|
|
|
start_data = find_boundary_tr(
|
2022-08-26 08:01:32 +02:00
|
|
|
$startc.parents(".selectable_row, .message_header").first(),
|
2022-01-25 11:36:19 +01:00
|
|
|
($row) => $row.next(),
|
2020-07-15 00:34:28 +02:00
|
|
|
);
|
2014-03-13 22:07:56 +01:00
|
|
|
if (start_data === undefined) {
|
2019-03-12 11:24:32 +01:00
|
|
|
// 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];
|
2014-03-13 22:07:56 +01:00
|
|
|
}
|
|
|
|
|
2022-01-25 11:36:19 +01:00
|
|
|
$endc = $(range.endContainer);
|
2022-08-13 06:15:42 +02:00
|
|
|
$initial_end_tr = get_end_tr_from_endc($endc);
|
2022-01-25 11:36:19 +01:00
|
|
|
end_data = find_boundary_tr($initial_end_tr, ($row) => $row.prev());
|
2019-03-12 11:24:32 +01:00
|
|
|
|
2014-03-13 22:07:56 +01:00
|
|
|
if (end_data === undefined) {
|
2019-03-12 11:24:32 +01:00
|
|
|
// Skip any selection sections that don't intersect a message.
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
if (end_data[0] !== undefined) {
|
|
|
|
end_id = end_data[0];
|
2014-03-13 22:07:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if (start_data[1] || end_data[1]) {
|
2019-03-12 11:24:32 +01:00
|
|
|
// If the find_boundary_tr call for either the first or
|
|
|
|
// the last message covered by the selection
|
2014-03-13 22:07:56 +01:00
|
|
|
skip_same_td_check = true;
|
|
|
|
}
|
2019-03-12 11:24:32 +01:00
|
|
|
}
|
2014-03-13 22:07:56 +01:00
|
|
|
|
2019-03-23 12:30:31 +01:00
|
|
|
return {
|
2020-07-20 22:18:43 +02:00
|
|
|
ranges,
|
|
|
|
start_id,
|
|
|
|
end_id,
|
|
|
|
skip_same_td_check,
|
2019-03-23 12:30:31 +01:00
|
|
|
};
|
2021-02-10 16:58:56 +01:00
|
|
|
}
|
2014-03-13 22:07:56 +01:00
|
|
|
|
2022-08-13 06:15:42 +02:00
|
|
|
function get_end_tr_from_endc($endc) {
|
|
|
|
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 $(".message_row").last();
|
|
|
|
}
|
|
|
|
|
2022-04-05 00:15:34 +02:00
|
|
|
// 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 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) {
|
2022-08-26 08:01:32 +02:00
|
|
|
const $parent_msg = $endc.parents(".message_row").first();
|
2022-04-05 00:15:34 +02:00
|
|
|
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.
|
|
|
|
if ($endc.parents(".message_header").length > 0) {
|
2022-08-26 08:01:32 +02:00
|
|
|
const $overflow_recipient_row = $endc.parents(".recipient_row").first();
|
2022-04-05 00:15:34 +02:00
|
|
|
return $overflow_recipient_row.prev(".recipient_row").last(".message_row");
|
|
|
|
}
|
|
|
|
// If somehow we get here, do the default return.
|
|
|
|
}
|
|
|
|
|
2022-08-26 08:01:32 +02:00
|
|
|
return $endc.parents(".selectable_row").first();
|
2022-08-13 06:15:42 +02:00
|
|
|
}
|
|
|
|
|
2021-02-10 16:58:56 +01:00
|
|
|
export function paste_handler_converter(paste_html) {
|
2019-11-11 23:29:45 +01:00
|
|
|
const turndownService = new TurndownService();
|
2020-07-15 01:29:15 +02:00
|
|
|
turndownService.addRule("headings", {
|
|
|
|
filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
2020-07-20 22:18:43 +02:00
|
|
|
replacement(content) {
|
2019-11-11 23:29:45 +01:00
|
|
|
return content;
|
|
|
|
},
|
|
|
|
});
|
2020-07-15 01:29:15 +02:00
|
|
|
turndownService.addRule("emphasis", {
|
|
|
|
filter: ["em", "i"],
|
2020-07-20 22:18:43 +02:00
|
|
|
replacement(content) {
|
2020-07-15 01:29:15 +02:00
|
|
|
return "*" + content + "*";
|
2019-11-11 23:29:45 +01:00
|
|
|
},
|
|
|
|
});
|
|
|
|
// Checks for raw links without custom text or title.
|
2020-07-15 01:29:15 +02:00
|
|
|
turndownService.addRule("links", {
|
2020-07-20 22:18:43 +02:00
|
|
|
filter(node) {
|
2020-07-15 00:34:28 +02:00
|
|
|
return (
|
|
|
|
node.nodeName === "A" && node.href === node.innerHTML && node.href === node.title
|
|
|
|
);
|
2019-11-11 23:29:45 +01:00
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
replacement(content) {
|
2019-11-11 23:29:45 +01:00
|
|
|
return content;
|
|
|
|
},
|
|
|
|
});
|
2017-11-28 11:13:33 +01:00
|
|
|
|
2019-11-11 23:29:45 +01:00
|
|
|
let markdown_text = turndownService.turndown(paste_html);
|
|
|
|
|
|
|
|
// Checks for escaped ordered list syntax.
|
2023-05-11 21:49:10 +02:00
|
|
|
markdown_text = markdown_text.replaceAll(/^(\W* {0,3})(\d+)\\\. /gm, "$1$2. ");
|
2017-11-23 21:17:08 +01:00
|
|
|
|
2018-04-21 01:56:50 +02:00
|
|
|
// Removes newlines before the start of a list and between list elements.
|
2023-05-11 21:49:10 +02:00
|
|
|
markdown_text = markdown_text.replaceAll(/\n+([*+-])/g, "\n$1");
|
2018-04-21 01:56:50 +02:00
|
|
|
return markdown_text;
|
2021-02-10 16:58:56 +01:00
|
|
|
}
|
2017-11-23 21:17:08 +01:00
|
|
|
|
2023-08-23 22:50:37 +02:00
|
|
|
function is_safe_url_paste_target($textarea) {
|
|
|
|
const range = $textarea.range();
|
|
|
|
|
|
|
|
if (!range.text) {
|
|
|
|
// No range is selected
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isUrl(range.text.trim())) {
|
|
|
|
// Don't engage our URL paste logic over existing URLs
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (range.start <= 2) {
|
|
|
|
// The range opens too close to the start of the textarea
|
|
|
|
// to have to worry about Markdown link syntax
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Look at the two characters before the start of the original
|
|
|
|
// range in search of the tell-tale `](` from existing Markdown
|
|
|
|
// link syntax
|
|
|
|
const possible_markdown_link_markers = $textarea[0].value.slice(range.start - 2, range.start);
|
|
|
|
|
|
|
|
if (possible_markdown_link_markers === "](") {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2021-02-10 16:58:56 +01:00
|
|
|
export function paste_handler(event) {
|
2019-11-02 00:06:25 +01:00
|
|
|
const clipboardData = event.originalEvent.clipboardData;
|
2018-03-30 13:00:44 +02:00
|
|
|
if (!clipboardData) {
|
|
|
|
// On IE11, ClipboardData isn't defined. One can instead
|
|
|
|
// access it with `window.clipboardData`, but even that
|
|
|
|
// doesn't support text/html, so this code path couldn't do
|
|
|
|
// anything special anyway. So we instead just let the
|
|
|
|
// default paste handler run on IE11.
|
|
|
|
return;
|
|
|
|
}
|
2017-10-06 10:46:08 +02:00
|
|
|
|
2018-03-30 13:00:44 +02:00
|
|
|
if (clipboardData.getData) {
|
2023-08-03 20:01:10 +02:00
|
|
|
const $textarea = $(event.currentTarget);
|
|
|
|
const paste_text = clipboardData.getData("text");
|
2020-07-15 01:29:15 +02:00
|
|
|
const paste_html = clipboardData.getData("text/html");
|
2023-08-03 20:01:10 +02:00
|
|
|
// Trim the paste_text to accommodate sloppy copying
|
|
|
|
const trimmed_paste_text = paste_text.trim();
|
|
|
|
|
2023-08-23 22:50:37 +02:00
|
|
|
// Only intervene to generate formatted links when dealing
|
|
|
|
// with a URL and a URL-safe range selection.
|
|
|
|
if (isUrl(trimmed_paste_text) && is_safe_url_paste_target($textarea)) {
|
2023-08-03 20:01:10 +02:00
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
const url = trimmed_paste_text;
|
|
|
|
compose_ui.format_text($textarea, "linked", url);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-04-10 07:08:26 +02:00
|
|
|
if (paste_html && page_params.development_environment) {
|
2021-02-10 16:58:56 +01:00
|
|
|
const text = paste_handler_converter(paste_html);
|
2020-10-07 12:37:15 +02:00
|
|
|
const mdImageRegex = /^!\[.*]\(.*\)$/;
|
2021-01-23 02:50:13 +01:00
|
|
|
if (mdImageRegex.test(text)) {
|
2019-01-19 18:15:17 +01:00
|
|
|
// This block catches cases where we are pasting an
|
2019-11-21 05:24:55 +01:00
|
|
|
// image into Zulip, which is handled by upload.js.
|
2019-01-19 18:15:17 +01:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
2018-03-30 13:00:44 +02:00
|
|
|
compose_ui.insert_syntax_and_focus(text);
|
|
|
|
}
|
2017-10-06 10:46:08 +02:00
|
|
|
}
|
2021-02-10 16:58:56 +01:00
|
|
|
}
|
2014-03-13 22:07:56 +01:00
|
|
|
|
2021-02-10 16:58:56 +01:00
|
|
|
export function initialize() {
|
|
|
|
$("#compose-textarea").on("paste", paste_handler);
|
2023-08-03 20:01:10 +02:00
|
|
|
$("body").on("paste", ".message_edit_content", paste_handler);
|
2021-02-10 16:58:56 +01:00
|
|
|
}
|