diff --git a/web/src/copy_messages.ts b/web/src/copy_messages.ts index b00283fa5f..208aab26ad 100644 --- a/web/src/copy_messages.ts +++ b/web/src/copy_messages.ts @@ -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 $$$$ syntax. + Our paste behavior for KaTeX relies on processing the MathML + annotations generated by KaTeX in `` 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; }