/* * This file is part of TREB. * * TREB is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any * later version. * * TREB is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along * with TREB. If not, see . * * Copyright 2022-2026 trebco, llc. * info@treb.app * */ /** * attempting (at least partial) rewrite of editor. better support of * external editors, and a little cleaner behavior for context highlighting. * * I didn't want to handle spellcheck, but we're setting a flag reflecting * whether it's a formula; so we probably should do it. * * we are specifically NOT handling the following: * * - enter key * * subclasses or callers can handle those. * * --- * * NOTE: external editors might run in a different realm (in the js meaning * of that term). so we don't necessarily want to use the spreadsheet's context * for everything. this is going to be extremely confusing. * */ import { Area, type ICellAddress, IsCellAddress, Localization, Rectangle, type Cell, DOMContext } from 'treb-base-types'; import type { ExpressionUnit, ParseResult, UnitAddress, UnitRange } from 'treb-parser'; import { Parser } from 'treb-parser'; import type { DataModel, ViewModel } from 'treb-data-model'; import type { Autocomplete, AutocompleteResult } from './autocomplete'; import { EventSource } from 'treb-utils'; import { type AutocompleteExecResult, AutocompleteMatcher, type FunctionDescriptor } from './autocomplete_matcher'; export interface UpdateTextOptions { rewrite_addresses: boolean; validate_addresses: boolean; canonicalize_functions: boolean; format_only: boolean; toll_events: boolean; } type GenericEventListener = (event: Event) => unknown; // ---------------- /* export interface Editor2UpdateEvent { type: 'update'; dependencies?: Area[]; } */ /** event on commit, either enter or tab */ export interface FormulaEditorCommitEvent { type: 'commit'; // selection?: GridSelection; // I think this is no longer used? can we drop? value?: string; /** * true if commiting an array. note that if the cell _is_ an array, * and you commit as !array, that should be an error. */ array?: boolean; /** * for the formula editor, the event won't bubble so we can't handle * it with the normal event handler -- so use the passed event to */ event?: KeyboardEvent; } /** event on discard -- escape */ export interface FormulaEditorDiscardEvent { type: 'discard'; } /** event on end select state, reset selection */ export interface FormulaEditorEndSelectionEvent { type: 'end-selection'; } /** event on text update: need to update sheet dependencies */ export interface FormulaEditorUpdateEvent { type: 'update'; text?: string; cell?: Cell; dependencies?: Area[]; } // export interface FormulaEditorAutocompleteEvent { // type: 'autocomplete'; // text?: string; // cursor?: number; // } /* export interface RetainFocusEvent { type: 'retain-focus'; focus: boolean; } */ export interface StartEditingEvent { type: 'start-editing'; editor?: string; } export interface StopEditingEvent { type: 'stop-editing'; editor?: string; } /** discriminated union */ export type FormulaEditorEvent = // RetainFocusEvent | StopEditingEvent | StartEditingEvent | FormulaEditorUpdateEvent | FormulaEditorCommitEvent | FormulaEditorDiscardEvent | FormulaEditorEndSelectionEvent ; // ----------------- export interface NodeDescriptor { /** the contenteditable node */ node: HTMLElement; /** list of references in this node */ references?: Area[]; /** listeners we attached, so we can clean up */ listeners?: Map, GenericEventListener>; /** last-known text, to avoid unecessary styling */ formatted_text?: string; /** check (not sure if we still need this) length of html content */ check?: number; } export class Editor extends EventSource { protected static readonly FormulaChars = ('$^&*(-+={[<>/~%@' + Localization.argument_separator).split(''); // FIXME: i18n /** * the current edit cell. in the event we're editing a merged or * array cell, this might be different than the actual target address. */ public active_cell?: Cell; /** matcher. passed in by owner. should move to constructor arguments */ public autocomplete_matcher?: AutocompleteMatcher; /** the containing node, used for layout */ protected container_node?: HTMLElement; /** * this is the node we are currently editing. it's possible we are not * editing any cell, but just formatting. this one sends events and is * the target for inserting addresses. */ protected active_editor?: NodeDescriptor; /** * all nodes that are involved with this editor. we format all of them, * and if you edit one we might switch the colors in the others as * references change. */ protected nodes: NodeDescriptor[] = []; /** * address of cell we're editing, if we're editing a cell */ public target_address?: ICellAddress; /** * assume we're editing a formula. this is for the external editor. * if we switch the formula bar to inherit from this class, it should * be false. */ protected assume_formula = false; /** * this flag indicates we're editing a formula, which starts with `=`. */ protected text_formula = false; /** * this has changed -- we don't have an internal field. instead we'll * check when called. it's slightly more expensive but should be * relatively rare. */ public get selecting(): boolean { if (this.assume_formula) { return true; // always selecting } if (!this.text_formula) { return false; } // FIXME: also if you change the selection. our insert routine // handles that but this will return false. the test is "is the // cursor in or at the end of a reference?" if (this.active_editor && this.active_editor.node === this.active_editor.node.ownerDocument.activeElement) { const view = this.active_editor.node.ownerDocument.defaultView as (Window & typeof globalThis); const selection = view.getSelection(); const count = selection?.rangeCount; if (count) { const range = selection?.getRangeAt(0); const element = range?.endContainer instanceof view.HTMLElement ? range.endContainer : range.endContainer?.parentElement; // this is a reference, assume we're selecting (we will replace) if (element?.dataset.reference !== undefined) { return true; } // we may be able to use the selection directly /* if (range?.endContainer instanceof Text) { const str = (range.endContainer.textContent?.substring(0, range.endOffset) || '').trim(); if (str.length && Editor2.FormulaChars.includes(str[str.length - 1])) { return true; } } */ // start, not end if (range?.startContainer instanceof view.Text) { const str = (range.startContainer.textContent?.substring(0, range.startOffset) || '').trim(); if (str.length && Editor.FormulaChars.includes(str[str.length - 1])) { return true; } } else { console.info("mark 21", range); } } const text = this.SubstringToCaret2(this.active_editor.node)[1].trim(); if (text.length) { const char = text[text.length - 1]; return Editor.FormulaChars.includes(char); } } return false; } /** internal. not sure why we have a shadow property. */ protected composite_dependencies: Area[] = []; /** accessor */ public get dependencies(): Area[] { return this.composite_dependencies; } /** reference to model parser */ public parser: Parser; constructor( public model: DataModel, public view: ViewModel, public autocomplete?: Autocomplete ){ super(); this.parser = model.parser; } public FocusEditor(): void { if (this.active_editor) { this.active_editor.node.focus(); } } /** * add an event listener to the node. these are stored so we can remove * them later if the node is disconnected. * * listeners moved to node descriptors so we can have multiple sets. */ protected RegisterListener(descriptor: NodeDescriptor, key: K, handler: (event: HTMLElementEventMap[K]) => unknown) { descriptor.node.addEventListener(key, handler); if (!descriptor.listeners) { descriptor.listeners = new Map(); } descriptor.listeners.set(key, handler as GenericEventListener); } protected SelectAll(node: HTMLElement) { const view = node.ownerDocument.defaultView as (Window & typeof globalThis); const selection = view.getSelection(); const range = node.ownerDocument.createRange(); range.selectNode(node); selection?.removeAllRanges(); selection?.addRange(range); } protected SetCaret( start: { node: ChildNode, offset: number }, end?: { node: ChildNode, offset: number }) { const doc = start.node.ownerDocument; const view = doc?.defaultView as (Window & typeof globalThis); const selection = view.getSelection(); const range = doc?.createRange(); const FirstTextNode = (node: ChildNode) => { let target: Node = node; while (target && !(target instanceof view.Text) && !!target.firstChild) { target = target.firstChild; } return target; }; const start_node = FirstTextNode(start.node); if (end) { const end_node = FirstTextNode(end.node); if (selection && range) { range.setStart(start_node, start.offset); range.setEnd(end_node, end.offset); selection.removeAllRanges(); selection.addRange(range); } } else { if (selection && range) { range.setStart(start_node, start.offset); range.setEnd(start_node, start.offset); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } } } /** * */ public InsertReference(reference: string, toggle_reference_type = false, typed_reference_list: string[] = []) { if (!this.active_editor) { return; } const view = this.active_editor.node.ownerDocument.defaultView as (Window & typeof globalThis); const selection = view.getSelection(); if (!selection) { throw new Error('error getting selection'); } if (selection.rangeCount === 0) { // console.warn('range count is 0'); return ''; } const range = selection.getRangeAt(0); const text = this.active_editor.node.textContent || ''; // so what we are doing here depends on where the caret is. // if the caret is in a reference (address, range, &c) then // we replace the reference. that seems like the logical thing // to do. // if the caret is not in a reference, then we need to insert/append // it at the caret position. should we replace existing stuff? what // if it's in a literal? ... // maybe the criteria should be "is there a range selection", and if // so, replace the range selection -- otherwise, insert the reference // (possibly with a delimeter, space, or operator?) // A: easiest case: selection is in a reference. replace it. // actually the first case should be the range selection, since that // might include _more_ than an existing reference, and we want to // replace the entire range selection. if (range.startContainer instanceof view.Text) { // first case: range selected if (!range.collapsed && range.startOffset < range.endOffset) { const substrings = this.SubstringToCaret2(this.active_editor.node); // console.info("Case 1", substrings); /* // console.info('case 1'); const substring_1 = this.SubstringToCaret(this.editor_node, true); const substring_2 = this.SubstringToCaret(this.editor_node, false); const test = this.SubstringToCaret2(this.editor_node); console.info( (test[0] === substring_1 && test[1] === substring_2) ? 'GOOD' : 'BAD', { test, substring_1, substring_2 }); */ this.active_editor.node.textContent = substrings[0] + reference + text.substring(substrings[1].length); this.SetCaret({ node: this.active_editor.node, offset: substrings[0].length, }, { node: this.active_editor.node, offset: substrings[0].length + reference.length }); } else { // check if we're in a reference node; if so, replace const parent = range.startContainer.parentElement; if (parent instanceof view.HTMLElement && parent.dataset.reference) { if (toggle_reference_type && typed_reference_list.length) { for (const [index, ref] of typed_reference_list.entries()) { if (ref === parent.textContent) { reference = typed_reference_list[index + 1] || typed_reference_list[0]; break; } } } // replace text parent.textContent = reference; this.SetCaret({ node: parent, offset: reference.length, }); } else { // console.info('case 3;', {sc: range.startContainer, text: range.startContainer.data, parent}); // otherwise, insert at caret. should we add a delimeter? it // probably depends on what's immediately preceding the caret. // UPDATE: what about following the caret? const substring = this.SubstringToCaret2(this.active_editor.node)[1]; let leader = ''; // let trailer = ''; const trimmed = substring.trim(); if (trimmed.length) { const char = trimmed[trimmed.length - 1]; if (!Editor.FormulaChars.includes(char)) { if (substring.length === trimmed.length) { leader = ' +'; } else { leader = '+'; } } } // check after. this is a little different because we still // want to set the caret at the end of the original reference. // we need a flag. // we can't insert a space, because that will break parsing (it // will become invalid). I guess we could insert a space, and just // accept that, but this seems like it works better (doing nothing). /* if (text.length > substring.length) { const char = text[substring.length]; if (!Editor2.FormulaChars.includes(char) && !/\s/.test(char)) { trailer = ' '; } } */ this.active_editor.node.textContent = substring + leader + reference + text.substring(substring.length); this.SetCaret({ node: this.active_editor.node, offset: substring.length + reference.length + leader.length, }); } } } else { // if startContainer is not text, that usually means the container // is empty. I don't think there's any other case. so we can insert // the text. we'll want to create a node, and we'll want to set the // cursor at the end. if (range.startContainer instanceof view.HTMLElement) { range.startContainer.textContent = reference; this.SetCaret({ node: range.startContainer, offset: reference.length, }); } else { console.warn("unexpected range start container", range.startContainer); } } // there may be some cases where we don't need to do this this.UpdateText(this.active_editor) this.UpdateColors(); // this does not raise an input event. probably because we're calling // it from script. but we might be pretty disconnected from the owner. // can we use a synthentic event that matches one of the real ones? // A: yes, except that isTrusted will evaluate to false. which I guess // is fine, in this context? // make sure to do this after updating so we have a current list of // references attached to the node this.active_editor.node.dispatchEvent(new Event('input', { bubbles: true, cancelable: true, })); } /** * this method does three things: * * (1) builds a flat list of references across all nodes * (2) applies colors to formatted references * (3) sends an event (if necessary, or forced) * * that's fine, but it needs a new name. * */ protected UpdateColors(force_event = false, toll_update = false) { // const view = this.active_editor?.node.ownerDocument.defaultView as (Window & typeof globalThis); // create a map of canonical label -> area const map: Map = new Map(); // also create a map of label -> index const indexes: Map = new Map(); for (const support of this.nodes) { for (const area of support.references || []) { const label = this.model.AddressToLabel(area, true); if (!map.has(label)) { map.set(label, area); indexes.set(label, indexes.size); } } } // FIXME: compare against current and short-circuit // console.info({map, indexes}); // now apply colors to nodes for (const entry of this.nodes) { for (const node of Array.from(entry.node.childNodes)) { const view = node.ownerDocument?.defaultView as (Window & typeof globalThis); if (view && node instanceof view.HTMLElement && node.dataset.reference) { const index = indexes.get(node.dataset.reference); node.dataset.highlightIndex = (typeof index === 'number') ? (index % 5 + 1).toString() : '?'; } } // this is a check for flushing text when we re-attach. // @see AttachNode entry.check = entry.node.innerHTML.length; } // dependencies is just the single list const list = Array.from(map.values()); if (!force_event) { if (JSON.stringify(this.composite_dependencies) === JSON.stringify(list)) { return; } } this.composite_dependencies = list; if (!toll_update) { this.Publish({ type: 'update', dependencies: this.composite_dependencies }); } } /** * get a list of all references in the text (actually in the parse result, * since we have that). stores the list in the node descriptor (and in * the node dataset). * * returns a list of the references in parse result mapped to normalized * address labels. those can be used to identify identical references when * we highlight later. * * @param parse_result * @returns */ protected UpdateDependencies(descriptor: NodeDescriptor, parse_result: ParseResult) { const reference_list: Array = []; for (const unit of parse_result.full_reference_list || []) { switch (unit.type) { case 'address': case 'range': { const start = unit.type === 'range' ? unit.start : unit; if (!start.sheet_id) { if (start.sheet) { start.sheet_id = this.model.sheets.Find(start.sheet)?.id || 0; } else { start.sheet_id = this.view.active_sheet.id; } } reference_list.push(unit); break; } case 'structured-reference': if (this.target_address) { const reference = this.model.ResolveStructuredReference(unit, this.target_address); if (reference) { reference_list.push(reference); } } else { console.info('target address not set'); } break; case 'identifier': { const named_range = this.model.GetName(unit.name, this.view.active_sheet.id); // FIXME: scoped? if (named_range?.type === 'range') { if (named_range.area.count === 1) { reference_list.push({ type: 'address', ...named_range.area.start, label: unit.name, position: unit.position, id: unit.id, }); } else { reference_list.push({ type: 'range', start: { type: 'address', position: unit.position, id: unit.id, label: unit.name, ...named_range.area.start, }, end: { type: 'address', position: unit.position, label: unit.name, id: unit.id, ...named_range.area.end, }, label: unit.name, position: unit.position, id: unit.id, }); } } break; } } } // how could this ever be out of order? (...) reference_list.sort((a, b) => a.position - b.position); // flat list, unique const references: Area[] = []; // set for matching const list: Set = new Set(); // for the result, map of reference to normalized address label // const map: Map = new Map(); const map: Map = new Map(); for (const entry of reference_list) { const label = this.model.AddressToLabel(entry, true); // , this.view.active_sheet); const area = IsCellAddress(entry) ? new Area(entry) : new Area(entry.start, entry.end); // add to references once if (!list.has(label)) { references.push(area); list.add(label); } // but keep a map map.set(entry.label, label); } this.UpdateReferences(descriptor, references); // console.info({map}); return map; } /** * store the set of references, and store in the node dataset for * external clients. * * @param descriptor * @param references * @param options */ protected UpdateReferences(descriptor: NodeDescriptor, references: Area[] = []) { descriptor.node.dataset.references = JSON.stringify(references.map(entry => this.model.AddressToLabel(entry, true))); descriptor.references = references; } /** * reformat text to highlight, which involves tinkering with * node structure. we're probably doing this more than necessary; * we might consider editing the existing structure, rather than * throwing it away every time. * */ protected UpdateText( // node: HTMLElement, descriptor: NodeDescriptor, options: Partial = {}) { const node = descriptor.node; const text = node.textContent || ''; const DOM = DOMContext.GetInstance(node.ownerDocument); // set this flag so we can use it in `get selected()` this.text_formula = text[0] === '='; if (this.active_editor && !this.assume_formula) { this.active_editor.node.spellcheck = !(this.text_formula); // if not assuming formula, and it's not a formula, then exit. if (!this.text_formula) { return; } } // this is a short-circuit so we don't format the same text twice. // but there are some problems when you assign the same text over, // especially if the text is empty. // // to unset this field make sure to set it to `undefined` instead // of any empty string, so it will expressly not match an empty string. if (text === descriptor.formatted_text) { // fix selection behavior for tabbing // for some reason this is too aggressive, it's happening when // we _should_ have a selection /* if (node === this.editor_node && node === document.activeElement) { const substr = this.SubstringToCaret(node); const substr2 = this.SubstringToCaret(node, true); if (text.length && substr === '' && substr2 === '') { this.SelectAll(node); } } */ // is there a case where we'd want to autocomplete here? (...) return; } // I wonder if this should be done asynchronously... we generally // have pretty short strings, so maybe not a big deal const [substring_start, substring_end] = this.SubstringToCaret2(node); // console.info({text, substr, substr2}); const caret_start = substring_start.length; let caret_end = substring_end.length; // this is a little hacky if (caret_start === 0 && caret_end === 0) { caret_end = text.length; } if (!text) { this.UpdateReferences(descriptor); // flush } else { const parse_result = this.parser.Parse(text); if (parse_result.expression) { const normalized_labels = this.UpdateDependencies(descriptor, parse_result); // the parser will drop a leading = character, so be // sure to add that back if necessary const offset = (text[0] === '=' ? 1 : 0); let start = 0; let selection_start: { node: ChildNode, offset: number } | undefined; let selection_end: { node: ChildNode, offset: number } | undefined; // let selection_node: ChildNode|null = null; // let selection_offset = 0; let text_index = 0; let last_text_node: Text|undefined; const fragment = DOM.Fragment(); const AddNode = (text: string, type = 'text', reference = '', force_selection = false) => { const text_node = DOM.Text(text); if (force_selection || ((caret_start > text_index || (caret_start === 0 && text_index === 0)) && caret_start <= text_index + text.length)) { selection_start = { offset: caret_start - text_index, node: text_node, }; } if (caret_end > text_index && caret_end <= text_index + text.length ) { selection_end = { offset: caret_end - text_index, node: text_node, }; } if (type !== 'text') { const span = DOM.Create('span', type); if (reference) { span.dataset.reference = reference; } // span.className = type; span.appendChild(text_node); fragment.appendChild(span); } else { fragment.appendChild(text_node); } last_text_node = text_node; text_index += text.length; }; this.parser.Walk(parse_result.expression, (unit: ExpressionUnit) => { if (unit.type === 'missing' || unit.type === 'group' || unit.type === 'dimensioned') { return true; } const pos = unit.position + offset; const part = text.substring(start, pos); let label = ''; let type: string = unit.type; let reference = ''; switch (unit.type) { case 'identifier': // FIXME: canonicalize (optionally) label = text.substring(pos, pos + unit.name.length); reference = normalized_labels.get(unit.name) || ''; break; case 'call': // FIXME: canonicalize (optionally) label = text.substring(pos, pos + unit.name.length); break; case 'literal': if (typeof unit.value === 'string') { label = text.substring(pos, pos + unit.value.length + 2); type = 'string'; } else { return false; } break; case 'address': case 'range': case 'structured-reference': reference = normalized_labels.get(unit.label) || '???'; /* { const index = indexes.get(unit); if (typeof index === 'number') { type += ` highlight-${(index % 5) + 1}` } } */ // TODO: validate (optionally) label = options.rewrite_addresses ? unit.label : text.substring(pos, pos + unit.label.length); break; default: // console.info('unhandled', unit.type); return true; } AddNode(part); AddNode(label, type, reference); start = pos + label.length; return unit.type !== 'range'; }); if (start < text.length) { AddNode(text.substring(start)); } if (!selection_start) { // console.info('no selection node'); if (last_text_node) { selection_start = { node: last_text_node, offset: (last_text_node.data || '').length, }; // console.info('using last node'); } else { // console.info('adding next selection node'); AddNode('', undefined, '', true); } } node.textContent = ''; node.appendChild(fragment); if (selection_start && !options.format_only && node === this.active_editor?.node) { this.SetCaret(selection_start, selection_end); // we were doing this for the external editor, and it was useful // because those editors don't grow. but it makes the spreadsheet // scroll when it's used in the ICE/overlay. maybe a flag? // (selection_end || selection_start).node.parentElement?.scrollIntoView(); } } else { // console.warn("expression failed", text); } } descriptor.formatted_text = text; // const matcher = this.autocomplete_matcher; if (matcher) { Promise.resolve().then(() => { const exec_result = matcher.Exec({ text, cursor: substring_end.length }); // fix behavior around "f16round", which is a function we accidentally // inherit from Math in Safari and Firefox. it breaks arrow-selection // because it pops up an AC window. we don't want to break AC behavior // on tooltips, though, so we still want to call AC methods. just block // the completion list. if (this.selecting && exec_result.completions?.length) { // console.info("Blocking completion list", JSON.stringify(exec_result.completions)); exec_result.completions = undefined; } const node = this.NodeAtIndex(exec_result.completions?.length ? (exec_result.position || 0) : (exec_result.function_position || 0)); this.Autocomplete(exec_result, node); }); } } protected NodeAtIndex(index: number): Node|undefined { const children = this.active_editor?.node.childNodes || []; for (let i = 0; i < children.length; i++) { const len = children[i].textContent?.length || 0; if (len > index) { return children[i]; } index -= len; } return undefined; } protected AcceptAutocomplete(ac_result: AutocompleteResult) { if (!this.active_editor) return; // let type = DescriptorType.Function; let type: FunctionDescriptor['type']; if (ac_result.data && ac_result.data.completions) { for (const completion of ac_result.data.completions) { if (completion.name.toLowerCase() === ac_result.value?.toLowerCase()) { type = completion.type ; // || DescriptorType.Function; break; } } } // since this only happens when typing, we know that there's a single // cursor position, and it's in a text node. can we use that reduction to // simplify how we insert? it's probably unecessary to highlight... // // at least in the case of functions. if we're inserting a named reference, // then we do need to highlight. so. const start = ac_result.data?.position || 0; const end = start + (ac_result.data?.token?.length || 0); const insertion = (type === 'token') ? ac_result.value : ac_result.value + '('; const text = this.active_editor.node.textContent || ''; let adjusted = text.substring(0, start) + insertion; const caret = adjusted.length; adjusted += text.substring(end); this.active_editor.node.textContent = adjusted; this.SetCaret({node: this.active_editor.node, offset: caret}); this.autocomplete?.Hide(); this.UpdateText(this.active_editor); this.UpdateColors(); } /** called when there's AC data to display (or tooltip) */ protected Autocomplete(data: AutocompleteExecResult, target_node?: Node): void { if (!this.container_node || !this.autocomplete) { return; } let client_rect: DOMRect; if (target_node?.nodeType === Node.ELEMENT_NODE) { client_rect = (target_node as Element).getBoundingClientRect(); } else { client_rect = this.container_node.getBoundingClientRect(); } // console.info({target_node, client_rect}); const rect = new Rectangle( Math.round(client_rect.left), Math.round(client_rect.top), client_rect.width, client_rect.height); this.autocomplete.Show(this.AcceptAutocomplete.bind(this), data, rect); } /** * this version gets substrings to both selection points. * * @param node * @returns [substring to start of selection, substring to end of selection] */ protected SubstringToCaret2(node: HTMLElement, force = false): [string, string] { const result: [string, string] = ['', '']; if (!force && node !== node.ownerDocument.activeElement || node !== this.active_editor?.node) { return result; } const doc = node.ownerDocument; const view = doc.defaultView as (Window & typeof globalThis); // is there a way to do this without recursing? (...) // how about string concat instead of array join, it's faster in // chrome (!) const complete = [ false, false ]; const Consume = (element: Node, range: Range) => { // it only seems to happen in firefox, but sometimes we'll get // a non-text node that is the start container and endcontainer. // // in that case we need to interpret the endOffset as nodes, not // characters. that's what's causing the firefox issues. I guess // that applies to startoffset as well? // not sure if this is bugged or what but when we hit this case, // it's always "all the text in there" regardless of the offsets. // not sure what the offsets are even referring to, since we get // offsets > the number of child nodes. is this a bug in firefox? if (element === range.startContainer && element === range.endContainer && !(element instanceof view.Text)) { /* if (range.startOffset !== 0 || range.endOffset !== 0) { console.info("warn offset", range.startOffset, range.endOffset); console.info(element); } */ complete[0] = complete[1] = true; result[0] += element.textContent; result[1] += element.textContent; return; } if (element === range.startContainer) { result[0] += (element.textContent || '').substring(0, range.startOffset); complete[0] = true; } if (element === range.endContainer) { result[1] += (element.textContent || '').substring(0, range.endOffset); complete[1] = true; } if (complete[0] && complete[1]) { return; } if (element instanceof view.Text) { const text = element.textContent || ''; if (!complete[0]) { result[0] += text; } if (!complete[1]) { result[1] += text; } } else if (element.hasChildNodes()) { for (const child of Array.from(element.childNodes)) { Consume(child, range); if (complete[0] && complete[1]) { return; } } } }; const selection = view.getSelection(); if (selection?.rangeCount ?? 0 > 0) { const range = selection?.getRangeAt(0); if (range) { Consume(node, range); } } return result; } }