copy_messages: Expand selection for math expressions and blocks.

With this we introduce selection expansion when
the selection spans across multiple inline expressions
or expressions within mutliple math blocks.

This makes the selection consistent with what is pasted
while also fixing some bugs encountered in cases such as:
1. Focus present in an inline math expression and
anchor present in a different inline math expression
and vice-versa.

2. Focus present in a math block and anchor present
in a different math expression outside that math block
and vice-versa.

3. When selecting expressions within a math block, the
first expression wasn't retained in the past because
of the selection not containing <annotation>.

Fixes #33676.
Also fixes
https://chat.zulip.org/#narrow/channel/9-issues/topic/.F0.9F.93.82.20pasting.20LaTeX/near/2159869
and other related issues.

Signed-off-by: apoorvapendse <apoorvavpendse@gmail.com>
This commit is contained in:
apoorvapendse
2025-04-23 07:28:20 +05:30
committed by Tim Abbott
parent 2e294e23c1
commit 080346c0e0

View File

@@ -119,54 +119,88 @@ function get_nearest_html_element(node: Node | null): Element | null {
return node.parentElement;
}
// selection_element will be either the start_element or end_element
function expand_range_based_on_katex_parent(
selection_element: Element,
is_range_start: boolean,
range: Range,
): void {
// Here, we have three cases:
// 1. This element lies within a math block expression i.e. within a `.katex-display`
// 2. This element lies within an inline math expression i.e. inside a `.katex` span
// with no `.katex-display` parent for that `.katex`
// 3. This element does not lie within a math expression, we directly return without expansion.
// We cascade through these cases, expanding the range and prioritizing math blocks over expressions
// in case we encounter them.
const is_within_math_block = selection_element.closest(".katex-display") !== null;
const is_within_math_expression = selection_element.closest(".katex") !== null;
if (!is_within_math_block && !is_within_math_expression) {
return;
}
if (is_within_math_block) {
// One might think that this will break in case of empty katex-display(s)
// being the start or end node which is/are created when we insert
// some extra newlines within a math block.
// However, is it not possible to select those empty katex-displays
// as per my observation on Chrome and Firefox.
if (is_range_start) {
range.setStart(selection_element.closest(".katex-display")!, 0);
} else {
// The offset 1 selects the only child of `.katex-display`
// which is `.katex`.
range.setEnd(selection_element.closest(".katex-display")!, 1);
}
} else {
if (is_range_start) {
range.setStart(selection_element.closest(".katex")!, 0);
} else {
// The offset 2 selects the two children of `.katex`
// namely `.katex-mathml` and `.katex-html`
range.setEnd(selection_element.closest(".katex")!, 2);
}
}
}
/*
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.
Our paste behavior for KaTeX relies on processing the MathML
annotations generated by KaTeX in `<annotation>` tags. This
function is responsible for expanding selections of math copied
out of Zulip to ensure the annotations are included in what is
copied, so that it pastes nicely.
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 expand the selection range only in the following cases:
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 span.
1. Either the startContainer or endContainer or both are within an
inline expression where the range covers one or more math
expressions.
2. Either the startContainer, endContainer, or both are within a
math block where the range covers one or more math expressions.
In principle, we only need to expand the start of the selection
range for the cases where multiple expressions are selected
because the end of the range always contains the annotation
element in case it lies within the math block.
But, we still expand the end of the range to select the complete
expression, since our paste handler has no way to split the
annotation, so we'll always be converting entire expressions.
*/
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 spans, 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") !== anchor_element?.closest(".katex")
) {
function improve_katex_selection_range(range: Range): void {
const start_element = get_nearest_html_element(range.startContainer);
const end_element = get_nearest_html_element(range.endContainer);
if (!end_element || !start_element) {
return;
}
if (anchor_element) {
const parent = anchor_element.closest(".katex");
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");
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);
}
// Only perform expansion if either the start or end element
// is itself a `.katex` element or is contained within one.
if (end_element.closest(".katex") === null && start_element.closest(".katex") === null) {
return;
}
expand_range_based_on_katex_parent(start_element, true, range);
expand_range_based_on_katex_parent(end_element, false, range);
}
export function copy_handler(ev: ClipboardEvent): boolean {
@@ -187,7 +221,6 @@ export function copy_handler(ev: ClipboardEvent): boolean {
const selection = window.getSelection();
assert(selection !== null);
improve_katex_selection_range(selection);
const analysis = analyze_selection(selection);
const start_id = analysis.start_id;
@@ -215,6 +248,23 @@ export function copy_handler(ev: ClipboardEvent): boolean {
if (!skip_same_td_check && start_id === end_id) {
// Check whether the selection both starts and ends in the
// same message and let the browser handle the copying.
// Firefox uses multiple ranges when selecting multiple messages.
// See https://drafts.csswg.org/css-ui-4/#valdef-user-select-none
// Instead of relying on Selection API's anchorNode and focusNode,
// we iterate over all ranges and expand them if needed.
//
// The reason is that anchorNode and focusNode only reflect the first range,
// which becomes an issue in Firefox. When the selection spans multiple ranges,
// for example, due to `user-select: none` elements in between the selection,
// Firefox creates disjoint ranges but only sets anchor/focus for the first one.
//
// So to handle multi-range selections correctly (especially in Firefox),
// we process all ranges individually.
for (let i = 0; i < selection.rangeCount; i += 1) {
improve_katex_selection_range(selection.getRangeAt(i));
}
return false;
}