mirror of https://github.com/zulip/zulip.git
349 lines
13 KiB
JavaScript
349 lines
13 KiB
JavaScript
function find_boundary_tr(initial_tr, iterate_row) {
|
|
var j;
|
|
var skip_same_td_check = false;
|
|
var 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;
|
|
}
|
|
|
|
// 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;
|
|
} 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) {
|
|
var message_header_content = rows.get_message_recipient_header(message_row)
|
|
.text()
|
|
.replace(/\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, start_id, end_id) {
|
|
var start_row = current_msg_list.get_row(start_id);
|
|
var start_recipient_row = rows.get_message_recipient_row(start_row);
|
|
var start_recipient_row_id = rows.id_for_recipient_row(start_recipient_row);
|
|
var should_include_start_recipient_header = false;
|
|
|
|
var last_recipient_row_id = start_recipient_row_id;
|
|
for (var row = start_row; rows.id(row) <= end_id; row = rows.next_visible(row)) {
|
|
var 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) {
|
|
div.append(construct_recipient_header(row));
|
|
last_recipient_row_id = recipient_row_id;
|
|
should_include_start_recipient_header = true;
|
|
}
|
|
var message = current_msg_list.get(rows.id(row));
|
|
var message_firstp = $(message.content).slice(0, 1);
|
|
message_firstp.prepend(message.sender_full_name + ": ");
|
|
div.append(message_firstp);
|
|
div.append($(message.content).slice(1));
|
|
}
|
|
|
|
if (should_include_start_recipient_header) {
|
|
div.prepend(construct_recipient_header(start_row));
|
|
}
|
|
}
|
|
|
|
function select_div(div, selection) {
|
|
div.css({
|
|
position: 'absolute',
|
|
left: '-99999px',
|
|
// Color and background is made according to "day mode"
|
|
// 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 night mode.
|
|
// 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(div[0]);
|
|
}
|
|
|
|
function remove_div(div, ranges, selection) {
|
|
window.setTimeout(function () {
|
|
selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
_.each(ranges, function (range) {
|
|
selection.addRange(range);
|
|
});
|
|
$('#copytempdiv').remove();
|
|
}, 0);
|
|
}
|
|
|
|
exports.copy_handler = function () {
|
|
// 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/auto-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.
|
|
|
|
var selection = window.getSelection();
|
|
var analysis = exports.analyze_selection(selection);
|
|
var ranges = analysis.ranges;
|
|
var start_id = analysis.start_id;
|
|
var end_id = analysis.end_id;
|
|
var skip_same_td_check = analysis.skip_same_td_check;
|
|
var div = $('<div>');
|
|
|
|
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.
|
|
document.execCommand('copy');
|
|
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.
|
|
document.execCommand('copy');
|
|
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);
|
|
document.execCommand('copy');
|
|
remove_div(div, ranges, selection);
|
|
};
|
|
|
|
exports.analyze_selection = function (selection) {
|
|
// 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.
|
|
|
|
var i;
|
|
var range;
|
|
var ranges = [];
|
|
var startc;
|
|
var endc;
|
|
var initial_end_tr;
|
|
var start_id;
|
|
var end_id;
|
|
var start_data;
|
|
var 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).
|
|
var 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')[0]), function (row) {
|
|
return 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);
|
|
// 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
|
|
if (endc.attr('id') === "bottom_whitespace" || endc.attr('id') === "compose_close") {
|
|
initial_end_tr = $(".message_row").last();
|
|
// The selection goes off the end of the message feed, so
|
|
// this is a multi-message selection.
|
|
skip_same_td_check = true;
|
|
} else {
|
|
initial_end_tr = $(endc.parents('.selectable_row')[0]);
|
|
}
|
|
end_data = find_boundary_tr(initial_end_tr, function (row) {
|
|
return 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: ranges,
|
|
start_id: start_id,
|
|
end_id: end_id,
|
|
skip_same_td_check: skip_same_td_check,
|
|
};
|
|
};
|
|
|
|
exports.paste_handler_converter = function (paste_html) {
|
|
var converters = {
|
|
converters: [
|
|
{
|
|
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
|
replacement: function (content) {
|
|
return content;
|
|
},
|
|
},
|
|
|
|
{
|
|
filter: ['em', 'i'],
|
|
replacement: function (content) {
|
|
return '*' + content + '*';
|
|
},
|
|
},
|
|
{
|
|
// Checks for raw links without custom text or title.
|
|
filter: function (node) {
|
|
return node.nodeName === "A" &&
|
|
node.href === node.innerHTML &&
|
|
node.href === node.title;
|
|
},
|
|
replacement: function (content) {
|
|
return content;
|
|
},
|
|
},
|
|
{
|
|
// Checks for escaped ordered list syntax.
|
|
filter: function (node) {
|
|
return /(\d+)\\\. /.test(node.innerHTML);
|
|
},
|
|
replacement: function (content) {
|
|
return content.replace(/(\d+)\\\. /g, '$1. ');
|
|
},
|
|
},
|
|
],
|
|
};
|
|
var markdown_html = toMarkdown(paste_html, converters);
|
|
|
|
// Now that we've done the main conversion, we want to remove
|
|
// any HTML tags that weren't converted to markdown-style
|
|
// text, since Bugdown doesn't support those.
|
|
var div = document.createElement("div");
|
|
div.innerHTML = markdown_html;
|
|
// Using textContent for modern browsers, innerText works for Internet Explorer
|
|
var markdown_text = div.textContent || div.innerText || "";
|
|
markdown_text = markdown_text.trim();
|
|
// Removes newlines before the start of a list and between list elements.
|
|
markdown_text = markdown_text.replace(/\n+([*+-])/g, '\n$1');
|
|
return markdown_text;
|
|
};
|
|
|
|
exports.paste_handler = function (event) {
|
|
var clipboardData = event.originalEvent.clipboardData;
|
|
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;
|
|
}
|
|
|
|
if (clipboardData.getData) {
|
|
var paste_html = clipboardData.getData('text/html');
|
|
if (paste_html && page_params.development_environment) {
|
|
var text = exports.paste_handler_converter(paste_html);
|
|
var mdImageRegex = /^!\[.*\]\(.*\)$/;
|
|
if (text.match(mdImageRegex)) {
|
|
// This block catches cases where we are pasting an
|
|
// image into Zulip, which should be handled by the
|
|
// jQuery filedrop library, not this code path.
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
compose_ui.insert_syntax_and_focus(text);
|
|
}
|
|
}
|
|
};
|
|
|
|
exports.initialize = function () {
|
|
$("#compose-textarea").bind('paste', exports.paste_handler);
|
|
$('body').on('paste', '#message_edit_form', exports.paste_handler);
|
|
};
|
|
|
|
window.copy_and_paste = exports;
|