/* * 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 * */ // import type { Area, Cell, Theme } from 'treb-base-types'; import { Editor, type NodeDescriptor, type FormulaEditorEvent } from './editor'; // import { Parser } from 'treb-parser'; import type { DataModel, ViewModel } from 'treb-data-model'; import type { GridOptions } from '../types/grid_options'; import { Autocomplete } from './autocomplete'; import { DOMContext } from 'treb-base-types'; // --- from formula_bar --- /* export interface FormulaBarResizeEvent { type: 'formula-bar-resize'; } */ export interface FormulaButtonEvent { type: 'formula-button'; formula?: string; cursor_position?: number; } export interface AddressLabelEvent { type: 'address-label-event'; text?: string; } export interface TollEvent { type: 'toll'; value?: string; } export interface ToggleReferenceEvent { type: 'toggle-reference'; } export type FormulaBar2Event = FormulaButtonEvent // | FormulaBarResizeEvent | AddressLabelEvent | TollEvent | ToggleReferenceEvent ; // --- export class FormulaBar extends Editor { public committed = false; public tolled?: { text: string, substring: string }; /** * if we're showing a spill array and it's not the first cell, show * the formula using a shadow style (and it's not editable). */ public shadow_ = false; public get shadow() { return this.shadow_; } public set shadow(shadow: boolean) { this.shadow_ = shadow; if (this.active_editor?.node) { if (shadow) { this.active_editor.node.classList.add('treb-editor-shadow'); } else { this.active_editor.node.classList.remove('treb-editor-shadow'); } } } /** is the _editor_ currently focused */ // tslint:disable-next-line:variable-name public focused_ = false; /** accessor for focused field */ public get focused(): boolean { return this.focused_; } /** address label (may also show other things... ?) */ private address_label_container: HTMLDivElement; /** address label (may also show other things... ?) */ private address_label: HTMLDivElement; /** the function button (optional?) */ private button!: HTMLButtonElement; /** */ private expand_button!: HTMLButtonElement; /* * corner for resizing formula editor * / private drag_corner!: HTMLDivElement; / * * for math * / private lines = 1; private last_formula = ''; */ private label_update_timer = 0; /** get formula text */ public get formula(): string { return this.active_editor ? this.active_editor.node.textContent || '' : ''; } /** set formula text */ public set formula(text: string) { if (this.active_editor) { this.active_editor.node.textContent = text; this.active_editor.formatted_text = undefined; } // this.last_formula = text; } /** get address label text */ public get label(): string { return this.address_label?.textContent || ''; } /** * set address label text. if the label is too long for the box, * add a title attribute for a tooltip. */ public set label(text: string) { if (!text.trim().length) { this.address_label.innerHTML = ' '; this.address_label.removeAttribute('title'); } else { this.address_label.textContent = text; if (!this.label_update_timer) { this.label_update_timer = requestAnimationFrame(() => { this.label_update_timer = 0; // should this be in a Yield callback? need to check IE11... // yes if (this.address_label.scrollWidth > this.address_label.offsetWidth) { this.address_label.setAttribute('title', text); } else { this.address_label.removeAttribute('title'); } }); } } } /** toggle editable property: supports locked cells */ public set editable(editable: boolean) { if (!this.active_editor || !this.container_node) return; if (editable) { this.active_editor.node.setAttribute('contenteditable', 'true'); // is that required? this.container_node.removeAttribute('locked'); } else { this.active_editor.node.removeAttribute('contenteditable'); this.container_node.setAttribute('locked', ''); } } constructor( container: HTMLElement, // parser: Parser, // theme: Theme, model: DataModel, view: ViewModel, private options: GridOptions, autocomplete: Autocomplete, ) { super(model, view, autocomplete); const DOM = DOMContext.GetInstance(container.ownerDocument); const inner_node = container.querySelector('.treb-formula-bar') as HTMLElement; inner_node.removeAttribute('hidden'); this.address_label_container = inner_node.querySelector('.treb-address-label') as HTMLDivElement; this.address_label = this.address_label_container.firstElementChild as HTMLDivElement; this.InitAddressLabel(); /* if (this.options.insert_function_button) { this.button = DOM.Create('button', 'formula-button', inner_node); this.button.addEventListener('click', () => { const formula: string = this.active_editor ? this.active_editor.node.textContent || '' : ''; this.Publish({ type: 'formula-button', formula }); }); } */ this.container_node = container.querySelector('.treb-editor-container') as HTMLDivElement; const target = this.container_node.firstElementChild as HTMLDivElement; const descriptor: NodeDescriptor = { node: target, }; this.active_editor = descriptor; this.nodes = [ descriptor ]; // ------------------ if (target) { this.RegisterListener(descriptor, 'input', (event: Event) => { // we send an extra event when we insert a reference. // so filter that out. this might cause problems for other // callers -- could we use a different filter? if (event.isTrusted) { this.UpdateText(descriptor); this.UpdateColors(); // will send a local event } }); } // ------------------ // // change the default back. this was changed when we were trying to figure // out what was happening with IME, but it had nothing to do with spellcheck. // this.active_editor.node.spellcheck = false; // change the default back this.RegisterListener(descriptor, 'focusin', () => { const restore = this.tolled !== undefined; this.tolled = undefined; // this.editor_node.addEventListener('focusin', () => { // can't happen if (!this.active_editor) { return; } // console.info('focus in'); let text = this.active_editor.node.textContent || ''; if (this.shadow) { this.shadow = false; text = ''; this.active_editor.node.textContent = text; this.active_editor.formatted_text = undefined; // why do we clear this here? } if (text[0] === '{' && text[text.length - 1] === '}') { text = text.substring(1, text.length - 1); this.active_editor.node.textContent = text; this.active_editor.formatted_text = undefined; // why do we clear this here? } // not here // this.editor_node.spellcheck = (text[0] !== '='); // true except for functions this.autocomplete?.ResetBlock(); this.UpdateText(this.active_editor); this.UpdateColors(undefined, true); // toll update event -- we will send in order, below if (restore) { // FIXME: we probably want to hold the caret so we can restore // to a particular place. or set the caret explicitly. const node = this.NodeAtIndex(text.length - 1); // console.info({text, node}); if (node) { this.SetCaret({node: node as ChildNode, offset: (node.textContent || '').length}); } } this.committed = false; this.Publish([ { type: 'start-editing', editor: 'formula-bar' }, { type: 'update', text, cell: this.active_cell, dependencies: this.composite_dependencies }, ]); this.focused_ = true; }); this.RegisterListener(descriptor, 'focusout', (event: FocusEvent) => { // this is new, to support an external "insert function" dialog. // still working out the semantics, but essentially we won't commit // and we'll keep pending text. we need some sort of flag to indicate // that we're in the editing state. const toll = (event.relatedTarget instanceof HTMLElement && event.relatedTarget.dataset.tollEditor); if (this.selecting) { console.info('focusout, but selecting...'); } this.autocomplete?.Hide(); const text = (this.active_editor ? this.GetTextContent(this.active_editor.node).join('') : '').trim(); if (toll) { let substring = text; if (this.active_editor?.node) { const s2c = this.SubstringToCaret2(this.active_editor.node, true); substring = s2c[0]; } this.committed = true; this.tolled = { text, substring, }; this.Publish({ type: 'toll', value: text, }); } else if (this.committed) { this.Publish([ { type: 'stop-editing' }, ]); } else { this.committed = true; this.Publish({ type: 'commit', value: text, }); } // huh? we're publishing this twice? this.Publish([ { type: 'stop-editing' }, ]); this.focused_ = false; if (this.active_editor) { this.active_editor.node.spellcheck = false; // for firefox } }); this.RegisterListener(descriptor, 'keydown', this.FormulaKeyDown.bind(this)); this.RegisterListener(descriptor, 'keyup', this.FormulaKeyUp.bind(this)); // why is this here, instead of in markup? just an oversight? if (this.options.expand_formula_button) { let focus_related_target: HTMLElement|undefined; this.expand_button = DOM.Create('button', 'expand-button', inner_node, { events: { focus: (event: FocusEvent) => { focus_related_target = event.relatedTarget instanceof HTMLElement ? event.relatedTarget : undefined; }, click: (event: MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (this.active_editor) { this.active_editor.node.scrollTop = 0; } if (inner_node.hasAttribute('expanded')) { inner_node.removeAttribute('expanded'); } else { inner_node.setAttribute('expanded', ''); } if (focus_related_target) { focus_related_target.focus(); } }, }, }); } } /** * we might be overthinking this, we don't necessarily need to restore. * * this method will focus the editor and set the caret (to the end, atm) * in case of a tolled editor. the idea is you call Restore() after your * dialog is complete and it's like you are back where you started. * * alternatively, call `Release()` to clean up any saved state. */ public Restore() { const target = this.container_node?.firstElementChild as HTMLDivElement; if (target) { target.focus(); } } /** * release anything that's been tolled. call this if you toll the editor * but don't want to restore it when you are done. I think this will be * the default. */ public Release() { this.tolled = undefined; } public IsElement(element: HTMLElement): boolean { return element === this.active_editor?.node; } public IsExpandButton(element: HTMLElement): boolean { return this.expand_button && (element === this.expand_button); } public InitAddressLabel() { this.address_label.contentEditable = 'true'; this.address_label.spellcheck = false; // on focus, select all // Q: do we do this in other places? we should consolidate // A: I don't think we do just this, usually there's additional logic for % and such this.address_label.addEventListener('focusin', () => { const doc = this.address_label.ownerDocument; // FIXME: close any open editors? (...) // we're now doing this async for all browsers... it's only really // necessary for IE11 and safari, but doesn't hurt requestAnimationFrame(() => { const selection = (doc.defaultView as (Window & typeof globalThis)).getSelection(); const range = doc.createRange(); range.selectNodeContents(this.address_label); selection?.removeAllRanges(); selection?.addRange(range); }); }); this.address_label.addEventListener('keydown', (event) => { if (event.isComposing) { return; } switch (event.key) { case 'Enter': event.stopPropagation(); event.preventDefault(); this.Publish({ type: 'address-label-event', text: this.address_label.textContent || undefined, }); break; case 'Esc': case 'Escape': event.stopPropagation(); event.preventDefault(); this.Publish({ type: 'address-label-event' }); break; } }); } private GetTextContent(node: Node) { const children = node.childNodes; const buffer: string[] = []; for (let i = 0; i < children.length; i++) { const child = children[i]; switch (child.nodeType) { case Node.ELEMENT_NODE: buffer.push(...this.GetTextContent(child)); if (child instanceof Element && child.tagName === 'DIV') { buffer.push('\n'); } break; case Node.TEXT_NODE: if (child.nodeValue) { buffer.push(child.nodeValue); } break; } } return buffer; } private FormulaKeyUp(event: KeyboardEvent){ if (event.isComposing) { return; } if (this.autocomplete) { const ac_result = this.autocomplete.HandleKey('keyup', event); if (ac_result.handled) { return; } // this.FlushReference(); } } private FormulaKeyDown(event: KeyboardEvent){ if (event.isComposing) { return; } if (this.autocomplete) { const ac_result = this.autocomplete.HandleKey('keydown', event); if (ac_result.accept) this.AcceptAutocomplete(ac_result); if (ac_result.handled) return; } switch (event.key){ case 'F4': if (this.selecting) { this.Publish({ type: 'toggle-reference' }); } break; case 'Enter': case 'Tab': { // this.selecting_ = false; const array = (event.key === 'Enter' && event.ctrlKey && event.shiftKey); // I think we use this nontstandard routine so that we preserve // newlines? not sure. would like to see the motivation for it. const text = (this.active_editor ? this.GetTextContent(this.active_editor.node).join('') : '').trim(); this.committed = true; this.Publish({ type: 'commit', // selection: this.selection, value: text, event, array, }); // this.FlushReference(); } break; case 'Escape': case 'Esc': // this.selecting_ = false; this.committed = true; this.Publish({ type: 'discard' }); // this.FlushReference(); break; default: return; } event.stopPropagation(); event.preventDefault(); } }