/* * 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 { DataModel, ViewModel, Sheet } from 'treb-data-model'; import { EventSource } from 'treb-utils'; import type { BaseLayout } from '../layout/base_layout'; import { MouseDrag } from './drag_mask'; import type { GridOptions } from './grid_options'; import { type ScaleEvent, ScaleControl } from './scale-control'; import { DOMContext, ResolveThemeColor, type Theme } from 'treb-base-types'; export interface ActivateSheetEvent { type: 'activate-sheet'; sheet: Sheet; } export interface RenameSheetEvent { type: 'rename-sheet'; sheet: Sheet; name: string; } export interface ReorderSheetEvent { type: 'reorder-sheet'; index: number; move_before: number; } export interface AddSheetEvent { type: 'add-sheet'; } export interface DeleteSheetEvent { type: 'delete-sheet'; } export interface CancelEvent { type: 'cancel'; } export type TabEvent = CancelEvent | ScaleEvent | AddSheetEvent | RenameSheetEvent | DeleteSheetEvent | ReorderSheetEvent | ActivateSheetEvent ; export interface StatsEntry { label: string; value: string; } /** * tabs for multiple sheets. at the bottom, atm (FIXME: options?) * * rename tabs (sheets) by double-clicking. this triggers a global * rename over all cells and annotations. * * reorder tabs by dragging. reorders the array, but order is not * material to any other module, so it's basically just housekeeping. * * add a new tab with a special (+) tab (last). * * FIXME: delete tabs... add an (x) to each tab? don't really want to * do that. right-click is out. ??? [A: toolbar menu] * */ export class TabBar extends EventSource { // private container?: HTMLElement; private tab_container?: HTMLElement; private scale_control?: ScaleControl; private stats_panel?: HTMLDivElement; private dragging = false; private double_click_data: { index?: number; timeout?: number; } = {}; private tab_color_cache: Map = new Map(); // tslint:disable-next-line: variable-name private _visible = false; public get visible(): boolean { return this._visible; } public set stats_data(value: StatsEntry[]) { if (this.stats_panel) { this.stats_panel.innerText = ''; // clear for (const entry of value) { this.DOM.Create('span', 'treb-stats-label', this.stats_panel, { text: entry.label }); this.DOM.Create('span', 'treb-stats-value', this.stats_panel, { text: entry.value, }); } } } private container: HTMLElement; private DOM: DOMContext; constructor( private layout: BaseLayout, private model: DataModel, private view: ViewModel, private options: GridOptions, private theme: Theme, // private container: HTMLElement, view_node: HTMLElement, ) { super(); this.DOM = DOMContext.GetInstance(view_node.ownerDocument); this.container = view_node.querySelector('.treb-spreadsheet-footer') as HTMLElement; if (!this.container) { throw new Error('missing container for tab bar'); } // if we're here, we have a tab bar. show unless we're on auto if (options.tab_bar !== 'auto') { this.container.removeAttribute('hidden'); } this.tab_container = this.container.querySelector('.treb-spreadsheet-tabs') as HTMLDivElement; this.container.addEventListener('click', event => { const command = (event.target as HTMLElement)?.dataset.command; if (command) { event.stopPropagation(); event.preventDefault(); switch (command) { case 'add-tab': this.Publish({ type: 'add-sheet' }); break; case 'delete-tab': this.Publish({ type: 'delete-sheet' }); break; default: console.info('unhandled command', command); } } }); if (this.options.stats) { this.stats_panel = this.container.querySelector('.treb-stats-panel') as HTMLDivElement; } if (this.options.scale_control) { const div = this.container.querySelector('.treb-scale-control') as HTMLDivElement; this.scale_control = new ScaleControl(div); this.scale_control.Subscribe((event: ScaleEvent) => { this.Publish(event); }); this.UpdateScale(this.options.initial_scale || 1); // so we only have to write the scaling routine once } } public IsDoubleClick(index: number, timeout = 300): boolean { if (this.double_click_data.index === index ) { clearTimeout(this.double_click_data.timeout); this.double_click_data.index = undefined; this.double_click_data.timeout = undefined; return true; } if (this.double_click_data.timeout) { clearTimeout(this.double_click_data.timeout); } this.double_click_data.index = index; // I don't think the window instance matters for this, // but perhaps it's worth using DOM just for consistency this.double_click_data.timeout = window.setTimeout(() => { this.double_click_data.index = undefined; this.double_click_data.timeout = undefined; }, timeout); return false; } public Hide(): void { this.Show(false); } public Show(show = true): void { if (!this.container) { return; } this._visible = show; if (show) { this.container.removeAttribute('hidden'); } else { this.container.setAttribute('hidden', ''); } } public SetActive(tab: HTMLElement, active: boolean, user = false): void { if (active) { // tab.classList.add('treb-selected'); tab.setAttribute('selected', ''); if (tab.dataset.background_color) { tab.style.backgroundColor = `color-mix(in srgb, ${tab.dataset.background_color} 20%, var(--treb-tab-bar-active-tab-background, #fff))`; tab.style.color = ''; } // this is forcing the page to scroll if the sheet is below // the fold. this is not useful behavior. at the same time, // we do need this to work... we probably have to do it manually // instead of using scrollIntoView. would be nice if we could // toggle this on manual/auto, so user clicks would still be // smooth. call that a TODO requestAnimationFrame(() => { if (user) { tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest'}); } else { if (tab.parentElement) { const left = tab.offsetLeft; const width = tab.offsetWidth; const container_width = tab.parentElement.clientWidth || 0; const scroll_left = tab.parentElement.scrollLeft || 0; if (left > container_width) { tab.parentElement.scrollLeft = left - width; } else if (scroll_left > left) { tab.parentElement.scrollLeft = left; } } } }); } else { // tab.classList.remove('treb-selected'); tab.removeAttribute('selected'); if (tab.dataset.background_color) { tab.style.backgroundColor = tab.dataset.background_color; } if (tab.dataset.foreground_color) { tab.style.color = tab.dataset.foreground_color; } } } /** change scale if we have a scale label */ public UpdateScale(scale: number): void { this.scale_control?.UpdateScale(scale * 100); } public DoubleClickTab(event: MouseEvent, tab: HTMLElement, sheet: Sheet) { tab.contentEditable = 'true'; if (this.DOM.doc) { const selection = this.DOM.GetSelection(); if (selection) { selection.removeAllRanges(); const range = this.DOM.doc.createRange(); range.selectNodeContents(tab); selection.addRange(range); } } tab.addEventListener('keydown', (inner_event: KeyboardEvent) => { switch (inner_event.key) { case 'Enter': // const name = tab.innerText.trim(); this.Publish({ type: 'rename-sheet', name: tab.innerText.trim(), sheet, }); break; case 'Escape': tab.innerText = sheet.name; this.Publish({ type: 'cancel' }); this.Update(true); break; default: return; } inner_event.stopPropagation(); inner_event.preventDefault(); }); tab.addEventListener('focusout', () => { const name = tab.innerText.trim(); if (name !== sheet.name) { this.Publish({ type: 'rename-sheet', name, sheet }); } else { this.Update(true); } }); tab.focus(); } public MouseDownTab(event: MouseEvent, tab: HTMLElement, sheet: Sheet, index: number, tabs: HTMLElement[]) { event.stopPropagation(); event.preventDefault(); if (this.IsDoubleClick(index)) { return; // seems to allow us to process double clicks normally... } this.Publish({ type: 'activate-sheet', sheet }); let rectangles = tabs.map((element) => element.getBoundingClientRect()); let order = index * 2 - 1; // orginal const left = rectangles[0].left; const right = rectangles[rectangles.length - 1].right; const top = rectangles[0].top; const bottom = rectangles[0].bottom; const min = -1; const max = (rectangles.length - 1) * 2 + 1; // see above re: dragging and activation. if the tabs aren't rebuilt, // then the classes won't change. for (const candidate of tabs) { this.SetActive(candidate, candidate === tab, true); } this.dragging = true; // ghost is a good idea but we need to delay it, because // it starts to flicker if you just click a tab to select // it (or double click to rename) /* const ghost = document.createElement('div'); ghost.classList.add('ghost-tab'); ghost.appendChild(tab.cloneNode(true)); ghost.style.top = '-1000px'; this.layout.mask.appendChild(ghost); */ MouseDrag(this.layout.mask, [], (move_event) => { const [x, y] = [move_event.clientX, move_event.clientY]; /* ghost.style.top = `${y}px`; ghost.style.left = `${x}px`; */ if (y > top && y < bottom) { let new_order = order; if (x < left) { new_order = min; } else if (x > right) { new_order = max; } else { for (let i = 0; i < rectangles.length; i++) { const rectangle = rectangles[i]; if (x >= rectangle.left && x <= rectangle.right) { if (i !== index) { if (x >= rectangle.left + rectangle.width / 2) { new_order = (i * 2) + 1; } else { new_order = (i * 2) - 1; } } break; } } } if (new_order !== order) { order = new_order; tab.style.order = order.toString(); rectangles = tabs.map((element) => element.getBoundingClientRect()); } } }, () => { let current = index; let move_before = (order + 1) / 2; /* this.layout.mask.removeChild(ghost); */ // console.info('set false') this.dragging = false; // the indexes we have are visible tabs only, so we may need // to adjust if there are hidden tabs in between. for (let i = 0; i < this.model.sheets.length; i++) { if (!this.model.sheets.list[i].visible) { if (current >= i) { current++; } if (move_before >= i) { move_before++; } } } if ((current === move_before) || (current === 0 && move_before <= 0) || (current === tabs.length - 1 && move_before >= tabs.length - 1)) { // didn't change } else { this.Publish({type: 'reorder-sheet', index: current, move_before}); } }); } public UpdateTheme() { this.tab_color_cache.clear(); this.Update(); } /** * update tabs from model. * * @param user - this is a user action, so use smooth scrolling * when activating the tab. otherwise it's automatic so jump. */ public Update(user = false): void { this.tab_color_cache.clear(); // we're setting tab color but it's not getting updated otherwise // this is a hack to normalize behavior if you try to re-order // a tab that's not the active tab. what ordinarily happens is // we start the drag, but then Update is called again which rebuilds // tabs and throws out the old set. // at the same time we want to activate on mousedown, not mouseup. // so for the time being we just block the rebuild if we are dragging. // longer term it's a FIXME. if (this.dragging) { return; } if (this.options.tab_bar === 'auto') { const visible_count = this.model.sheets.list.reduce((count, test) => test.visible ? count + 1 : count, 0); if (visible_count <= 1) { this.Show(false); return; } this.Show(true); } if (!this.tab_container) { return; } // clear this.tab_container.innerText = ''; // we need to pass the full array to the drag function, so collect them const tabs: HTMLElement[] = []; for (const sheet of this.model.sheets.list) { if (!sheet.visible) { continue; } const index = tabs.length; const tab = this.DOM.Create('li'); tab.setAttribute('tabindex', '0'); if (sheet.tab_color) { const id = sheet.id; if (!this.tab_color_cache.has(id)) { const background = ResolveThemeColor(this.theme, sheet.tab_color); const foreground = ResolveThemeColor(this.theme, { offset: sheet.tab_color }); if (background && foreground) { this.tab_color_cache.set(id, { background, foreground }); } } const color = this.tab_color_cache.get(id); if (color) { tab.style.backgroundColor = color.background; tab.style.color = color.foreground; tab.dataset.background_color = color.background; tab.dataset.foreground_color = color.foreground; } } // tab.classList.add('tab'); tab.style.order = (index * 2).toString(); tab.role = 'tab'; this.SetActive(tab, sheet === this.view.active_sheet, user); const mousedown = (event: MouseEvent) => this.MouseDownTab(event, tab, sheet, index, tabs); const doubleclick = (event: MouseEvent) => { tab.removeEventListener('mousedown', mousedown); tab.removeEventListener('dblclick', doubleclick); this.DoubleClickTab(event, tab, sheet); }; // you need the inner span for ellipsis, if we want that tab.textContent = sheet.name; // tab.innerHTML = `${sheet.name}`; tab.addEventListener('dblclick', doubleclick); tab.addEventListener('mousedown', mousedown); this.tab_container.appendChild(tab); tabs.push(tab); } } }