import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import * as WebRequest from 'web-request'; import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../fields/Doc"; import { documentSchema } from "../../../fields/documentSchemas"; import { Id } from "../../../fields/FieldSymbols"; import { HtmlField } from "../../../fields/HtmlField"; import { InkTool } from "../../../fields/InkField"; import { List } from "../../../fields/List"; import { listSpec, makeInterface } from "../../../fields/Schema"; import { ComputedField } from "../../../fields/ScriptField"; import { Cast, NumCast, StrCast } from "../../../fields/Types"; import { WebField } from "../../../fields/URLField"; import { TraceMobx } from "../../../fields/util"; import { emptyFunction, getWordAtPoint, OmitKeys, returnFalse, returnOne, setupMoveUpEvents, smoothScroll, Utils } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; import { KeyCodes } from "../../util/KeyCodes"; import { Scripting } from "../../util/Scripting"; import { SnappingManager } from "../../util/SnappingManager"; import { undoBatch } from "../../util/UndoManager"; import { MarqueeOptionsMenu } from "../collections/collectionFreeForm"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from "../DocComponent"; import { DocumentDecorations } from "../DocumentDecorations"; import { Colors } from "../global/globalEnums"; import { LightboxView } from "../LightboxView"; import { MarqueeAnnotator } from "../MarqueeAnnotator"; import { AnchorMenu } from "../pdf/AnchorMenu"; import { Annotation } from "../pdf/Annotation"; import { SidebarAnnos } from "../SidebarAnnos"; import { StyleProp } from "../StyleProvider"; import { DocumentViewProps } from "./DocumentView"; import { FieldView, FieldViewProps } from './FieldView'; import { LinkDocPreview } from "./LinkDocPreview"; import "./WebBox.scss"; import React = require("react"); const _global = (window /* browser */ || global /* node */) as any; const htmlToText = require("html-to-text"); type WebDocument = makeInterface<[typeof documentSchema]>; const WebDocument = makeInterface(documentSchema); @observer export class WebBox extends ViewBoxAnnotatableComponent(WebDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(WebBox, fieldKey); } public static openSidebarWidth = 250; private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean, hide: boolean) => void); private _mainCont: React.RefObject = React.createRef(); private _outerRef: React.RefObject = React.createRef(); private _disposers: { [name: string]: IReactionDisposer } = {}; private _annotationLayer: React.RefObject = React.createRef(); private _keyInput = React.createRef(); private _initialScroll: Opt; private _sidebarRef = React.createRef(); private _searchRef = React.createRef(); private _searchString = ""; @observable private _searching: boolean = false; @observable _showSidebar = false; @observable private _scrollTimer: any; @observable private _overlayAnnoInfo: Opt; @observable private _marqueeing: number[] | undefined; @observable private _isAnnotating = false; @observable private _iframeClick: HTMLIFrameElement | undefined = undefined; @observable private _iframe: HTMLIFrameElement | null = null; @observable private _savedAnnotations = new ObservableMap(); @observable private _scrollHeight = NumCast(this.layoutDoc.scrollHeight, 1500); @computed get _url() { return this.webField?.toString() || ""; } @computed get _urlHash() { return this._url ? WebBox.urlHash(this._url) + "" : ""; } @computed get scrollHeight() { return this._scrollHeight; } @computed get allAnnotations() { return DocListCast(this.dataDoc[this.annotationKey]); } @computed get inlineTextAnnotations() { return this.allAnnotations.filter(a => a.textInlineAnnotations); } @computed get webField() { return Cast(this.dataDoc[this.props.fieldKey], WebField)?.url; } constructor(props: any) { super(props); Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(this.dataDoc) || 850); Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(this.dataDoc) || this.Document[HeightSym]() / this.Document[WidthSym]() * 850); } @action search = (searchString: string, bwd?: boolean, clear: boolean = false) => { if (!this._searching && !clear) { this._searching = true; setTimeout(() => { this._searchRef.current?.focus(); this._searchRef.current?.select(); this._searchRef.current?.setRangeText(searchString); }); } if (clear) { this._iframe?.contentWindow?.getSelection()?.empty(); } if (searchString) { (this._iframe?.contentWindow as any)?.find(searchString, false, bwd, true); } return true; } async componentDidMount() { this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link. runInAction(() => { this._annotationKeySuffix = () => this._urlHash + "-annotations"; // bcz: need to make sure that doc.data-annotations points to the currently active web page's annotations (this could/should be when the doc is created) this.dataDoc[this.fieldKey + "-annotations"] = ComputedField.MakeFunction(`copyField(this["${this.fieldKey}-"+urlHash(this["${this.fieldKey}"]?.url?.toString())+"-annotations"`); this.dataDoc[this.fieldKey + "-sidebar"] = ComputedField.MakeFunction(`copyField(this["${this.fieldKey}-"+urlHash(this["${this.fieldKey}"]?.url?.toString())+"-sidebar"`); }); this._disposers.autoHeight = reaction(() => this.layoutDoc._autoHeight, autoHeight => { if (autoHeight) { this.layoutDoc._nativeHeight = NumCast(this.props.Document[this.props.fieldKey + "-nativeHeight"]); this.props.setHeight(NumCast(this.props.Document[this.props.fieldKey + "-nativeHeight"]) * (this.props.scaling?.() || 1)); } }); if (this.webField?.href.indexOf("youtube") !== -1) { const youtubeaspect = 400 / 315; const nativeWidth = Doc.NativeWidth(this.layoutDoc); const nativeHeight = Doc.NativeHeight(this.layoutDoc); if (this.webField) { if (!nativeWidth || !nativeHeight || Math.abs(nativeWidth / nativeHeight - youtubeaspect) > 0.05) { if (!nativeWidth) Doc.SetNativeWidth(this.layoutDoc, 600); Doc.SetNativeHeight(this.layoutDoc, (nativeWidth || 600) / youtubeaspect); this.layoutDoc._height = this.layoutDoc[WidthSym]() / youtubeaspect; } } // else it's an HTMLfield } else if (this.webField && !this.dataDoc.text) { const result = await WebRequest.get(Utils.CorsProxy(this.webField.href)); if (result) { this.dataDoc.text = htmlToText.fromString(result.content); } } var quickScroll = true; this._disposers.scrollReaction = reaction(() => NumCast(this.layoutDoc._scrollTop), (scrollTop) => { if (quickScroll) this._initialScroll = scrollTop; else { const viewTrans = StrCast(this.Document._viewTransition); const durationMiliStr = viewTrans.match(/([0-9]*)ms/); const durationSecStr = viewTrans.match(/([0-9.]*)s/); const duration = durationMiliStr ? Number(durationMiliStr[1]) : durationSecStr ? Number(durationSecStr[1]) * 1000 : 0; this.goTo(scrollTop, duration); } }, { fireImmediately: true } ); quickScroll = false; } componentWillUnmount() { Object.values(this._disposers).forEach(disposer => disposer?.()); this._iframe?.removeEventListener('wheel', this.iframeWheel, true); this._iframe?.contentDocument?.removeEventListener("pointerup", this.iframeUp); } @action createTextAnnotation = (sel: Selection, selRange: Range) => { if (this._mainCont.current) { const clientRects = selRange.getClientRects(); for (let i = 0; i < clientRects.length; i++) { const rect = clientRects.item(i); if (rect && rect.width !== this._mainCont.current.clientWidth) { const annoBox = document.createElement("div"); annoBox.className = "marqueeAnnotator-annotationBox"; // transforms the positions from screen onto the pdf div annoBox.style.top = (rect.top + this._mainCont.current.scrollTop).toString(); annoBox.style.left = (rect.left).toString(); annoBox.style.width = (rect.width).toString(); annoBox.style.height = (rect.height).toString(); this._annotationLayer.current && MarqueeAnnotator.previewNewAnnotation(this._savedAnnotations, this._annotationLayer.current, annoBox, 1); } } } //this._selectionText = selRange.cloneContents().textContent || ""; // clear selection if (sel.empty) sel.empty();// Chrome else if (sel.removeAllRanges) sel.removeAllRanges(); // Firefox } menuControls = () => this.urlEditor; // controls to be added to the top bar when a document of this type is selected scrollFocus = (doc: Doc, smooth: boolean) => { if (StrCast(doc.webUrl) !== this._url) this.submitURL(StrCast(doc.webUrl), !smooth); if (DocListCast(this.props.Document[this.fieldKey + "-sidebar"]).includes(doc) && !this.SidebarShown) { this.toggleSidebar(!smooth); } if (this._sidebarRef?.current?.makeDocUnfiltered(doc)) return 1; if (doc !== this.rootDoc && this._outerRef.current) { const windowHeight = this.props.PanelHeight() / (this.props.scaling?.() || 1); const scrollTo = Utils.scrollIntoView(NumCast(doc.y), doc[HeightSym](), NumCast(this.layoutDoc._scrollTop), windowHeight, windowHeight * .1); if (scrollTo !== undefined) { const focusSpeed = smooth ? 500 : 0; this._initialScroll !== undefined && (this._initialScroll = scrollTo); this.goTo(scrollTo, focusSpeed); return focusSpeed; } } this._initialScroll = NumCast(this.layoutDoc._scrollTop); return 0; } getAnchor = () => { const anchor = AnchorMenu.Instance?.GetAnchor(this._savedAnnotations) ?? Docs.Create.WebanchorDocument(this._url, { title: StrCast(this.rootDoc.title + " " + this.layoutDoc._scrollTop), y: NumCast(this.layoutDoc._scrollTop), unrendered: true }); this.addDocumentWrapper(anchor); return anchor; } @action iframeUp = (e: PointerEvent) => { this.props.docViewPath().lastElement()?.docView?.cleanupPointerEvents(); // pointerup events aren't generated on containing document view, so we have to invoke it here. if (this._iframe?.contentWindow && this._iframe.contentDocument && !this._iframe.contentWindow.getSelection()?.isCollapsed) { const mainContBounds = Utils.GetScreenTransform(this._mainCont.current!); const scale = (this.props.scaling?.() || 1) * mainContBounds.scale; const sel = this._iframe.contentWindow.getSelection(); if (sel) { this.createTextAnnotation(sel, sel.getRangeAt(0)); AnchorMenu.Instance.jumpTo(e.clientX * scale + mainContBounds.translateX, e.clientY * scale + mainContBounds.translateY - NumCast(this.layoutDoc._scrollTop) * scale); } } } @action iframeDown = (e: PointerEvent) => { const mainContBounds = Utils.GetScreenTransform(this._mainCont.current!); const scale = (this.props.scaling?.() || 1) * mainContBounds.scale; const word = getWordAtPoint(e.target, e.clientX, e.clientY); this._setPreviewCursor?.(e.clientX, e.clientY, false, true); MarqueeAnnotator.clearAnnotations(this._savedAnnotations); this._marqueeing = [e.clientX * scale + mainContBounds.translateX, e.clientY * scale + mainContBounds.translateY - NumCast(this.layoutDoc._scrollTop) * scale]; if (word) { setTimeout(action(() => this._marqueeing = undefined), 100); // bcz: hack .. anchor menu is setup within MarqueeAnnotator so we need to at least create the marqueeAnnotator even though we aren't using it. } else { this._iframeClick = this._iframe ?? undefined; this._isAnnotating = true; this.props.select(false); e.stopPropagation(); e.preventDefault(); } // bcz: hack - iframe grabs all events which messes up how we handle contextMenus. So this super naively simulates the event stack to get the specific menu items and the doc view menu items. if (e.button === 2 || (e.button === 0 && e.altKey)) { e.preventDefault(); e.stopPropagation(); ContextMenu.Instance.closeMenu(); ContextMenu.Instance.setIgnoreEvents(true); } } getScrollHeight = () => this._scrollHeight; isFirefox = () => { return "InstallTrigger" in window; // navigator.userAgent.indexOf("Chrome") !== -1; } iframeClick = () => this._iframeClick; iframeScaling = () => 1 / this.props.ScreenToLocalTransform().Scale; @action iframeLoaded = (e: any) => { const iframe = this._iframe; this._iframe?.contentDocument?.addEventListener("pointerup", this.iframeUp); if (iframe?.contentDocument) { iframe?.contentDocument.addEventListener("pointerdown", this.iframeDown); this._scrollHeight = Math.max(this.scrollHeight, iframe?.contentDocument.body.scrollHeight); setTimeout(action(() => this._scrollHeight = Math.max(this.scrollHeight, iframe?.contentDocument?.body.scrollHeight || 0)), 5000); const initialScroll = this._initialScroll; if (initialScroll !== undefined && this._outerRef.current) { // bcz: not sure why this happens, but if the webpage isn't ready yet, it's scroll height seems to be limited. So we need to wait tp set scroll location to what we want. setTimeout(() => this._outerRef.current!.scrollTop = initialScroll); this._initialScroll = undefined; } iframe.setAttribute("enable-annotation", "true"); iframe.contentDocument.addEventListener("click", undoBatch(action(e => { let href = ""; for (let ele = e.target; ele; ele = ele.parentElement) { href = (typeof (ele.href) === "string" ? ele.href : ele.href?.baseVal) || ele.parentElement?.href || href; } if (href && this.webField?.origin) { this.submitURL(href.replace(Utils.prepend(""), this.webField?.origin)); if (this._outerRef.current) { this._outerRef.current.scrollTop = NumCast(this.layoutDoc._scrollTop); this._outerRef.current.scrollLeft = 0; } } }))); iframe.contentDocument.addEventListener('wheel', this.iframeWheel, false); //iframe.contentDocument.addEventListener('scroll', () => !this.active() && this._iframe && (this._iframe.scrollTop = NumCast(this.layoutDoc._scrollTop), false)); } } @action iframeWheel = (e: any) => { if (!this._scrollTimer) { this._scrollTimer = setTimeout(action(() => this._scrollTimer = undefined), 250); // this turns events off on the iframe which allows scrolling to change direction smoothly } } @action setDashScrollTop = (scrollTop: number, timeout: number = 250) => { const iframeHeight = Math.max(1000, this._scrollHeight - this.panelHeight()); timeout = scrollTop > iframeHeight ? 0 : timeout; this._scrollTimer && clearTimeout(this._scrollTimer); this._scrollTimer = setTimeout(action(() => { this._scrollTimer = undefined; if (!LinkDocPreview.LinkInfo && this._outerRef.current && (!LightboxView.LightboxDoc || LightboxView.IsLightboxDocView(this.props.docViewPath()))) { this.layoutDoc._scrollTop = this._outerRef.current.scrollTop = scrollTop > iframeHeight ? iframeHeight : scrollTop; } }), timeout); } goTo = (scrollTop: number, duration: number) => { if (this._outerRef.current) { const iframeHeight = Math.max(1000, this._scrollHeight - this.panelHeight()); scrollTop = scrollTop > iframeHeight + 50 ? iframeHeight : scrollTop; if (duration) { smoothScroll(duration, [this._outerRef.current], scrollTop); this.setDashScrollTop(scrollTop, duration); } else { this.setDashScrollTop(scrollTop); } } } @action forward = () => { const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string"), []); const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), []); if (future.length) { this.dataDoc[this.fieldKey + "-history"] = new List([...history, this._url]); this.dataDoc[this.fieldKey] = new WebField(new URL(future.pop()!)); return true; } return false; } @action back = () => { const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string")); const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), []); if (history.length) { if (future === undefined) this.dataDoc[this.fieldKey + "-future"] = new List([this._url]); else this.dataDoc[this.fieldKey + "-future"] = new List([...future, this._url]); this.dataDoc[this.fieldKey] = new WebField(new URL(history.pop()!)); console.log(this._urlHash); return true; } return false; } static urlHash = (s: string) => { return Math.abs(s.split('').reduce((a: any, b: any) => { a = ((a << 5) - a) + b.charCodeAt(0); return a & a; }, 0)); } @action submitURL = (newUrl?: string, preview?: boolean) => { if (!newUrl) return; if (!newUrl.startsWith("http")) newUrl = "http://" + newUrl; try { const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string")); const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string")); const url = this.webField?.toString(); if (url && !preview) { this.dataDoc[this.fieldKey + "-history"] = new List([...(history || []), url]); this.layoutDoc._scrollTop = 0; future && (future.length = 0); } if (!preview) this.dataDoc[this.fieldKey] = new WebField(new URL(newUrl)); } catch (e) { console.log("WebBox URL error:" + this._url); } return true; } onWebUrlDrop = (e: React.DragEvent) => { const { dataTransfer } = e; const html = dataTransfer.getData("text/html"); const uri = dataTransfer.getData("text/uri-list"); const url = uri || html || this._url || ""; const newurl = url.startsWith(window.location.origin) ? url.replace(window.location.origin, this._url?.match(/http[s]?:\/\/[^\/]*/)?.[0] || "") : url; this.submitURL(newurl); e.stopPropagation(); } onWebUrlValueKeyDown = (e: React.KeyboardEvent) => { e.key === "Enter" && this.submitURL(this._keyInput.current!.value); e.stopPropagation(); } @computed get urlEditor() { return (
e.preventDefault()} > e.preventDefault()} onKeyDown={this.onWebUrlValueKeyDown} onClick={(e) => { this._keyInput.current!.select(); e.stopPropagation(); }} ref={this._keyInput} />
); } specificContextMenu = (e: React.MouseEvent | PointerEvent): void => { const cm = ContextMenu.Instance; const funcs: ContextMenuProps[] = []; if (!cm.findByDescription("Options...")) { !Doc.UserDoc().noviceMode && funcs.push({ description: (this.layoutDoc.useCors ? "Don't Use" : "Use") + " Cors", event: () => this.layoutDoc.useCors = !this.layoutDoc.useCors, icon: "snowflake" }); funcs.push({ description: (!this.layoutDoc.forceReflow ? "Force" : "Prevent") + " Reflow", event: () => { const nw = !this.layoutDoc.forceReflow ? undefined : Doc.NativeWidth(this.layoutDoc) - this.sidebarWidth() / (this.props.scaling?.() || 1); this.layoutDoc.forceReflow = !nw; if (nw) { Doc.SetInPlace(this.layoutDoc, this.fieldKey + "-nativeWidth", nw, true); } }, icon: "snowflake" }); cm.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); } } @action onMarqueeDown = (e: React.PointerEvent) => { if (!e.altKey && e.button === 0 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool)) { setupMoveUpEvents(this, e, action(e => { MarqueeAnnotator.clearAnnotations(this._savedAnnotations); this._marqueeing = [e.clientX, e.clientY]; return true; }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false); } } @action finishMarquee = (x?: number, y?: number, e?: PointerEvent) => { this._marqueeing = undefined; this._isAnnotating = false; this._iframeClick = undefined; if (x !== undefined && y !== undefined) { this._setPreviewCursor?.(x, y, false, false); ContextMenu.Instance.closeMenu(); ContextMenu.Instance.setIgnoreEvents(false); if (e?.button === 2 || e?.altKey) { this.specificContextMenu(undefined as any); this.props.docViewPath().lastElement().docView?.onContextMenu(undefined, x, y); } } } @computed get urlContent() { const field = this.dataDoc[this.props.fieldKey]; let view; if (field instanceof HtmlField) { view = ; } else if (field instanceof WebField) { const url = this.layoutDoc.useCors ? Utils.CorsProxy(this._url) : this._url; view =