/* * 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 { EmbeddedSpreadsheet } from '../embedded-spreadsheet'; import type { EmbeddedSpreadsheetOptions } from '../options'; import css from 'treb-embed/style/treb-spreadsheet-element.scss'; import html from 'treb-embed/markup/layout.html'; import toolbar_html from 'treb-embed/markup/toolbar.html'; // import css from '../../style/treb-spreadsheet-element.scss'; // import html from '../../markup/layout.html'; // import toolbar_html from '../../markup/toolbar.html'; import { NumberFormatCache } from 'treb-format'; import { ColorFunctions, type Color, IsThemeColor } from 'treb-base-types'; import { Measurement } from 'treb-utils'; import type { ToolbarMessage } from '../toolbar-message'; import { DOMContext } from 'treb-base-types'; import { font_stack_labels, type FontStackType } from 'treb-base-types/src/font-stack'; /** with a view towards i18n */ const default_titles: Record = { close_dialog: 'Close dialog', insert_function: 'Insert function...', delete_sheet: 'Delete current sheet', add_sheet: 'Add sheet', document_modified: 'This document has been modified from the original version.', recalculate: 'Recalculate', toggle_toolbar: 'Toggle toolbar', export: 'Export as XLSX', revert: 'Revert to original version', about: `What's this?`, toggle_sidebar: 'Toggle sidebar', }; /** @internal */ export class SpreadsheetConstructor { /** container, if any */ public root?: HTMLElement; /** spreadsheet instance */ public sheet?: EmbeddedSpreadsheet; /** current border color. will be applied to new borders. */ protected border_color?: Color; /** color bar elements, since we update them frequently */ protected color_bar_elements: Record = {}; /** some menu buttons change icons from time to time */ protected replace_targets: Record = {}; /** root layout element */ protected layout_element?: HTMLElement; /** views container */ protected views?: HTMLElement; /** * handle to the revert button, so we can adjust it. we can use * container classes for the most part but we are updating the title. * (FIXME: double-up the button, no reference required) */ protected revert_button?: HTMLElement; protected revert_state = false; /** cached controls */ protected toolbar_controls: Record = {}; /** swatch lists in color chooser */ protected swatch_lists: { theme?: HTMLDivElement, other?: HTMLDivElement, } = {}; protected DOM: DOMContext; // constructor(root?: HTMLElement|string) { if (typeof root === 'string') { root = document.querySelector(root) as HTMLElement; } this.DOM = DOMContext.GetInstance(root?.ownerDocument); // there's a possibility this could be running in a node environment. // in that case (wihtout a shim) HTMLElement will not exist, so we can't // check type. // but in that case what would root be? (...) if (this.DOM.view && root instanceof this.DOM.view.HTMLElement) { this.root = root; const style_node = this.DOM.doc?.head.querySelector('style[treb-stylesheet]'); if (!style_node) { this.DOM.doc?.head.prepend( this.DOM.Create('style', undefined, undefined, { text: css, attrs: { 'treb-stylesheet': '' } })); } } } /** * coerce an attribute value into a more useful type. for attributes, * having no value implies "true". false should be explicitly set as * "false"; we don't, atm, support falsy values like '0' (that would be * coerced to a number). */ public CoerceAttributeValue(value: string|null): number|boolean|string { if (value === null || value.toString().toLowerCase() === 'true' || value === '') { return true; } else if (value.toLowerCase() === 'false') { return false; } else { const test = Number(value); if (!isNaN(test)) { return test; } } // default to string, if it was null default to empty string (no nulls) return value || ''; } /** * get options from node attributes. we're still working on final * semantics but at the moment we'll translate hyphen-separated-options * to our standard snake_case_options. * * we also support the old-style data-options * * @returns */ public ParseOptionAttributes(): Partial { const attribute_options: Record = {}; if (this.root) { const names = this.root.getAttributeNames(); for (const name of names) { switch (name) { // skip case 'class': case 'style': case 'id': continue; // old-style options (in two flavors). old-style options are // comma-delimited an in the form `key=value`, or just `key` // for boolean true. case 'data-options': case 'options': { // in this case use the original name, which should // be in snake_case (for backcompat) const value = this.root.getAttribute(name) || ''; const elements = value.split(','); // console.info(elements); for (const element of elements) { const parts = element.split(/=/); if (parts.length === 1) { attribute_options[parts[0]] = true; } else { attribute_options[parts[0]] = this.CoerceAttributeValue(parts[1]); } } } continue; // old style (not handling though) case 'data-treb': continue; // has special handling as an attribute case 'inline-document': continue; // special case case 'src': attribute_options.document = this.root.getAttribute('src') || undefined; continue; } // attrtibute options are in kebab-case while our internal // options are still in snake_case. attribute_options[name.replace(/-/g, '_')] = this.CoerceAttributeValue(this.root.getAttribute(name)); } } return { ...attribute_options } as Partial; } /** * attach content to element. for custom elements, this is called via * the connectedCallback call. for elements created with the API, we * call it immediately. */ public AttachElement(options: EmbeddedSpreadsheetOptions = {}) { options = { ...this.ParseOptionAttributes(), ...options, }; if (this.root) { // set a default size if the node does not have width or height. // we do this with a class, so it's easier to override if desired. // could we use vars? (...) if (!options.headless) { const rect = this.root.getBoundingClientRect(); if (!rect.width || !rect.height) { this.root.classList.add('treb-default-size'); } } // inline-document means look in the tag contents for a script // element, and use that. the script must have type "application/json", // and if it has a name, the name must match the value of the // inline-document attribute. // // so either // // // // // // or // // // // if (this.root.hasAttribute('inline-document')) { const inline_name = this.root.getAttribute('inline-document') || ''; for (const element of Array.from(this.root.children)) { if (this.DOM.view && element instanceof this.DOM.view.HTMLScriptElement) { if (element.type === 'application/json') { const name = element.getAttribute('name') || ''; // add special case for aggressive clients/wrappers if (name === inline_name || !name && inline_name === 'true') { const content = element.textContent; if (content) { try { options.inline_document = JSON.parse(content); } catch (err) { console.error(err); } } break; } } } } if (!options.inline_document) { console.warn('inline document failed'); } } this.root.innerHTML = html; options.container = this.root.querySelector('.treb-layout-spreadsheet') as HTMLElement; } if (!process.env.XLSX_SUPPORT) { options.export = false; // remove export button from sidebar } // set a local variable so we don't have to keep testing the member this.sheet = new EmbeddedSpreadsheet(options); if (this.root) { this.CreateLayout(this.sheet, this.root); } } public CreateLayout(sheet: EmbeddedSpreadsheet, root: HTMLElement) { // call our internal resize method when the node is resized // (primary instance will handle views) // why are we doing this here? ... because this is layout? dunno const resizeObserver = new ResizeObserver(() => sheet.Resize()); resizeObserver.observe(root); // const resizeObserver = new ResizeObserver(() => sheet.Resize()); // resizeObserver.observe(root); // handle sidebar collapse this.layout_element = root.querySelector('.treb-main') as HTMLElement; const button = root.querySelector('.treb-toggle-sidebar-button'); if (button && this.layout_element) { const element = this.layout_element; button.addEventListener('click', () => { // attribute is set if it has a value and that value is either // empty or "true"; we don't accept any other values, because // that just makes extra work. const value = element.getAttribute('collapsed'); const state = (typeof value === 'string' && (value === '' || value === 'true')); // toggle if (state) { element.removeAttribute('collapsed'); } else { element.setAttribute('collapsed', ''); } }); } // --- set initial state before enabling transitions ----------------------- if (sheet.options.toolbar === 'show' || sheet.options.toolbar === 'show-narrow') { this.layout_element?.setAttribute('toolbar', ''); } if (sheet.options.collapsed) { this.layout_element?.setAttribute('collapsed', ''); } // --- revert indicator ---------------------------------------------------- const revert_indicator = root.querySelector('[data-command=revert-indicator]'); if (this.DOM.view && revert_indicator instanceof this.DOM.view.HTMLElement) { if (sheet.options.revert_indicator) { revert_indicator.addEventListener('click', () => { sheet.HandleToolbarMessage({ command: 'revert-indicator', }); }); } else { revert_indicator.style.display = 'none'; } } // --- toolbar/sidebar ----------------------------------------------------- const sidebar = root.querySelector('.treb-layout-sidebar'); sidebar?.addEventListener('click', event => { const target = event.target as HTMLElement; if (target.dataset.command) { switch (target.dataset.command) { case 'toggle-toolbar': this.ToggleToolbar(); break; default: sheet.HandleToolbarMessage({ command: target.dataset.command, } as ToolbarMessage); break; } } }); if (sheet.options.toolbar) { this.AttachToolbar(sheet, root); } // --- hide/remove --------------------------------------------------------- // compare conditional items against options. not sure which way we're // ultimately going to land with the option names. for the time being // I'm going to do this the verbose way. const conditional_map: Record = { // 'file-menu': !!sheet.options.file_menu, 'table-button': !!sheet.options.table_button, // 'chart-menu': !!sheet.options.chart_menu, // 'font-scale': !!sheet.options.font_scale, 'revert': !!sheet.options.revert_button, 'toolbar': !!sheet.options.toolbar, 'export': !!sheet.options.export, 'insert-function': !!sheet.options.insert_function_button, // the following won't work as expected in split, because this // code won't be run when the new view is created -- do something // else // resize should actually work because we're hiding new view // resize handles via positioning 'resize': !!sheet.options.resizable, // add-tab and delete-tab will still work for the menu 'add-tab': !!sheet.options.add_tab, 'delete-tab': (!!sheet.options.delete_tab || !!sheet.options.add_tab), // we actually don't want to remove stats if it's not in use, because // we need it for layout // 'stats': !!sheet.options.stats, // scale control is not (yet) declarative, so this isn't effective anyway // 'scale-control': !!sheet.options.scale_control, } for (const [key, value] of Object.entries(conditional_map)) { if (!value) { const elements = this.layout_element.querySelectorAll(`[data-conditional=${key}]`) as NodeListOf; for (const element of Array.from(elements)) { element.style.display = 'none'; } } } if (sheet.options.revert_button) { this.revert_button = this.layout_element.querySelector('[data-command=revert]') || undefined; } // --- resize -------------------------------------------------------------- if (sheet.options.resizable) { const size = { width: 0, height: 0 }; const position = { x: 0, y: 0 }; const delta = { x: 0, y: 0 }; // const resize_container = root.querySelector('.treb-layout-resize-container'); this.views = root.querySelector('.treb-views') || undefined; let mask: HTMLElement|undefined; let resizer: HTMLElement|undefined; const resize_handle = root.querySelector('.treb-layout-resize-handle') as HTMLElement; // mouse up handler added to mask (when created) const mouseup = () => finish(); // mouse move handler added to mask (when created) const mousemove = ((event: MouseEvent) => { if (event.buttons === 0) { finish(); } else { delta.x = event.screenX - position.x; delta.y = event.screenY - position.y; if (resizer) { resizer.style.width = (size.width + delta.x) + 'px'; resizer.style.height = (size.height + delta.y) + 'px'; } } }); // clean up mask and layout rectangle const finish = () => { // resize_handle.classList.remove('retain-opacity'); // we're not using this anymore if (delta.x || delta.y) { const rect = root.getBoundingClientRect(); if (!sheet.options.constrain_width) { root.style.width = (rect.width + delta.x) + 'px'; } root.style.height = (rect.height + delta.y) + 'px'; } if (mask) { mask.removeEventListener('mouseup', mouseup); mask.removeEventListener('mousemove', mousemove); mask.parentElement?.removeChild(mask); mask = undefined; } resizer?.parentElement?.removeChild(resizer); resizer = undefined; }; resize_handle.addEventListener('mousedown', (event: MouseEvent) => { event.stopPropagation(); event.preventDefault(); const resize_parent = root.querySelector('.treb-main') as HTMLElement; // was document.body resizer = this.DOM.Div('treb-resize-rect', resize_parent); mask = this.DOM.Div('treb-resize-mask', resize_parent, { attrs: { style: 'cursor: nw-resize;' }, events: { mouseup, mousemove }, }); // mask.addEventListener('mouseup', mouse_up); // mask.addEventListener('mousemove', mouse_move); // resize_handle.classList.add('retain-opacity'); // we're not using this anymore position.x = event.screenX; position.y = event.screenY; delta.x = 0; delta.y = 0; const layouts = this.views?.querySelectorAll('.treb-spreadsheet-body'); const rects = Array.from(layouts||[]).map(element => element.getBoundingClientRect()); if (rects.length) { const composite: { top: number, left: number, right: number, bottom: number } = JSON.parse(JSON.stringify(rects.shift())); for (const rect of rects) { composite.top = Math.min(rect.top, composite.top); composite.left = Math.min(rect.left, composite.left); composite.right = Math.max(rect.right, composite.right); composite.bottom = Math.max(rect.bottom, composite.bottom); } const width = composite.right - composite.left; const height = composite.bottom - composite.top; resizer.style.top = (composite.top) + 'px'; resizer.style.left = (composite.left) + 'px'; resizer.style.width = (width) + 'px'; resizer.style.height = (height) + 'px'; size.width = width; size.height = height; } }); } // --- titles -------------------------------------------------------------- const elements = Array.from(this.layout_element.querySelectorAll('[data-title]')); for (const element of elements) { if (element instanceof HTMLElement) { // temp workaround if (element.dataset.activeTitle) { continue; } if (element.dataset.title && default_titles[element.dataset.title]) { element.title = default_titles[element.dataset.title]; } } } // --- animated ------------------------------------------------------------ // requestAnimationFrame(() => { setTimeout(() => this.layout_element?.setAttribute('animate', ''), 250); } public ToggleToolbar() { if (this.layout_element) { const value = this.layout_element.getAttribute('toolbar'); const state = (typeof value === 'string' && (value === '' || value === 'true')); if (state) { this.layout_element.removeAttribute('toolbar'); } else { this.layout_element.setAttribute('toolbar', ''); } } } public UpdateSelectionStyle(sheet: EmbeddedSpreadsheet, toolbar: HTMLElement, comment_box: HTMLTextAreaElement) { const state = sheet.selection_state; // unset all comment_box.value = ''; for (const value of Object.values(this.toolbar_controls)) { if (value) { // value.classList.remove('treb-active'); value.removeAttribute('active'); if (value.dataset.inactiveTitle) { value.title = value.dataset.inactiveTitle; } } } const Activate = (element?: HTMLElement) => { if (element) { // element.classList.add('treb-active'); element.setAttribute('active', ''); if (element.dataset.activeTitle) { element.title = element.dataset.activeTitle; } } }; if (state.comment) { Activate(this.toolbar_controls.comment); comment_box.value = state.comment; } if (state.style?.locked) { Activate(this.toolbar_controls.locked); } if (state.frozen) { Activate(this.toolbar_controls.freeze); } if (state.style?.wrap) { Activate(this.toolbar_controls.wrap); } if (this.toolbar_controls.table) { if (state.table) { Activate(this.toolbar_controls.table); this.toolbar_controls.table.dataset.command = 'remove-table'; } else { this.toolbar_controls.table.dataset.command = 'insert-table'; } } if (this.toolbar_controls.merge) { if (state.merge) { Activate(this.toolbar_controls.merge); this.toolbar_controls.merge.dataset.command = 'unmerge-cells'; } else { this.toolbar_controls.merge.dataset.command = 'merge-cells'; } } if (this.toolbar_controls.stack) { if (state.style?.font_face) { if (state.style.font_face.startsWith('stack:')) { const stack_name = state.style.font_face.substring(6) as FontStackType; this.toolbar_controls.stack.textContent = font_stack_labels[stack_name] || ''; this.toolbar_controls.stack.dataset.fontStack = stack_name; } else { this.toolbar_controls.stack.textContent = ''; this.toolbar_controls.stack.dataset.fontStack = ''; } } else { // } if (!state.empty) { this.toolbar_controls.stack.textContent = font_stack_labels.default || ''; this.toolbar_controls.stack.dataset.fontStack = 'default'; } } const format = this.toolbar_controls.format as HTMLInputElement; if (format) { if (state.style?.number_format) { format.value = NumberFormatCache.SymbolicName(state.style.number_format) || state.style.number_format; } else { format.value = 'General'; } } const scale = this.toolbar_controls.scale as HTMLInputElement; if (scale) { scale.value = sheet.FormatNumber(state.relative_font_size || 1, '0.00'); } switch (state.style?.horizontal_align) { case 'left': Activate(this.toolbar_controls.left); break; case 'center': Activate(this.toolbar_controls.center); break; case 'right': Activate(this.toolbar_controls.right); break; } switch (state.style?.vertical_align) { case 'top': Activate(this.toolbar_controls.top); break; case 'middle': Activate(this.toolbar_controls.middle); break; case 'bottom': Activate(this.toolbar_controls.bottom); break; } } public UpdateDocumentStyles(sheet: EmbeddedSpreadsheet, format_menu: HTMLElement) { // --- colors ------------------------------------------------------------- { let fragment = this.DOM.Fragment(); const length = sheet.document_styles.theme_colors.length; const themes = ['Background', 'Text', 'Background', 'Text', 'Accent']; if (length) { const depth = sheet.document_styles.theme_colors[0].length; for (let i = 0; i < depth; i++) { for (let j = 0; j < length; j++) { const entry = sheet.document_styles.theme_colors[j][i]; const style = `background: ${entry.resolved};`; let title = themes[j] || themes[4]; if (IsThemeColor(entry.color) && entry.color.tint) { title += ` (${(entry.color.tint > 0 ? '+' : '') + (entry.color.tint) * 100}%)`; } else { // set theme default colors if (j === 0) { this.color_bar_elements.fill?.style.setProperty('--treb-default-color', entry.resolved); } else if (j === 1) { this.color_bar_elements.text?.style.setProperty('--treb-default-color', entry.resolved); this.color_bar_elements.border?.style.setProperty('--treb-default-color', entry.resolved); } } this.DOM.Create('button', undefined, fragment, { attrs: { style, title }, data: { command: 'set-color', color: JSON.stringify(entry.color) }, }); } } } this.swatch_lists.theme?.replaceChildren(fragment); fragment = this.DOM.Fragment(); /* this.DOM.Create('button', 'treb-default-color', fragment, { attrs: { title: 'Default color' }, data: { command: 'set-color', color: JSON.stringify({}) }, }); */ const colors = ['Black', 'White', 'Gray', 'Red', 'Orange', 'Yellow', 'Green', 'Blue', 'Indigo', 'Violet']; const lc = colors.map(color => color.toLowerCase()); const additional_colors = sheet.document_styles.colors.filter(test => { return !lc.includes(test.toLowerCase()); }); for (const text of [...colors, ...additional_colors]) { const style = `background: ${text.toLowerCase()};`; this.DOM.Create('button', undefined, fragment, { attrs: { style, title: text, }, data: { command: 'set-color', color: JSON.stringify({text: text.toLowerCase()})}, }); // Element('button', fragment, { style, title: text, data: { command: 'set-color', color: JSON.stringify({text: text.toLowerCase()})}}); } this.swatch_lists.other?.replaceChildren(fragment); } // --- number formats ----------------------------------------------------- const number_formats: string[] = [ 'General', 'Number', 'Integer', 'Percent', 'Fraction', 'Accounting', 'Currency', 'Scientific', ]; const date_formats: string[] = [ 'Timestamp', 'Long Date', 'Short Date', ]; for (const format of sheet.document_styles.number_formats) { if (NumberFormatCache.SymbolicName(NumberFormatCache.Translate(format))) { continue; } const instance = NumberFormatCache.Get(format); if (instance.date_format) { date_formats.push(format); } else { number_formats.push(format); } } const Button = (format: string) => this.DOM.Create('button', undefined, undefined, { text: format, data: { format, command: 'number-format' }, }); const fragment = this.DOM.Fragment(); fragment.append(...number_formats.map(format => Button(format))); fragment.append(this.DOM.Div(undefined, undefined, { attrs: { separator: '' }})); fragment.append(...date_formats.map(format => Button(format))); format_menu.textContent = ''; format_menu.append(fragment); } /** * setting explicit state on the revert button (if enabled). * * @param sheet */ public UpdateRevertState(sheet: EmbeddedSpreadsheet) { const state = sheet.can_revert; if (this.revert_state === state) { return; // nothing to do } this.revert_state = state; if (this.revert_button || sheet.options.revert_indicator) { if (this.revert_state) { this.views?.classList.add('treb-can-revert'); } else { this.views?.classList.remove('treb-can-revert'); } if (this.revert_button) { this.revert_button.dataset.canRevert = state ? 'true' : 'false'; // FIXME: remove // FIXME: container classes, double up button if (state) { this.revert_button.classList.remove('sidebar-disabled'); this.revert_button.title = 'Revert to original version'; // FIXME: strings } else { this.revert_button.classList.add('sidebar-disabled'); this.revert_button.title = 'This is the original version of the document'; // FIXME: strings } } } } /** * replace a given template with its contents. */ public ReplaceTemplate(root: HTMLElement, selector: string, remove = true) { const template = root.querySelector(selector) as HTMLTemplateElement; if (template && template.parentElement) { // console.info(template, template.parentElement); for (const child of Array.from(template.content.children)) { template.parentElement.insertBefore(child, template); } if (remove) { template.parentElement.removeChild(template); } } else { console.warn('template not found', selector); } } public AttachToolbar(sheet: EmbeddedSpreadsheet, root: HTMLElement) { // --- layout -------------------------------------------------------------- const scroller = root.querySelector('.treb-layout-header') as HTMLElement; const toolbar = root.querySelector('.treb-toolbar') as HTMLElement; toolbar.innerHTML = toolbar_html; // adjust toolbar based on options const remove: Array = []; // wide or narrow menu if (sheet.options.toolbar === 'narrow' || sheet.options.toolbar === 'show-narrow') { remove.push(...Array.from(toolbar.querySelectorAll('[wide]'))); } else { remove.push(...Array.from(toolbar.querySelectorAll('[narrow]'))); } // optional toolbar items if (!sheet.options.file_menu) { remove.push(toolbar.querySelector('[file-menu]')); } if (!sheet.options.indent_buttons) { remove.push(toolbar.querySelector('[indent-group]')); } if (!sheet.options.font_scale) { remove.push(toolbar.querySelector('[font-scale]')); } if (!sheet.options.font_stack) { remove.push(toolbar.querySelector('[font-stack]')); } else { const buttons = toolbar.querySelectorAll(`[font-stack] button[data-font-stack]`) as NodeListOf; for (const button of buttons) { if (button.dataset.fontStack) { const label = font_stack_labels[button.dataset.fontStack as FontStackType]; button.textContent = label || ''; } } } if (!sheet.options.chart_menu) { remove.push(toolbar.querySelector('[chart-menu]')); } if (!sheet.options.freeze_button) { remove.push(toolbar.querySelector('[freeze-button]')); } if (!sheet.options.table_button) { remove.push(toolbar.querySelector('[table-button]')); } if (!sheet.options.add_tab && !sheet.options.delete_tab) { remove.push(...Array.from(toolbar.querySelectorAll('[add-remove-sheet]'))); } if (!sheet.options.toolbar_recalculate_button) { remove.push(toolbar.querySelector('[recalculate-button]')); } if (!process.env.XLSX_SUPPORT) { remove.push(...Array.from(toolbar.querySelectorAll('[xlsx-support]'))); } for (const element of remove) { if (element) { element.parentElement?.removeChild(element); } } const color_chooser = toolbar.querySelector('.treb-color-chooser') as HTMLElement; const comment_box = toolbar.querySelector('.treb-comment-box textarea') as HTMLTextAreaElement; // --- controls ------------------------------------------------------------ for (const [key, value] of Object.entries({ // for align/justify make sure we are collecting the wide // versions. narrow versions don't highlight. 'top': '[wide] [data-command=align-top]', 'middle': '[wide] [data-command=align-middle]', 'bottom': '[wide] [data-command=align-bottom]', 'left': '[wide] [data-command=justify-left]', 'right': '[wide] [data-command=justify-right]', 'center': '[wide] [data-command=justify-center]', 'wrap': '[data-command=wrap-text]', 'merge': '[data-id=merge]', 'comment': '[data-icon=comment]', 'locked': '[data-command=lock-cells]', 'freeze': '[data-command=freeze-panes]', 'table': '[data-icon=table]', 'format': 'input.treb-number-format', 'scale': 'input.treb-font-scale', 'stack': 'button.treb-font-stack', })) { const element = toolbar.querySelector(value) as HTMLElement; if (element) { this.toolbar_controls[key] = element; } else { // console.warn('missing toolbar element', value); } } const swatch_lists = color_chooser.querySelectorAll('.treb-swatches'); this.swatch_lists = { theme: swatch_lists[0] as HTMLDivElement, other: swatch_lists[1] as HTMLDivElement, }; let button = root.querySelector('[data-command=increase-precision') as HTMLElement; if (button) { button.textContent = this.sheet?.FormatNumber(0, '0.00') || ''; } button = root.querySelector('[data-command=decrease-precision') as HTMLElement; if (button) { button.textContent = this.sheet?.FormatNumber(0, '0.0') || ''; } button = toolbar.querySelector('[data-command=update-comment]') as HTMLButtonElement; comment_box.addEventListener('keydown', event => { if (event.key === 'Enter' && (event.shiftKey || event.ctrlKey)) { button.click(); } }); // why are we not just getting all? (...) for (const entry of ['border', 'annotation', 'align', 'justify']) { this.replace_targets[entry] = toolbar.querySelector(`[data-target=${entry}`) as HTMLElement; } for (const entry of ['fill', 'text', 'border']) { this.color_bar_elements[entry] = toolbar.querySelector(`[data-color-bar=${entry}]`) as HTMLElement; } // // unified click handler for toolbar controls // toolbar.addEventListener('click', event => { const target = event.target as HTMLElement; // the toolbar message used to take "data" for historical // reasdons, now it takes inline properties. we can be a little // more precise about this, although we'll have to update if // we add any new data types. const props: { comment?: string; color?: Color; format?: string; scale?: string; font_stack?: string; } = { format: target.dataset.format, scale: target.dataset.scale, font_stack: target.dataset.fontStack, }; let command = target?.dataset.command; if (command) { // we may need to replace an icon in the toolbar const replace = (target.parentElement as HTMLElement)?.dataset.replace; if (replace) { const replace_target = this.replace_targets[replace]; if (replace_target) { replace_target.dataset.command = command; replace_target.title = target.title || ''; } } // for borders, if we have a cached border color add that to the event data if (/^border-/.test(command)) { props.color = this.border_color || {}; } switch (command) { case 'text-color': case 'fill-color': props.color = {}; try { props.color = JSON.parse(target.dataset.color || '{}'); } catch (err) { console.error(err); } break; case 'set-color': // swap command command = color_chooser.dataset.colorCommand || ''; // convert string to color props.color = {}; try { props.color = JSON.parse(target.dataset.color || '{}'); } catch (err) { console.error(err); } // cache for later if (command === 'border-color') { this.border_color = props.color; } // update color bar if (color_chooser.dataset.target) { const replace = this.color_bar_elements[color_chooser.dataset.target]; if (replace) { replace.style.setProperty('--treb-color-bar-color', target.style.backgroundColor); replace.dataset.color = target.dataset.color || '{}'; } } break; case 'update-comment': props.comment = comment_box.value; break; } sheet.HandleToolbarMessage({ command, ...props, } as ToolbarMessage); } }); // common const CreateInputHandler = (selector: string, handler: (value: string) => boolean) => { const input = toolbar.querySelector(selector) as HTMLInputElement; if (input) { let cached_value = ''; input.addEventListener('focusin', () => cached_value = input.value); input.addEventListener('keydown', event => { switch (event.key) { case 'Escape': input.value = cached_value; sheet.Focus(); break; case 'Enter': if (!handler(input.value)) { input.value = cached_value; sheet.Focus(); } break; default: return; } event.stopPropagation(); event.preventDefault(); }); } }; // number format input CreateInputHandler('input.treb-number-format', (format: string) => { if (!format) { return false; } sheet.HandleToolbarMessage({ command: 'number-format', format, }) return true; }); // font scale input CreateInputHandler('input.treb-font-scale', (value: string) => { const scale = Number(value); if (!scale || isNaN(scale)) { console.warn('invalid scale value'); return false; } sheet.HandleToolbarMessage({ command: 'font-scale', scale, }); return true; }); // color chooser const color_input = color_chooser.querySelector('input') as HTMLInputElement; const color_button = color_chooser.querySelector('input + button') as HTMLButtonElement; color_input.addEventListener('input', (event: Event) => { if (event instanceof InputEvent && event.isComposing) { return; } color_button.style.background = color_input.value || ''; // this is a check for "did it resolve properly" const resolved = color_button.style.backgroundColor || '#fff'; const bytes = Measurement.MeasureColor(resolved); const hsl = ColorFunctions.RGBToHSL(bytes[0], bytes[1], bytes[2]); // light or dark based on background color_button.style.color = (hsl.l > .5) ? '#000' : '#fff'; // color for command color_button.dataset.color = JSON.stringify( color_button.style.backgroundColor ? { text: color_button.style.backgroundColor } : {}); }); color_input.addEventListener('keydown', event => { if (event.key === 'Enter') { event.stopPropagation(); event.preventDefault(); color_button.click(); } }); // --- menus --------------------------------------------------------------- // since we are positioning menus with script, they'll get detached // if you scroll the toolbar. we could track scrolling, but it makes // as much sense to just close any open menu. // firefox thinks this is a "scroll linked posiitoning effect". that's // not 100% wrong but it's an absurd thing to flag for that warning. if (/firefox/i.test(navigator.userAgent)) { scroller.addEventListener('scroll', () => { if (this.DOM.view && this.DOM.doc?.activeElement instanceof this.DOM.view.HTMLElement ) { this.DOM.doc.activeElement?.blur(); } }); } else { scroller.addEventListener('scroll', () => sheet.Focus()); } // we set up a key listener for the escape key when menus are open, we // need to remove it if focus goes out of the toolbar let handlers_attached = false; const escape_handler = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); event.preventDefault(); Promise.resolve().then(() => sheet.Focus()); } }; const focusout_handler = (event: FocusEvent) => { if (handlers_attached) { if (event.relatedTarget instanceof Node && toolbar.contains(event.relatedTarget)) { return; } toolbar.removeEventListener('keydown', escape_handler); toolbar.removeEventListener('focusout', focusout_handler); handlers_attached = false; } }; const PositionMenu = (event: FocusEvent|MouseEvent) => { // FIXME: because these are situational, move the // lookups/checks outside of this function into the // event handlers let target = event.target as HTMLElement; let parent = target?.parentElement; if (target?.classList.contains('treb-menu')) { parent = target; for (const child of Array.from(parent.children)) { if (child.tagName === 'BUTTON') { target = child as HTMLElement; break; } } } else if (!parent?.classList.contains('treb-menu')) { return; } // if (parent?.classList.contains('treb-menu')) if (target && parent) { // console.info('positioning'); if (!handlers_attached) { toolbar.addEventListener('focusout', focusout_handler); toolbar.addEventListener('keydown', escape_handler); handlers_attached = true; } // we're sharing the color chooser, drop it in to // the target if this is a color menu if (parent.dataset.colorCommand) { color_chooser.querySelector('.treb-default-color')?.setAttribute('title', parent.dataset.defaultColorText || 'Default color'); const label = color_chooser.querySelector('.treb-default-color-label'); if (label) { label.textContent = parent.dataset.defaultColorText || 'Default color'; } parent.appendChild(color_chooser); color_chooser.dataset.colorCommand = parent.dataset.colorCommand; color_chooser.dataset.target = parent.dataset.replaceColor || ''; } const menu = parent.querySelector('div') as HTMLElement; const scroller_rect = scroller.getBoundingClientRect(); const target_rect = target.getBoundingClientRect(); let { left } = target_rect; // for composite controls, align to the first component // (that only needs to apply on left-aligning) const group = parent.parentElement; if (group?.hasAttribute('composite')) { const element = group.firstElementChild as HTMLElement; const rect = element.getBoundingClientRect(); left = rect.left; } const menu_rect = menu.getBoundingClientRect(); if (parent.classList.contains('treb-submenu')) { menu.style.top = (target_rect.top - menu_rect.height / 2) + 'px'; if (left + target_rect.width + 6 + menu_rect.width > scroller_rect.right) { menu.style.left = (left - 6 - menu_rect.width) + 'px'; } else { menu.style.left = (left + target_rect.width + 6) + 'px'; } } else { menu.style.top = target_rect.bottom + 'px'; // right-align if we would overflow the toolbar if (left + menu_rect.width > scroller_rect.right - 6) { menu.style.left = (target_rect.right - menu_rect.width) + 'px'; } else { menu.style.left = left + 'px'; } } // const focus = menu.querySelector('textarea, input') as HTMLElement; const focus = menu.querySelector('textarea') as HTMLElement; if (focus) { requestAnimationFrame(() => focus.focus()); } } }; const format_menu = this.root?.querySelector('.treb-number-format-menu') as HTMLElement; if (format_menu) { // the first time we call this (now) we want to get the default // colors for text, fill, and border to set buttons. this.UpdateDocumentStyles(sheet, format_menu); this.UpdateSelectionStyle(sheet, toolbar, comment_box); sheet.Subscribe(event => { switch (event.type) { // need to do something with this case 'focus-view': break; case 'data': case 'document-change': case 'load': case 'reset': this.UpdateDocumentStyles(sheet, format_menu); this.UpdateSelectionStyle(sheet, toolbar, comment_box); this.UpdateRevertState(sheet); break; case 'theme-change': this.UpdateDocumentStyles(sheet, format_menu); this.UpdateSelectionStyle(sheet, toolbar, comment_box); break; case 'annotation-selection': case 'selection': this.UpdateSelectionStyle(sheet, toolbar, comment_box); break; } }); } const safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // positioning on focusin will catch keyboard and mouse navigation // ...but this won't work on safari. ... toolbar.addEventListener('focusin', event => { PositionMenu(event); }); // safari disables focus on buttons for some reason. you can override // that, but does anyone do that? also, what about osx? // // for safari, we'll position on mousedown. this will result in some // extra calls to the position routine but that shouldn't be too // bad. we also need to remove focus on the menu elements we're adding // tab indexes to. if(safari) { const elements = Array.from(toolbar.querySelectorAll('.treb-menu') as NodeListOf); for (const element of elements) { element.tabIndex = 0; } toolbar.addEventListener('mousedown', event => { PositionMenu(event); }); } } }