/* eslint-disable @typescript-eslint/no-unused-vars */ import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import Xarrow from 'react-xarrows'; import { DashColor, lightOrDark, returnFalse } from '../../../ClientUtils'; import { FieldResult } from '../../../fields/Doc'; import { DocCss, DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { DocCast, NumCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { SnappingManager } from '../../util/SnappingManager'; import { ViewBoxBaseComponent } from '../DocComponent'; import { EditableView } from '../EditableView'; import { StyleProp } from '../StyleProp'; import { ComparisonBox } from './ComparisonBox'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { RichTextMenu } from './formattedText/RichTextMenu'; import './LinkBox.scss'; @observer export class LinkBox extends ViewBoxBaseComponent() { public static LayoutString(fieldKey: string = 'link') { return FieldView.LayoutString(LinkBox, fieldKey); } _hackToSeeIfDeleted: NodeJS.Timeout | undefined; _disposers: { [name: string]: IReactionDisposer } = {}; _divRef: HTMLDivElement | null = null; @observable _forceAnimate: number = 0; // forces xArrow to animate when a transition animation is detected on something that affects an anchor @observable _hide = false; // don't render if anchor is not visible since that breaks xAnchor constructor(props: FieldViewProps) { super(props); makeObservable(this); } @computed get anchor1() { return this.anchor(1); } // prettier-ignore @computed get anchor2() { return this.anchor(2); } // prettier-ignore anchor = (which: number) => { const anch = DocCast(this.dataDoc['link_anchor_' + which]); const anchor = anch?.layout_unrendered ? DocCast(anch.annotationOn) : anch; return DocumentView.getDocumentView(anchor, this.DocumentView?.().containerViewPath?.().lastElement()); }; componentWillUnmount() { this._hackToSeeIfDeleted && clearTimeout(this._hackToSeeIfDeleted); Object.keys(this._disposers).forEach(key => this._disposers[key]()); } componentDidMount() { this._props.setContentViewBox?.(this); this._disposers.deleting = reaction( () => !this.anchor1 && !this.anchor2 && this.DocumentView?.() && (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView!())), empty => { if (empty) { this._hackToSeeIfDeleted = setTimeout(() => { !this.anchor1 && !this.anchor2 && this._props.removeDocument?.(this.Document); }, 1000); } } ); this._disposers.dragging = reaction( () => SnappingManager.IsDragging, () => setTimeout( action(() => {// need to wait for drag manager to set 'hidden' flag on dragged DOM elements const a = this.anchor1; const b = this.anchor2; let a1 = a && document.getElementById(a.ViewGuid); let a2 = b && document.getElementById(b.ViewGuid); // test whether the anchors themselves are hidden,... if (!a1 || !a2 || a?.ContentDiv?.hidden || b?.ContentDiv?.hidden) this._hide = true; else { // .. or whether any of their DOM parents are hidden for (; a1 && !a1.hidden; a1 = a1.parentElement); for (; a2 && !a2.hidden; a2 = a2.parentElement); this._hide = !!(a1 || a2); } })) // prettier-ignore ); } /** * 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 (['listItem-container', 'fonticonbox'].includes((ele as HTMLElement)?.className ?? '')) { console.log('RESTORE :', document.activeElement, this._divRef); this._divRef?.focus(); break; } } } }; setRef = (r: HTMLDivElement | null) => (this._divRef = r); render() { TraceMobx(); if (this._hide) return null; const a = this.anchor1; const b = this.anchor2; this._forceAnimate; const docView = this._props.docViewPath().lastElement(); if (a && b) { // text selection bounds are not directly observable, so we have to // force an update when anything that could affect them changes (text edits causing reflow, scrolling) a.Document[a.LayoutFieldKey]; b.Document[b.LayoutFieldKey]; a.Document.layout_scrollTop; b.Document.layout_scrollTop; a.Document[DocCss]; b.Document[DocCss]; const axf = a.screenToViewTransform(); // these force re-render when a or b moves (so do NOT remove) const bxf = b.screenToViewTransform(); const scale = docView?.screenToViewTransform().Scale ?? 1; const at = a.getBounds?.transition; // these force re-render when a or b change size and at the end of an animated transition const bt = b.getBounds?.transition; // inquring getBounds() also causes text anchors to update whether or not they reflow (any size change triggers an invalidation) let foundParent = false; const getAnchor = (field: FieldResult): Element[] => { const docField = DocCast(field); const doc = docField?.layout_unrendered ? DocCast(docField.annotationOn, docField) : docField; if (!doc) return []; const ele = document.getElementById(DocumentView.UniquifyId(DocumentView.LightboxContains(this.DocumentView?.()), doc[Id])); if (ele?.className === 'linkBox-label') foundParent = true; if (ele?.getBoundingClientRect().width) return [ele]; const eles = Array.from(document.getElementsByClassName(doc[Id])).filter(el => el?.getBoundingClientRect().width); const annoOn = DocCast(doc.annotationOn); if (eles.length || !annoOn) return eles; const pareles = getAnchor(annoOn); foundParent = !!pareles.length; return pareles; }; // if there's an element in the DOM with a classname containing a link anchor's id (eg a hypertext ), // then that DOM element is a hyperlink source for the current anchor and we want to place our link box at it's top right // otherwise, we just use the computed nearest point on the document boundary to the target Document const targetAhyperlinks = getAnchor(this.dataDoc.link_anchor_1); const targetBhyperlinks = getAnchor(this.dataDoc.link_anchor_2); const container = this.DocumentView?.().containerViewPath?.().lastElement()?.ContentDiv; const aid = targetAhyperlinks?.find(alink => container?.contains(alink))?.id ?? targetAhyperlinks?.lastElement()?.id; const bid = targetBhyperlinks?.find(blink => container?.contains(blink))?.id ?? targetBhyperlinks?.lastElement()?.id; if (!aid || !bid) { setTimeout(action(() => (this._forceAnimate += 0.01))); return null; } if (foundParent) { setTimeout( action(() => (this._forceAnimate += 0.01)), 1 ); } if (at || bt) { setTimeout(action(() => (this._forceAnimate += 0.01))); // this forces an update during a transition animation } const highlight = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Highlighting) as { highlightStyle: string; highlightColor: string; highlightIndex: number; highlightStroke: boolean }; const highlightColor = highlight?.highlightIndex ? highlight?.highlightColor : undefined; const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string; const fontFamily = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontFamily) as string; const fontSize = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontSize) as number; const fontColor = (c => (c !== 'transparent' ? c : undefined))(StrCast(this.layoutDoc.link_fontColor)); const { stroke_markerScale: strokeMarkerScale, stroke_width: strokeRawWidth, stroke_startMarker: strokeStartMarker, stroke_endMarker: strokeEndMarker, stroke_dash: strokeDash } = this.Document; const strokeWidth = NumCast(strokeRawWidth, 1); const linkDesc = StrCast(this.dataDoc.link_description) || ' '; const labelText = linkDesc.substring(0, 50) + (linkDesc.length > 50 ? '...' : ''); return ( <> {!highlightColor ? null : ( )} e.stopPropagation()} onFocus={() => { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, this.dataDoc); this._divRef?.removeEventListener('focusout', this.keepFocus); this._divRef?.addEventListener('focusout', this.keepFocus); }} onBlur={() => { if (document.activeElement !== this._divRef && document.activeElement?.parentElement !== this._divRef) { this._divRef?.removeEventListener('focusout', this.keepFocus); RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); } }} style={{ borderRadius: '8px', transform: `scale(${1 / scale})`, pointerEvents: this._props.isDocumentActive?.() ? 'all' : undefined, fontSize, fontFamily /* , fontStyle: 'italic' */, color: fontColor || lightOrDark(DashColor(color).fade(0.5).toString()), paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, background: DashColor((!docView?.isSelected() && highlightColor) || color) .fade(0.5) .toString(), }}> linkDesc} SetValue={action(val => { this.Document.$link_description = val; return true; })} /> {/* (this.Document.$link_description = val))} fillWidth /> */} } passProps={{}} /> ); } setTimeout( action(() => { this._forceAnimate += 1; }), 2 ); return (
); } } Docs.Prototypes.TemplateMap.set(DocumentType.LINK, { layout: { view: LinkBox, dataField: 'link' }, options: { acl: '', childDontRegisterViews: true, layout_hideLinkAnchors: true, _height: 1, _width: 1, link: '', link_description: '', color: 'lightBlue', // lightblue is default color for linking dot and link documents text comment area _dropPropertiesToRemove: new List(['onClick']), }, });