import { Property } from 'csstype'; import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import * as textfit from 'textfit'; import { Doc, Field } from '../../../fields/Doc'; import { NumCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DragManager } from '../../util/DragManager'; import { undoable, UndoManager } from '../../util/UndoManager'; import { ViewBoxBaseComponent } from '../DocComponent'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import './LabelBox.scss'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; import { RichTextMenu } from './formattedText/RichTextMenu'; @observer export class LabelBox extends ViewBoxBaseComponent() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(LabelBox, fieldKey); } private dropDisposer?: DragManager.DragDropDisposer; private _timeout: NodeJS.Timeout | undefined; private _divRef: HTMLDivElement | null = null; private _disposers: { [key: string]: IReactionDisposer } = {}; private _liveTextUndo: UndoManager.Batch | undefined; // captured undo batch when typing a new text note into a collection constructor(props: FieldViewProps) { super(props); makeObservable(this); } protected createDropTarget = (ele: HTMLDivElement) => { this.dropDisposer?.(); if (ele) { this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document); } }; componentDidMount() { this._props.setContentViewBox?.(this); this._disposers.active = reaction( () => this.Title, () => document.activeElement !== this._divRef && this._forceRerender++ ); } componentWillUnMount() { this._timeout && clearTimeout(this._timeout); this.setText(this._divRef?.innerText ?? ''); Object.values(this._disposers).forEach(disposer => disposer()); } @observable _forceRerender = 0; @computed get Title() { return Field.toString(this.dataDoc[this.fieldKey]); } // prettier-ignore @computed get backgroundColor() { return this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string; } // prettier-ignore @computed get boxShadow() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BoxShadow) as string; } // prettier-ignore setText = undoable((text: string) => { this.dataDoc[this.fieldKey] = text; }, 'set label text'); drop = (/* e: Event, de: DragManager.DropEvent */) => { return false; }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { if (!pinProps) return this.Document; const anchor = Docs.Create.ConfigDocument({ title: StrCast(this.Document.title), annotationOn: this.Document }); if (anchor) { if (!addAsAnnotation) anchor.backgroundColor = 'transparent'; // addAsAnnotation && this.addDocument(anchor); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}) } }, this.Document); return anchor; } return anchor; }; fitTextToBox = ( r: HTMLElement | null | undefined ): { minFontSize: number; maxFontSize: number; multiLine: boolean; alignHoriz: boolean; alignVert: boolean; detectMultiLine: boolean; } => { this._timeout && clearTimeout(this._timeout); const textfitParams = { minFontSize: NumCast(this.layoutDoc._label_minFontSize, 1), maxFontSize: NumCast(this.layoutDoc._label_maxFontSize, 100), multiLine: r?.textContent?.includes('\n') ? true : false, // hack because tetFit doesn't support align 'right', but we need mobx to invalidate, so treat null as false and set to right inline alignHoriz: StrCast(this.layoutDoc[this.fieldKey + '_align']) === 'center' ? true : StrCast(this.layoutDoc[this.fieldKey + '_align']) === 'right' ? (null as unknown as boolean) : false, alignVert: true, detectMultiLine: false, }; if (r) { if (!r.offsetHeight || !r.offsetWidth) { r.style.opacity = '0'; this._timeout = setTimeout(() => this.fitTextToBox(r)); return textfitParams; } r.style.opacity = '1'; r.style.whiteSpace = ''; // textfit sets to nowrap if not multiline, but doesn't reeset if it becomes multiline r.style.textAlign = StrCast(this.layoutDoc[this.fieldKey + '_align']); // textfit doesn't reset textAlign if it has been set to center, so we just set it to what we want r.firstChild instanceof HTMLElement && (r.firstChild.style.textAlign = StrCast(this.layoutDoc[this.fieldKey + '_align'])); textfit(r, textfitParams); } return textfitParams; }; resetCursor = (cranchor?: number) => { if (this._divRef && (cranchor || this._divRef === document.activeElement)) { const range = document.createRange(); const anchor = cranchor ?? this._divRef.childNodes.length; const container = cranchor === undefined ? this._divRef : (this._divRef.firstChild?.firstChild ?? this._divRef); range.setStart(container, anchor); range.setEnd(container, anchor); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); } }; beforeInput = action((event: InputEvent) => { const spanChild = this._divRef?.firstChild?.firstChild; if (spanChild?.nodeName === '#text' && ['insertLineBreak', 'insertParagraph'].includes(event.inputType)) { event.preventDefault(); event.stopPropagation(); const selection = document.getSelection(); if (selection && document.activeElement === event.target) { const text = spanChild.textContent ?? ''; const cranchor = selection.anchorNode === this._divRef ? (selection.anchorOffset ? text.length : 0) : selection.anchorOffset; const addReturnHack = text.length <= cranchor && text[text.length - 1] !== '\n' ? '\n\n' : '\n'; // not sure why, but need to add a second carriage return if typing enter at the end of the text const splitText = text.substring(0, cranchor) + addReturnHack + text.substring(cranchor); spanChild.textContent = splitText; this.resetCursor(cranchor + addReturnHack.length); } // const span = document.createElement('span'); // span.innerHTML = '​'; // this._divRef!.append(span); } }); // .labelBox-mainButton > div > span:nth-child(2) { /** * When an IconButton is clicked, it will receive focus. However, we don't want that since we want or need that since we really want * to maintain focus in the label's editing div (and cursor position). so this relies on IconButton's having a tabindex set to -1 so that * we can march up the tree from the 'relatedTarget' to determine if the loss of focus was caused by a fonticonbox. If it is, we then * restore focus * @param e focusout event on the editing div */ keepFocus = (e: FocusEvent) => { if (e.relatedTarget instanceof HTMLElement && e.relatedTarget.tabIndex === -1) { for (let ele: HTMLElement | null = e.relatedTarget; ele; ele = (ele as HTMLElement)?.parentElement) { if ((ele as HTMLElement)?.className === 'fonticonbox') { setTimeout(() => this._divRef?.focus()); break; } } } }; setRef = (r: HTMLDivElement | null) => { this._divRef?.removeEventListener('beforeinput', this.beforeInput); this._divRef = r; if (this._divRef) { this._divRef.addEventListener('beforeinput', this.beforeInput); if (DocumentView.SelectOnLoad === this.Document) { DocumentView.SetSelectOnLoad(undefined); this._liveTextUndo = FormattedTextBox.LiveTextUndo; FormattedTextBox.LiveTextUndo = undefined; this._divRef.focus(); } this.fitTextToBox(this._divRef); if (this.Title) { this.resetCursor(); } } else this._timeout && clearTimeout(this._timeout); }; render() { TraceMobx(); const boxParams = this.fitTextToBox(undefined); // this causes mobx to trigger re-render when data changes const [xmargin, ymargin] = [NumCast(this.layoutDoc._xMargin), NumCast(this.layoutDoc._uMargin)]; return (
{ e.stopPropagation(); }} onKeyUp={action(e => { e.stopPropagation(); const text = this._divRef?.firstChild; if (text && (text as HTMLElement)?.nodeType === 3) { this._divRef?.removeChild(text); this._divRef?.firstChild?.appendChild(text); this.resetCursor(); } this.fitTextToBox(this._divRef); })} onFocus={() => { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, this.dataDoc); this._divRef?.removeEventListener('focusout', this.keepFocus); this._divRef?.addEventListener('focusout', this.keepFocus); }} onBlur={e => { this._divRef?.removeEventListener('focusout', this.keepFocus); this.setText(this._divRef?.innerText ?? ''); if (!FormattedTextBox.tryKeepingFocus(e.relatedTarget, () => this._divRef?.focus())) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); this._liveTextUndo?.end(); } }} dangerouslySetInnerHTML={{ __html: `${this.Title?.startsWith('#') ? '' : (this.Title ?? '')}`, }} contentEditable={this._props.onClickScript?.() ? undefined : true} ref={this.setRef} />
); } } Docs.Prototypes.TemplateMap.set(DocumentType.LABEL, { layout: { view: LabelBox, dataField: 'title' }, options: { acl: '', _layout_nativeDimEditable: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true, title_align: 'center', title_transform: 'uppercase' }, }); Docs.Prototypes.TemplateMap.set(DocumentType.BUTTON, { layout: { view: LabelBox, dataField: 'title' }, options: { acl: '', _layout_nativeDimEditable: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true, title_align: 'center', title_transform: 'uppercase' }, });