import { action, runInAction } from 'mobx'; import { Doc, DocListCast, FieldType, FieldResult, Opt } from '../../fields/Doc'; import { ScriptField } from '../../fields/ScriptField'; import { BoolCast, Cast, DocCast, NumCast, ScriptCast, StrCast } from '../../fields/Types'; import { DocumentType } from '../documents/DocumentTypes'; import { OpenWhere } from '../views/nodes/DocumentView'; import { FocusViewOptions } from '../views/nodes/FieldView'; import { PresBox } from '../views/nodes/trails'; import { DocumentManager } from './DocumentManager'; import { LinkManager } from './LinkManager'; import { ScriptingGlobals } from './ScriptingGlobals'; import { SelectionManager } from './SelectionManager'; import { SnappingManager } from './SnappingManager'; import { UndoManager } from './UndoManager'; /* * link doc: * - link_anchor_1: doc * - link_anchor_2: doc * * group doc: * - type: string representing the group type/name/category * - metadata: doc representing the metadata kvps * * metadata doc: * - user defined kvps */ export class LinkFollower { // follows a link - if the target is on screen, it highlights/pans to it. // if the target isn't onscreen, then it will open up the target in the lightbox, or in place // depending on the followLinkLocation property of the source (or the link itself as a fallback); public static FollowLink = (linkDoc: Opt, sourceDoc: Doc, altKey: boolean) => { const batch = UndoManager.StartBatch('Follow Link'); runInAction(() => SnappingManager.SetIsLinkFollowing(true)); // turn off decoration bounds while following links since animations may occur, and DocDecorations is based on screenToLocal which is not always an observable value return LinkFollower.traverseLink( linkDoc, sourceDoc, action(() => { batch.end(); Doc.AddUnHighlightWatcher(() => SnappingManager.SetIsLinkFollowing(false)); }), altKey ? true : undefined ); }; public static traverseLink(link: Opt, sourceDoc: Doc, finished?: () => void, traverseBacklink?: boolean) { const getView = (doc: Doc) => DocumentManager.Instance.getFirstDocumentView(DocCast(doc.layout_unrendered ? doc.annotationOn : doc)); const isAnchor = (source: Doc, anchor: Doc) => Doc.AreProtosEqual(anchor, source) || Doc.AreProtosEqual(anchor.annotationOn as Doc, source); const linkDocs = link ? [link] : LinkManager.Links(sourceDoc); const fwdLinks = linkDocs.filter(l => isAnchor(sourceDoc, l.link_anchor_1 as Doc)); // link docs where 'sourceDoc' is link_anchor_1 const backLinks = linkDocs.filter(l => isAnchor(sourceDoc, l.link_anchor_2 as Doc)); // link docs where 'sourceDoc' is link_anchor_2 const fwdLinkWithoutTargetView = fwdLinks.find(l => !getView(DocCast(l.link_anchor_2))); const backLinkWithoutTargetView = backLinks.find(l => !getView(DocCast(l.link_anchor_1))); const linkWithoutTargetDoc = traverseBacklink === undefined ? fwdLinkWithoutTargetView ?? backLinkWithoutTargetView : traverseBacklink ? backLinkWithoutTargetView : fwdLinkWithoutTargetView; const linkDocList = linkWithoutTargetDoc && !sourceDoc.followAllLinks ? [linkWithoutTargetDoc] : traverseBacklink === undefined ? fwdLinks.concat(backLinks) : traverseBacklink ? backLinks : fwdLinks; const followLinks = sourceDoc.followLinkToggle || sourceDoc.followAllLinks ? linkDocList : linkDocList.slice(0, 1); let count = 0; const allFinished = () => ++count === followLinks.length && finished?.(); if (!followLinks.length) { finished?.(); return false; } followLinks.forEach(async linkDoc => { const target = ( sourceDoc === linkDoc.link_anchor_1 ? linkDoc.link_anchor_2 : sourceDoc === linkDoc.link_anchor_2 ? linkDoc.link_anchor_1 : Doc.AreProtosEqual(sourceDoc, linkDoc.link_anchor_1 as Doc) || Doc.AreProtosEqual((linkDoc.link_anchor_1 as Doc).annotationOn as Doc, sourceDoc) ? linkDoc.link_anchor_2 : linkDoc.link_anchor_1 ) as Doc; const srcAnchor: Doc = LinkManager.getOppositeAnchor(linkDoc, target) ?? sourceDoc; if (target) { const doFollow = (canToggle?: boolean) => { const toggleTarget = canToggle && BoolCast(sourceDoc.followLinkToggle); const options: FocusViewOptions = { playAudio: BoolCast(srcAnchor.followLinkAudio), playMedia: BoolCast(srcAnchor.followLinkVideo), toggleTarget, noSelect: true, willPan: true, willZoomCentered: BoolCast(srcAnchor.followLinkZoom, false), zoomTime: NumCast(srcAnchor.followLinkTransitionTime, 500), zoomScale: Cast(srcAnchor.followLinkZoomScale, 'number', null), easeFunc: StrCast(srcAnchor.followLinkEase, 'ease') as any, openLocation: StrCast(srcAnchor.followLinkLocation, OpenWhere.lightbox) as OpenWhere, effect: srcAnchor, zoomTextSelections: BoolCast(srcAnchor.followLinkZoomText), }; if (target.type === DocumentType.PRES) { const containerDocContext = DocumentManager.GetContextPath(sourceDoc, true); // gather all views that affect layout of sourceDoc so we can revert them after playing the rail SelectionManager.DeselectAll(); if (!DocumentManager.Instance.AddViewRenderedCb(target, dv => containerDocContext.length && (dv.ComponentView as PresBox).PlayTrail(containerDocContext))) { PresBox.OpenPresMinimized(target, [0, 0]); } finished?.(); } else { DocumentManager.Instance.showDocument(target, options, allFinished); } }; let movedTarget = false; if (srcAnchor.followLinkLocation === OpenWhere.inParent) { const sourceDocParent = DocCast(sourceDoc.embedContainer); if (target.embedContainer instanceof Doc && target.embedContainer !== sourceDocParent) { Doc.RemoveDocFromList(target.embedContainer, Doc.LayoutFieldKey(target.embedContainer), target); movedTarget = true; } if (!DocListCast(sourceDocParent[Doc.LayoutFieldKey(sourceDocParent)]).includes(target)) { Doc.AddDocToList(sourceDocParent, Doc.LayoutFieldKey(sourceDocParent), target); movedTarget = true; } Doc.SetContainer(target, sourceDocParent); } const moveTo = [NumCast(sourceDoc.x) + NumCast(sourceDoc.followLinkXoffset), NumCast(sourceDoc.y) + NumCast(sourceDoc.followLinkYoffset)]; if (srcAnchor.followLinkXoffset !== undefined && moveTo[0] !== target.x) { // eslint-disable-next-line prefer-destructuring target.x = moveTo[0]; movedTarget = true; } if (srcAnchor.followLinkYoffset !== undefined && moveTo[1] !== target.y) { // eslint-disable-next-line prefer-destructuring target.y = moveTo[1]; movedTarget = true; } if (movedTarget) setTimeout(doFollow); else doFollow(true); } else { allFinished(); } }); return true; } } // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function followLink(doc: Doc, altKey: boolean) { SelectionManager.DeselectAll(); return LinkFollower.FollowLink(undefined, doc, altKey) ? undefined : { select: true }; }); export function FollowLinkScript() { return ScriptField.MakeScript('return followLink(this,altKey)', { altKey: 'boolean' }); } export function IsFollowLinkScript(field: FieldResult) { return ScriptCast(field)?.script.originalScript.includes('return followLink('); }