/* * 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 { Editor, type NodeDescriptor } from './editor'; import { Area, Cell, type CellStyle, type CellValue, Rectangle, Style, type Theme, ResolveThemeColor } from 'treb-base-types'; import { DataModel, type ViewModel, type GridSelection } from 'treb-data-model'; import { Autocomplete } from './autocomplete'; import { UA } from '../util/ua'; export type OverlayEditorResult = 'handled' | 'commit' | 'discard'; /** * but when to send it? */ export interface ResetSelectionEvent { type: 'reset-selection'; } export class OverlayEditor extends Editor { // --- do we actually need this? --------------------------------------------- /** * selection being edited. note that this is private rather than protected * in an effort to prevent subclasses from accidentally using shallow copies */ private internal_selection: GridSelection = { target: { row: 0, column: 0 }, area: new Area({ row: 0, column: 0 }), }; /** accessor for selection */ public get selection(){ return this.internal_selection; } /** set selection, deep copy */ public set selection(rhs: GridSelection){ if (!rhs){ const zero = {row: 0, column: 0}; this.internal_selection = {target: zero, area: new Area(zero)}; } else { const target = rhs.target || rhs.area.start; this.internal_selection = { target: {row: target.row, column: target.column}, area: new Area(rhs.area.start, rhs.area.end), }; } } // --------------------------------------------------------------------------- /** possibly carrying over a font face (+size?) */ public edit_style?: CellStyle; /** * this is a flag used to indicate when we need to reset the selection. * the issue has to do with selecting cells via arrow keys; if you do * that twice, the second time the selection starts on the cell you * selected the first time. so we want to fix that. * * I guess that used to work with an 'end-selection' event (although it * didn't change the sheet) but that doesn't happen anymore because * selecting state is determined dynamically now. */ public reset_selection = false; /** we could use the descriptor reference */ public edit_node: HTMLElement & ElementContentEditable; /** narrowing from superclass */ public container_node: HTMLElement; /** special node for ICE */ public edit_inset: HTMLElement; public scale = 1; // this should go into theme, since it tends to follow it /** shadow property */ private internal_editing = false; /** accessor */ public get editing(): boolean { return this.internal_editing; } /** * this is only set one time for each state, so it would be more * efficient to inline it unless that's going to change */ protected set editing(state: boolean) { if (this.internal_editing !== state) { this.internal_editing = state; if (state) { this.container_node.style.opacity = '1'; this.container_node.style.pointerEvents = 'initial'; } else { this.container_node.style.opacity = '0'; this.container_node.style.pointerEvents = 'none'; } } } constructor( private container: HTMLElement, private theme: Theme, model: DataModel, view: ViewModel, autocomplete: Autocomplete) { super(model, view, autocomplete); this.container_node = container.querySelector('.treb-overlay-container') as HTMLElement; this.edit_node = this.container_node.querySelector('.treb-overlay-editor') as HTMLElement & ElementContentEditable; if (UA.is_firefox) { this.edit_node.classList.add('firefox'); } // attempting to cancel "auto" keyboard on ios this.edit_node.inputMode = 'none'; //// const descriptor: NodeDescriptor = { node: this.edit_node }; this.nodes = [ descriptor ]; this.active_editor = descriptor; this.RegisterListener(descriptor, 'input', (event: Event) => { if (event instanceof InputEvent && event.isComposing) { return; } if (!event.isTrusted) { this.reset_selection = true; // this is a hack, and unreliable (but works for now) return; } if (this.reset_selection) { this.Publish({ type: 'reset-selection', }); } // this is a new thing that popped up in chrome (actually edge). // not sure what's happening but this seems to clean it up. // we technically could allow a newline here, but... call that a TODO const first_child = this.edit_node.firstChild as HTMLElement; if (first_child && first_child.tagName === 'BR') { this.edit_node.removeChild(first_child); } if (!this.editing) { return; } this.UpdateText(descriptor); this.UpdateColors(); }); this.RegisterListener(descriptor, 'keyup', (event: KeyboardEvent) => { if (event.isComposing || !this.editing) { return; } // we're not doing anything with the result? (...) if (this.autocomplete && this.autocomplete.HandleKey('keyup', event).handled) { return; } }); this.edit_inset = this.container_node.querySelector('.treb-overlay-inset') as HTMLElement; // this.container_node = this.container_node ; // wtf is this? this.ClearContents(); } public UpdateCaption(text = ''): void { this.edit_node.setAttribute('aria-label', text); } public Focus(text = ''): void { // we get unexpected scroll behavior if we focus on the overlay editor // when it is not already focused, and the grid is scrolled. that's because // by default the editor is at (0, 0), so we need to move it before we // focus on it (but only in this case). if (this.edit_node !== this.edit_node.ownerDocument.activeElement) { // this was not correct, but should we add those 2 pixels back? // this.edit_container.style.top = `${this.container.scrollTop + 2}px`; // this.edit_container.style.left = `${this.container.scrollLeft + 2}px`; this.container_node.style.top = `${this.container.scrollTop + this.view.active_sheet.header_offset.y}px`; this.container_node.style.left = `${this.container.scrollLeft + this.view.active_sheet.header_offset.x}px`; } this.edit_node.focus(); this.UpdateCaption(text); } /* TEMP (should be Hide() ?) */ public CloseEditor(): void { this.editing = false; this.reset_selection = false; // this (all) should go into the set visible accessor? (...) this.ClearContents(); this.edit_node.spellcheck = true; // default this.autocomplete?.Hide(); this.active_cell = undefined; } /** * remove contents, plus add mozilla junk node */ public ClearContents(): void { // UA doesn't change, so this should be mapped directly // (meaning function pointer and no test) // ...maybe overoptimizing if (UA.is_firefox) { // in firefox if the node is empty when you focus on it the // cursor shifts up like 1/2 em or something, no idea why // (TODO: check bugs) this.edit_node.innerHTML = ''; } else { this.edit_node.textContent = ''; } } // --------------------------------------------------------------------------- /** * start editing. I'm not sure why we're passing the selection around, * but I don't want to take it out until I can answer that question. * * something to do with keyboard selection? (which needs to be fixed)? */ public Edit(gridselection: GridSelection, rect: Rectangle, cell: Cell, value?: CellValue, event?: Event, edit_style?: CellStyle): void { this.Publish({ type: 'start-editing', editor: 'ice', }); this.active_cell = cell; this.target_address = {...gridselection.target}; this.reset_selection = false; const style: CellStyle = JSON.parse(JSON.stringify(cell.style || {})); // clone // // CURRENT ISSUE: this works, but the style is not actually applied to // the cell. we need to modify the commit routine to apply the style. // I think? // // WAIT, that might not be right... might have something to do with // default? // this.edit_style = edit_style; // set (or unset) if (edit_style?.font_face) { style.font_face = edit_style.font_face; } const font_info = Style.CompositeFont(this.theme.grid_cell_font_size, style, this.scale, this.theme); this.edit_node.style.font = font_info.font; if (font_info.variants) { this.edit_node.style.fontVariant = font_info.variants; } else { this.edit_node.style.fontVariant = ''; } this.edit_node.style.color = ResolveThemeColor(this.theme, style.text, 1); this.edit_inset.style.backgroundColor = ResolveThemeColor(this.theme, style.fill, 0); // NOTE: now that we dropped support for IE11, we can probably // remove more than one class at the same time. // (but apparently firefox didn't support multiple classes either, // until v[x]? I think that may have been years ago...) switch (style.horizontal_align) { case 'right': // Style.HorizontalAlign.Right: this.container_node.classList.remove('align-center', 'align-left'); this.container_node.classList.add('align-right'); break; case 'center': // Style.HorizontalAlign.Center: this.container_node.classList.remove('align-right', 'align-left'); this.container_node.classList.add('align-center'); break; default: this.container_node.classList.remove('align-right', 'align-center'); this.container_node.classList.add('align-left'); break; } this.edit_node.style.paddingBottom = `${ Math.max(0, (self.devicePixelRatio||1) - 1)}px`; // console.info('pb', this.edit_node.style.paddingBottom); // TODO: alignment, underline (strike?) // bold/italic already work because those are font properties. const value_string = value?.toString() || ''; // do this only if there's existing text, in which case we're not // typing... or it could be a %, which is OK because the key is a number if (value_string && value_string[0] === '=') { this.edit_node.spellcheck = false; } // move the rect before monkeying with the selection, otherwise we // get jumpy scrolling behavior in scrolled contexts. rect.ApplyStyle(this.container_node); // trial and error. still a little wonky at scales, but reasonable. const offset = UA.is_mac ? 0 : 0.5; this.edit_node.style.bottom = offset.toFixed(2) + 'px'; // does this need to scale for dpr? not sure this.autocomplete?.ResetBlock(); this.selection = gridselection; if (typeof value !== 'undefined') { const percent = value_string[0] !== '=' && value_string[value_string.length - 1] === '%'; const value_length = value_string.length; this.edit_node.textContent = value_string; this.SetCaret({ node: this.edit_node, offset: value_length - (percent ? 1 : 0) }) // event moved below } this.editing = true; Promise.resolve().then(() => { if (this.active_editor) { this.active_editor.formatted_text = undefined; // necessary? (...) this.UpdateText(this.active_editor); this.UpdateColors(); } // not sure about these two tests, they're from the old version if (!event && value !== undefined) { this.Publish({type: 'update', text: value.toString(), dependencies: this.composite_dependencies}); } }); } public GetEditState() { const text = this.active_editor?.node?.textContent || ''; let substring = ''; if (this.active_editor?.node) { substring = this.SubstringToCaret2(this.active_editor.node, true)[0]; } return { text, substring }; } /** * check if we want to handle this key. we have some special cases (tab, * enter, escape) where we do take some action but we also let the * spreadsheet handle the key. for those we have some additional return * values. * * NOTE this is *not* added as an event handler -- it's called by the grid * * @param event * @returns */ public HandleKeyDown(event: KeyboardEvent): OverlayEditorResult|undefined { // skip keys if we're not editing if (!this.editing) { return undefined; // not handled } // pass through to autocomplete if (this.autocomplete) { const ac = this.autocomplete.HandleKey('keydown', event); if (ac.accept){ this.AcceptAutocomplete(ac); } if (ac.handled) { return 'handled'; } } switch (event.key) { case 'F4': return undefined; case 'Enter': case 'Tab': return 'commit'; case 'Escape': case 'Esc': return 'discard'; case 'ArrowUp': case 'ArrowDown': case 'ArrowLeft': case 'ArrowRight': case 'Up': case 'Down': case 'Left': case 'Right': return this.selecting ? undefined : 'commit'; } return 'handled'; // we will consume } public UpdateScale(scale: number): void { // we're not changing in place, so this won't affect any open editors... // I think there's a case where you change scale without focusing (using // the mouse wheel) which might result in incorrect rendering... TODO/FIXME this.scale = scale; } }