import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tooltip } from '@material-ui/core'; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import { Dictionary } from "typescript-collections"; 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 { Cast, NumCast, StrCast } from "../../../fields/Types"; import { WebField } from "../../../fields/URLField"; import { TraceMobx } from "../../../fields/util"; import { emptyFunction, OmitKeys, returnOne, smoothScroll, Utils } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { DragManager } from "../../util/DragManager"; import { ImageUtils } from "../../util/Import & Export/ImageUtils"; import { undoBatch } from "../../util/UndoManager"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { DocumentDecorations } from "../DocumentDecorations"; import { MarqueeAnnotator } from "../MarqueeAnnotator"; import { Annotation } from "../pdf/Annotation"; import { DocAfterFocusFunc } from "./DocumentView"; import { FieldView, FieldViewProps } from './FieldView'; import "./WebBox.scss"; import React = require("react"); import { LinkDocPreview } from "./LinkDocPreview"; 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); } private _mainCont: React.RefObject = React.createRef(); private _disposers: { [name: string]: IReactionDisposer } = {}; private _longPressSecondsHack?: NodeJS.Timeout; private _outerRef = React.createRef(); private _annotationLayer: React.RefObject = React.createRef(); private _iframeIndicatorRef = React.createRef(); private _iframeDragRef = React.createRef(); private _keyInput = React.createRef(); private _ignoreScroll = false; private _initialScroll: Opt; @observable private _marqueeing: number[] | undefined; @observable private _url: string = "hello"; @observable private _pressX: number = 0; @observable private _pressY: number = 0; @observable private _iframe: HTMLIFrameElement | null = null; @observable private _savedAnnotations: Dictionary = new Dictionary(); get scrollHeight() { return this.webpage?.scrollHeight || 1000; } get webpage() { return this._iframe?.contentDocument?.children[0]; } constructor(props: any) { super(props); if (this.dataDoc[this.fieldKey] instanceof WebField) { Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(this.dataDoc) || 850); Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(this.dataDoc) || this.Document[HeightSym]() / this.Document[WidthSym]() * 850); } this._annotationKey = this._annotationKey + "-" + this.urlHash(this._url); } iframeLoaded = (e: any) => { const iframe = this._iframe; if (iframe?.contentDocument) { if (this._initialScroll !== undefined && this._outerRef.current && this.webpage) { this.webpage.scrollTop = this._initialScroll; this._outerRef.current.scrollTop = this._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.submitURL(href.replace(Utils.prepend(""), Cast(this.dataDoc[this.fieldKey], WebField, null)?.url.origin)); if (this.webpage) { this.webpage.scrollTop = NumCast(this.layoutDoc._scrollTop); this.webpage.scrollLeft = 0; } } }))); iframe.contentDocument.addEventListener('wheel', this.iframeWheel, false); } } @action onWheelScroll = (scrollTop: number) => { if (this.webpage && this._outerRef.current) { this.webpage.scrollLeft = 0; this._outerRef.current.scrollTop = scrollTop; this._outerRef.current.scrollLeft = 0; this._ignoreScroll = true; if (this.layoutDoc._scrollTop !== scrollTop) { this.layoutDoc._scrollTop = scrollTop; } this._ignoreScroll = false; } } iframeWheel = (e: any) => this.webpage && e.target?.children && this.onWheelScroll(this.webpage.scrollTop); onWheel = (e: React.WheelEvent) => { this._outerRef.current && this.onWheelScroll(this._outerRef.current.scrollTop); e.stopPropagation(); } getAnchor = () => this.rootDoc; scrollFocus = (doc: Doc, smooth: boolean) => { let focusSpeed: Opt; if (doc !== this.rootDoc && this.webpage && this._outerRef.current) { const scrollTo = Utils.scrollIntoView(NumCast(doc.y), doc[HeightSym](), NumCast(this.layoutDoc._scrollTop), this.props.PanelHeight() / (this.props.scaling?.() || 1)); if (scrollTo !== undefined) { this._initialScroll !== undefined && (this._initialScroll = scrollTo); this._ignoreScroll = true; this.goTo(scrollTo, focusSpeed = smooth ? 500 : 0); if (!LinkDocPreview.LinkInfo) { this.layoutDoc._scrollTop = scrollTo; } this._ignoreScroll = false; } } else { this._initialScroll = NumCast(doc.y); } return focusSpeed; } 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. const urlField = Cast(this.dataDoc[this.props.fieldKey], WebField); runInAction(() => this._url = urlField?.url.toString() || ""); this._disposers.selection = reaction(() => this.props.isSelected(), selected => { if (!selected) { this._savedAnnotations.values().forEach(v => v.forEach(a => a.remove())); this._savedAnnotations.clear(); } }, { fireImmediately: true }); document.addEventListener("pointerup", this.onLongPressUp); document.addEventListener("pointermove", this.onLongPressMove); const field = Cast(this.rootDoc[this.props.fieldKey], WebField); if (field?.url.href.indexOf("youtube") !== -1) { const youtubeaspect = 400 / 315; const nativeWidth = Doc.NativeWidth(this.layoutDoc); const nativeHeight = Doc.NativeHeight(this.layoutDoc); if (field) { 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 (field?.url && !this.dataDoc.text) { const result = await WebRequest.get(Utils.CorsProxy(field.url.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 if (!this._ignoreScroll) { 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; } goTo = (scrollTop: number, duration: number) => { if (this._outerRef.current && this.webpage) { if (duration) { smoothScroll(duration, this.webpage as any as HTMLElement, scrollTop); smoothScroll(duration, this._outerRef.current, scrollTop); } else { this.webpage.scrollTop = scrollTop; this._outerRef.current.scrollTop = scrollTop; } } } componentWillUnmount() { Object.values(this._disposers).forEach(disposer => disposer?.()); document.removeEventListener("pointerup", this.onLongPressUp); document.removeEventListener("pointermove", this.onLongPressMove); this._iframe?.removeEventListener('wheel', this.iframeWheel); } @action forward = () => { const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string"), null); const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), null); if (future.length) { history.push(this._url); this.dataDoc[this.fieldKey] = new WebField(new URL(this._url = future.pop()!)); this._annotationKey = this.fieldKey + "-annotations-" + this.urlHash(this._url); return true; } return false; } @action back = () => { const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string"), null); const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), null); if (history.length) { if (future === undefined) this.dataDoc[this.fieldKey + "-future"] = new List([this._url]); else future.push(this._url); this.dataDoc[this.fieldKey] = new WebField(new URL(this._url = history.pop()!)); this._annotationKey = this.fieldKey + "-annotations-" + this.urlHash(this._url); return true; } return false; } urlHash(s: string) { return s.split('').reduce((a: any, b: any) => { a = ((a << 5) - a) + b.charCodeAt(0); return a & a; }, 0); } @action submitURL = (newUrl: string) => { if (!newUrl.startsWith("http")) newUrl = "http://" + newUrl; try { const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string"), null); const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), null); const url = Cast(this.dataDoc[this.fieldKey], WebField, null)?.url.toString(); if (url) { if (history === undefined) { this.dataDoc[this.fieldKey + "-history"] = new List([url]); } else { history.push(url); } this.layoutDoc._scrollTop = 0; future && (future.length = 0); } this._url = newUrl; this._annotationKey = this.fieldKey + "-annotations-" + this.urlHash(this._url); this.dataDoc[this.fieldKey] = new WebField(new URL(newUrl)); } catch (e) { console.log("WebBox URL error:" + this._url); } return true; } menuControls = () => this.urlEditor; 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} />
); } editToggleBtn() { return {`${this.props.Document.isAnnotating ? "Exit" : "Enter"} annotation mode`}}>
this.layoutDoc.isAnnotating = !this.layoutDoc.isAnnotating)}>
; } _ignore = 0; onPreWheel = (e: React.WheelEvent) => this._ignore = e.timeStamp; onPrePointer = (e: React.PointerEvent) => this._ignore = e.timeStamp; onPostPointer = (e: React.PointerEvent) => this._ignore !== e.timeStamp && e.stopPropagation(); onPostWheel = (e: React.WheelEvent) => this._ignore !== e.timeStamp && e.stopPropagation(); onLongPressDown = (e: React.PointerEvent) => { this._pressX = e.clientX; this._pressY = e.clientY; // find the pressed element in the iframe (currently only works if its an img) let pressedElement: HTMLElement | undefined; let pressedBound: ClientRect | undefined; let selectedText: string = ""; let pressedImg: boolean = false; if (this._iframe) { const B = this._iframe.getBoundingClientRect(); const iframeDoc = this._iframe.contentDocument; if (B && iframeDoc) { // TODO: this only works when scale = 1 as it is currently only inteded for mobile upload const element = iframeDoc.elementFromPoint(this._pressX - B.left, this._pressY - B.top); if (element && element.nodeName === "IMG") { pressedBound = element.getBoundingClientRect(); pressedElement = element.cloneNode(true) as HTMLElement; pressedImg = true; } else { // check if there is selected text const text = iframeDoc.getSelection(); if (text && text.toString().length > 0) { selectedText = text.toString(); // get html of the selected text const range = text.getRangeAt(0); const contents = range.cloneContents(); const div = document.createElement("div"); div.appendChild(contents); pressedElement = div; pressedBound = range.getBoundingClientRect(); } } } } // mark the pressed element if (pressedElement && pressedBound) { if (this._iframeIndicatorRef.current) { this._iframeIndicatorRef.current.style.top = pressedBound.top + "px"; this._iframeIndicatorRef.current.style.left = pressedBound.left + "px"; this._iframeIndicatorRef.current.style.width = pressedBound.width + "px"; this._iframeIndicatorRef.current.style.height = pressedBound.height + "px"; this._iframeIndicatorRef.current.classList.add("active"); } } // start dragging the pressed element if long pressed this._longPressSecondsHack = setTimeout(() => { if (pressedImg && pressedElement && pressedBound) { e.stopPropagation(); e.preventDefault(); if (pressedElement.nodeName === "IMG") { const src = pressedElement.getAttribute("src"); // TODO: may not always work if (src) { const doc = Docs.Create.ImageDocument(src); ImageUtils.ExtractExif(doc); // add clone to div so that dragging ghost is placed properly if (this._iframeDragRef.current) this._iframeDragRef.current.appendChild(pressedElement); const dragData = new DragManager.DocumentDragData([doc]); DragManager.StartDocumentDrag([pressedElement], dragData, this._pressX, this._pressY, { hideSource: true }); } } } else if (selectedText && pressedBound && pressedElement) { e.stopPropagation(); e.preventDefault(); // create doc with the selected text's html const doc = Docs.Create.HtmlDocument(pressedElement.innerHTML); // create dragging ghost with the selected text if (this._iframeDragRef.current) this._iframeDragRef.current.appendChild(pressedElement); // start the drag const dragData = new DragManager.DocumentDragData([doc]); DragManager.StartDocumentDrag([pressedElement], dragData, this._pressX - pressedBound.top, this._pressY - pressedBound.top, { hideSource: true }); } }, 1500); } onLongPressMove = (e: PointerEvent) => { // this._pressX = e.clientX; // this._pressY = e.clientY; } onLongPressUp = (e: PointerEvent) => { this._longPressSecondsHack && clearTimeout(this._longPressSecondsHack); this._iframeIndicatorRef.current?.classList.remove("active"); while (this._iframeDragRef.current?.firstChild) this._iframeDragRef.current.removeChild(this._iframeDragRef.current.firstChild); } specificContextMenu = (e: React.MouseEvent): void => { const cm = ContextMenu.Instance; const funcs: ContextMenuProps[] = []; 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[this.fieldKey + "-contentWidth"] ? "Unfreeze" : "Freeze") + " Content Width", event: () => this.layoutDoc[this.fieldKey + "-contentWidth"] = this.layoutDoc[this.fieldKey + "-contentWidth"] ? undefined : Doc.NativeWidth(this.layoutDoc), icon: "snowflake" }); cm.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); } @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(field.url.href) : field.url.href; // view =