import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Property } from 'csstype'; import { htmlToText } from 'html-to-text'; import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import axios from 'axios'; import * as WebRequest from 'web-request'; import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivHeight, getWordAtPoint, lightOrDark, returnFalse, returnOne, returnZero, setupMoveUpEvents, smoothScroll } from '../../../ClientUtils'; import { Doc, DocListCast, Field, FieldType, Opt } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { HtmlField } from '../../../fields/HtmlField'; import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { RefField } from '../../../fields/RefField'; import { listSpec } from '../../../fields/Schema'; import { Cast, NumCast, StrCast, toList, WebCast } from '../../../fields/Types'; import { ImageField, WebField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction, stringHash } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { DocUtils } from '../../documents/DocUtils'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SnappingManager } from '../../util/SnappingManager'; import { undoable, UndoManager } from '../../util/UndoManager'; import { MarqueeOptionsMenu } from '../collections/collectionFreeForm'; import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { Colors } from '../global/globalEnums'; import { MarqueeAnnotator } from '../MarqueeAnnotator'; import { AnchorMenu } from '../pdf/AnchorMenu'; import { Annotation } from '../pdf/Annotation'; import { GPTPopup } from '../pdf/GPTPopup/GPTPopup'; import { PinDocView, PinProps } from '../PinFuncs'; import { SidebarAnnos } from '../SidebarAnnos'; import { StyleProp } from '../StyleProp'; import { ViewBoxInterface } from '../ViewBoxInterface'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import { LinkInfo } from './LinkDocPreview'; import { OpenWhere } from './OpenWhere'; import './WebBox.scss'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { CreateImage } = require('./WebBoxRenderer'); @observer export class WebBox extends ViewBoxAnnotatableComponent() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(WebBox, fieldKey); } public static openSidebarWidth = 250; public static sidebarResizerWidth = 5; static webStyleSheet = addStyleSheet(); private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean, hide: boolean, doc: Opt) => void); private _mainCont: React.RefObject = React.createRef(); private _outerRef: React.RefObject = React.createRef(); private _marqueeref = React.createRef(); private _disposers: { [name: string]: IReactionDisposer } = {}; private _annotationLayer: React.RefObject = React.createRef(); private _keyInput = React.createRef(); private _initialScroll: Opt = NumCast(this.layoutDoc.thumbScrollTop, NumCast(this.layoutDoc.layout_scrollTop)); private _sidebarRef = React.createRef(); private _searchRef = React.createRef(); private _searchString = ''; private _scrollTimer: NodeJS.Timeout | undefined; private _getAnchor: (savedAnnotations: Opt>, addAsAnnotation: boolean) => Opt = () => undefined; @observable private _webUrl = ''; // url of the page we want to display @observable private _hackHide = false; @observable private _searching: boolean = false; @observable private _showSidebar = false; @observable private _webPageHasBeenRendered = false; @observable private _marqueeing: number[] | undefined = undefined; @observable private _screenshotUrl: string | null = null; // URL to the screenshot image @observable private _fullHeight: number = 0; // Full height of the webpage screenshot @observable private _isLoadingScreenshot: boolean = false; // Loading state for the screenshot @observable private _iframe: HTMLIFrameElement | null = null; @observable private _savedAnnotations = new ObservableMap(); @observable private _scrollHeight = NumCast(this.layoutDoc.scrollHeight); @observable private _screenshotError: string | null = null; // Error message if screenshot fails @observable private _loadingFromCache: boolean = false; @computed get _url() { return this.webField?.toString() || ''; } @computed get _urlHash() { return '' + (stringHash(this._url) ?? ''); } @computed get scrollHeight() { return Math.max(NumCast(this.layoutDoc._height), this._scrollHeight); } @computed get allAnnotations() { return DocListCast(this.dataDoc[this.annotationKey]); } @computed get inlineTextAnnotations() { return this.allAnnotations.filter(a => a.text_inlineAnnotations); } @computed get webField() { return Cast(this.Document[this._props.fieldKey], WebField)?.url; } constructor(props: FieldViewProps) { super(props); makeObservable(this); this._webUrl = this._url; // setting the weburl will change the src parameter of the embedded iframe and force a navigation to it. } @action override 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); }); } try { const contentWindow = this._iframe?.contentWindow; if (clear) { contentWindow?.getSelection()?.empty(); } if (searchString && contentWindow && 'find' in contentWindow) { (contentWindow.find as (str: string, caseSens?: boolean, backward?: boolean, wrapAround?: boolean) => void)(searchString, false, bwd, true); } } catch (e) { console.log('WebBox search error', e); } return true; }; @action setScrollPos = (pos: number) => { if (!this._outerRef.current || this._outerRef.current.scrollHeight < pos) { if (this._webPageHasBeenRendered) setTimeout(() => this.setScrollPos(pos), 250); } else { this._outerRef.current.scrollTop = pos; this._initialScroll = undefined; } }; updateIcon = async () => { if (!this._screenshotUrl) { // If we don't have a screenshot yet, capture one first await this.captureWebScreenshot(); } const scrollTop = NumCast(this.layoutDoc._layout_scrollTop); const nativeWidth = NumCast(this.layoutDoc.nativeWidth); const nativeHeight = (nativeWidth * this._props.PanelHeight()) / this._props.PanelWidth(); this.layoutDoc.thumb = undefined; this.Document.thumbLockout = true; // lock to prevent multiple thumb updates. try { // If we have a screenshot, use it directly for the thumbnail if (this._screenshotUrl) { return ClientUtils.convertDataUri(this._screenshotUrl, this.layoutDoc[Id] + '_icon_' + new Date().getTime(), true, this.layoutDoc[Id] + '_icon_').then(returnedfilename => { this.Document.thumbLockout = false; this.layoutDoc.thumb = new ImageField(returnedfilename); this.layoutDoc.thumbScrollTop = scrollTop; this.layoutDoc.thumbNativeWidth = nativeWidth; this.layoutDoc.thumbNativeHeight = nativeHeight; }); } else { console.log('No screenshot available for thumbnail generation'); this.Document.thumbLockout = false; return Promise.resolve(); } } catch (error) { console.error('Error creating thumbnail:', error); this.Document.thumbLockout = false; return Promise.reject(error); } }; componentDidMount() { this._props.setContentViewBox?.(this); // this tells the DocumentView that this WebBox is the "content" of the document. this allows the DocumentView to call WebBox relevant methods to configure the UI (eg, show back/forward buttons) runInAction(() => { this._annotationKeySuffix = () => (this._urlHash ? 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) if (this._url) { const reqdFuncs: { [key: string]: string } = {}; reqdFuncs[this.fieldKey + '_annotations'] = `copyField(this["${this.fieldKey}_"+urlHash(this["${this.fieldKey}"]?.url?.toString())+"_annotations"])`; reqdFuncs[this.fieldKey + '_annotations-setter'] = `this["${this.fieldKey}_"+urlHash(this["${this.fieldKey}"]?.url?.toString())+"_annotations"] = value`; reqdFuncs[this.fieldKey + '_sidebar'] = `copyField(this["${this.fieldKey}_"+urlHash(this["${this.fieldKey}"]?.url?.toString())+"_sidebar"])`; DocUtils.AssignScripts(this.dataDoc, {}, reqdFuncs); } }); this._disposers.urlchange = reaction( () => WebCast(this.dataDoc.data), () => this.submitURL(false, false) ); this._disposers.titling = reaction( () => StrCast(this.Document.title), url => { url.startsWith('www') && this.setData('http://' + url); url.startsWith('http') && this.setData(url); } ); this._disposers.layout_autoHeight = reaction( () => this.layoutDoc._layout_autoHeight, layoutAutoHeight => { if (layoutAutoHeight) { const nh = NumCast(this.Document[this._props.fieldKey + '_nativeHeight'], NumCast(this.Document.nativeHeight)); this.layoutDoc._nativeHeight = nh; this._props.setHeight?.(nh * (this._props.NativeDimScaling?.() || 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 = NumCast(this.layoutDoc._width) / youtubeaspect; } } // else it's an HTMLfield } else if (this.webField && !this.dataDoc.text) { WebRequest.get(ClientUtils.CorsProxy(this.webField.href)) // .then(result => { result && (this.dataDoc.text = htmlToText(result.content)); }); } this._disposers.scrollReaction = reaction( () => NumCast(this.layoutDoc._layout_scrollTop), scrollTop => { 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, 'ease'); }, { fireImmediately: true } ); // Check if we have a cached screenshot URL in metadata if (this._url) { this._webUrl = this._url; const cachedScreenshotUrl = StrCast(this.dataDoc[this.fieldKey + '_screenshotUrl']); const cachedHeight = NumCast(this.dataDoc[this.fieldKey + '_screenshotHeight']); if (cachedScreenshotUrl && cachedHeight) { // Use cached screenshot this._loadingFromCache = true; this._isLoadingScreenshot = true; // Verify the cached screenshot exists by loading the image const img = new Image(); img.onload = action(() => { this._screenshotUrl = cachedScreenshotUrl; this._fullHeight = cachedHeight; this._scrollHeight = cachedHeight; this._webPageHasBeenRendered = true; this._isLoadingScreenshot = false; this._loadingFromCache = false; // Apply dimensions and initial scroll if (this.layoutDoc._layout_autoHeight) { this.layoutDoc._nativeHeight = this._fullHeight; this._props.setHeight?.(this._fullHeight * (this._props.NativeDimScaling?.() || 1)); } if (this._initialScroll !== undefined) { this.setScrollPos(this._initialScroll); } console.log(`Loaded cached screenshot: ${this._screenshotUrl}`); }); img.onerror = action(() => { // If image fails to load, capture a new screenshot console.log('Cached screenshot not found, capturing new one'); this._loadingFromCache = false; this.captureWebScreenshot(); }); img.src = cachedScreenshotUrl; } else { // No cached screenshot, capture a new one this.captureWebScreenshot(); } } } componentWillUnmount() { // Clean up timers if (this._scrollTimer) { clearTimeout(this._scrollTimer); this._scrollTimer = undefined; } // Clean up reaction disposers Object.values(this._disposers).forEach(disposer => disposer?.()); } private _selectionText: string = ''; private _selectionContent: DocumentFragment | undefined; selectionText = () => this._selectionText; selectionContent = () => this._selectionContent; @action createTextAnnotation = (sel: Selection, selRange: Range | undefined) => { if (this._mainCont.current && selRange) { if (this.dataDoc[this._props.fieldKey] instanceof HtmlField) this._mainCont.current.style.transform = `rotate(${NumCast(this.DocumentView!().screenToContentsTransform().RotateDeg)}deg)`; const clientRects = selRange.getClientRects(); for (let i = 0; i < clientRects.length; i++) { const rect = clientRects.item(i); const mainrect = this._url ? { translateX: 0, translateY: 0, scale: 1 } : ClientUtils.GetScreenTransform(this._mainCont.current); if (rect && rect.width !== this._mainCont.current.clientWidth) { const annoBox = document.createElement('div'); annoBox.className = 'marqueeAnnotator-annotationBox'; const scale = this._url ? 1 : this.ScreenToLocalBoxXf().Scale; // transforms the positions from screen onto the pdf div annoBox.style.top = ((rect.top - mainrect.translateY) * scale + (this._url ? this._mainCont.current.scrollTop : NumCast(this.layoutDoc.layout_scrollTop))).toString(); annoBox.style.left = ((rect.left - mainrect.translateX) * scale).toString(); annoBox.style.width = (rect.width * scale).toString(); annoBox.style.height = (rect.height * scale).toString(); this._annotationLayer.current && MarqueeAnnotator.previewNewAnnotation(this._savedAnnotations, this._annotationLayer.current, annoBox, 1); } } this._mainCont.current.style.transform = ''; } this._selectionContent = selRange?.cloneContents(); this._selectionText = this._selectionContent?.textContent || ''; // clear selection this._textAnnotationCreator = undefined; if (sel.empty) sel.empty(); // Chrome else if (sel.removeAllRanges) sel.removeAllRanges(); // Firefox return this._savedAnnotations; }; focus = (anchor: Doc, options: FocusViewOptions) => { if (anchor !== this.Document && this._outerRef.current) { const windowHeight = this._props.PanelHeight() / (this._props.NativeDimScaling?.() || 1); const scrollTo = ClientUtils.scrollIntoView( NumCast(anchor.y), NumCast(anchor._height), NumCast(this.layoutDoc._layout_scrollTop), windowHeight, windowHeight * 0.1, Math.max(NumCast(anchor.y) + NumCast(anchor._height), this._scrollHeight) ); if (scrollTo !== undefined) { if (this._initialScroll === undefined) { const focusTime = options.zoomTime ?? 500; this.goTo(scrollTo, focusTime, options.easeFunc); return focusTime; } this._initialScroll = scrollTo; } } return undefined; }; @action getView = (doc: Doc /* , options: FocusViewOptions */) => { if (Doc.AreProtosEqual(doc, this.Document)) return new Promise>(res => { res(this.DocumentView?.()); }); if (this.Document.layout_fieldKey === 'layout_icon') this.DocumentView?.().iconify(); const webUrl = WebCast(doc.config_data)?.url; if (this._url && webUrl && webUrl.href !== this._url) this.setData(webUrl.href); if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) this.toggleSidebar(false); return new Promise>(res => { DocumentView.addViewRenderedCb(doc, dv => res(dv)); }); }; sidebarAddDocTab = (doc: Doc, where: OpenWhere) => { if (DocListCast(this.Document[this._props.fieldKey + '_sidebar']).includes(doc) && !this.SidebarShown) { this.toggleSidebar(false); return true; } return this._props.addDocTab(doc, where); }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { let ele: Opt; try { const contents = this._iframe?.contentWindow?.getSelection()?.getRangeAt(0).cloneContents(); if (contents) { ele = document.createElement('div'); ele.append(contents); } } catch { /* empty */ } const visibleAnchor = this._getAnchor(this._savedAnnotations, true); const anchor = visibleAnchor ?? Docs.Create.ConfigDocument({ title: StrCast(this.Document.title + ' ' + this.layoutDoc._layout_scrollTop), y: NumCast(this.layoutDoc._layout_scrollTop), annotationOn: this.Document, }); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), scrollable: !!pinProps?.pinData, pannable: true } }, this.Document); anchor.text = ele?.textContent ?? ''; anchor.text_html = ele?.innerHTML ?? this._selectionText; addAsAnnotation && this.addDocumentWrapper(anchor); return anchor; }; _textAnnotationCreator: (() => ObservableMap) | undefined; savedAnnotationsCreator: () => ObservableMap = () => this._textAnnotationCreator?.() || this._savedAnnotations; @action webClipDown = (e: React.PointerEvent) => { e.stopPropagation(); const sel = window.getSelection(); this._textAnnotationCreator = undefined; if (sel?.empty) sel.empty(); // Chrome else if (sel?.removeAllRanges) sel.removeAllRanges(); // Firefox // bcz: NEED TO unrotate e.clientX and e.clientY const target = e.target as HTMLElement; const word = target && getWordAtPoint(target, e.clientX, e.clientY); this._setPreviewCursor?.(e.clientX, e.clientY, false, true, this.Document); MarqueeAnnotator.clearAnnotations(this._savedAnnotations); if (!word && !target?.className?.includes('rangeslider') && !target?.onclick && !target?.parentElement?.onclick) { if (e.button !== 2) this.marqueeing = [e.clientX, e.clientY]; e.preventDefault(); } document.addEventListener('pointerup', this.webClipUp); }; @action webClipUp = (e: PointerEvent) => { if (window.getSelection()?.isCollapsed && this._marqueeref.current?.isEmpty) { this.marqueeing = undefined; } document.removeEventListener('pointerup', this.webClipUp); this._getAnchor = AnchorMenu.Instance?.GetAnchor; // need to save AnchorMenu's getAnchor since a subsequent selection on another doc will overwrite this value const sel = window.getSelection(); if (sel && !sel.isCollapsed) { const selRange = sel.getRangeAt(0); this._selectionText = sel.toString(); AnchorMenu.Instance.setSelectedText(sel.toString()); this._textAnnotationCreator = () => this.createTextAnnotation(sel, selRange); (!sel.isCollapsed || this.marqueeing) && AnchorMenu.Instance.jumpTo(e.clientX, e.clientY); // Changing which document to add the annotation to (the currently selected WebBox) GPTPopup.Instance.setSidebarFieldKey(`${this._props.fieldKey}_${this._urlHash ? this._urlHash + '_' : ''}sidebar`); GPTPopup.Instance.addDoc = this.sidebarAddDocument; } }; @action iframeDown = () => { // This is an empty replacement to avoid linter errors // The original functionality is no longer needed }; isFirefox = () => 'InstallTrigger' in window; // navigator.userAgent.indexOf("Chrome") !== -1; addWebStyleSheet(document: Document | null | undefined, styleType: string = 'text/css') { if (document) { const style = document.createElement('style'); style.type = styleType; const sheets = document.head.appendChild(style); return sheets.sheet; } return undefined; } addWebStyleSheetRule(sheet: CSSStyleSheet | null | undefined, selector: string, css: { [key: string]: string }, selectorPrefix = '.') { const propText = typeof css === 'string' ? css : Object.keys(css) .map(p => p + ':' + (p === 'content' ? "'" + css[p] + "'" : css[p])) .join(';'); return sheet?.insertRule(selectorPrefix + selector + '{' + propText + '}', sheet.cssRules.length); } _iframetimeout: NodeJS.Timeout | undefined = undefined; @observable _warning = 0; @action setDashScrollTop = (scrollTop: number, timeout: number = 250) => { const iframeHeight = Math.max(scrollTop, this._scrollHeight - this.panelHeight()); if (this._scrollTimer) { clearTimeout(this._scrollTimer); clearStyleSheetRules(WebBox.webStyleSheet); } addStyleSheetRule(WebBox.webStyleSheet, 'webBox-iframe', { 'pointer-events': 'none' }); this._scrollTimer = setTimeout(() => { clearStyleSheetRules(WebBox.webStyleSheet); this._scrollTimer = undefined; const newScrollTop = scrollTop > iframeHeight ? iframeHeight : scrollTop; if (!LinkInfo.Instance?.LinkInfo && this._outerRef.current && newScrollTop !== this.layoutDoc.thumbScrollTop && (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView?.()))) { this.layoutDoc.thumb = undefined; this.layoutDoc.thumbScrollTop = undefined; this.layoutDoc.thumbNativeWidth = undefined; this.layoutDoc.thumbNativeHeight = undefined; this.layoutDoc.layout_scrollTop = this._outerRef.current.scrollTop = newScrollTop; } else if (this._outerRef.current) this._outerRef.current.scrollTop = newScrollTop; }, timeout); }; goTo = (scrollTop: number, duration: number, easeFunc: 'linear' | 'ease' | undefined) => { if (this._outerRef.current) { if (duration) { smoothScroll(duration, [this._outerRef.current], scrollTop, easeFunc); this.setDashScrollTop(scrollTop, duration); } else { this.setDashScrollTop(scrollTop); } } else this._initialScroll = scrollTop; }; forward = (checkAvailable?: boolean) => { const future = Cast(this.dataDoc[this.fieldKey + '_future'], listSpec('string'), []); const history = Cast(this.dataDoc[this.fieldKey + '_history'], listSpec('string'), []); if (checkAvailable) return future.length; runInAction(() => { if (future.length) { const curUrl = this._url; this.dataDoc[this.fieldKey + '_history'] = new List([...history, this._url]); this.dataDoc[this.fieldKey] = new WebField(new URL(future.pop()!)); this._scrollHeight = 0; // Reset screenshot state for new URL this._screenshotUrl = null; this._fullHeight = 0; this._isLoadingScreenshot = false; if (this._webUrl === this._url) { this._webUrl = curUrl; setTimeout( action(() => { this._webUrl = this._url; this.captureWebScreenshot(); // Capture screenshot for new URL }) ); } else { this._webUrl = this._url; this.captureWebScreenshot(); // Capture screenshot for new URL } return true; } return undefined; }); return false; }; back = (checkAvailable?: boolean) => { const future = Cast(this.dataDoc[this.fieldKey + '_future'], listSpec('string')); const history = Cast(this.dataDoc[this.fieldKey + '_history'], listSpec('string'), []); if (checkAvailable) return history.length; runInAction(() => { if (history.length) { const curUrl = this._url; 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()!)); this._scrollHeight = 0; // Reset screenshot state for new URL this._screenshotUrl = null; this._fullHeight = 0; this._isLoadingScreenshot = false; if (this._webUrl === this._url) { this._webUrl = curUrl; setTimeout( action(() => { this._webUrl = this._url; this.captureWebScreenshot(); // Capture screenshot for new URL }) ); } else { this._webUrl = this._url; this.captureWebScreenshot(); // Capture screenshot for new URL } return true; } return undefined; }); return false; }; @action submitURL = (preview?: boolean, dontUpdateIframe?: boolean) => { try { if (!preview) { if (this._webPageHasBeenRendered) { this.layoutDoc.thumb = undefined; this.layoutDoc.thumbScrollTop = undefined; this.layoutDoc.thumbNativeWidth = undefined; this.layoutDoc.thumbNativeHeight = undefined; } if (!dontUpdateIframe) { this._webUrl = this._url; // Capture screenshot when URL changes this.captureWebScreenshot(); } } } catch { console.log('WebBox URL error:' + this._url); } return true; }; @action captureWebScreenshot = async () => { if (!this._url || this._loadingFromCache) return; try { this._isLoadingScreenshot = true; this._screenshotError = null; console.log(`Capturing screenshot for URL: ${this._url}`); try { const response = await axios.post('/captureWebScreenshot', { url: this._url, width: NumCast(this.Document.nativeWidth, 1200), height: NumCast(this.Document.nativeHeight, 800), fullPage: true, // Request a full page screenshot }); runInAction(() => { this._screenshotUrl = response.data.screenshotUrl; this._fullHeight = response.data.fullHeight; this._scrollHeight = response.data.fullHeight; this._webPageHasBeenRendered = true; this._isLoadingScreenshot = false; // Store screenshot URL and height in document metadata this.dataDoc[this.fieldKey + '_screenshotUrl'] = response.data.screenshotUrl; this.dataDoc[this.fieldKey + '_screenshotHeight'] = response.data.fullHeight; // Update native dimensions to match the screenshot if (!this.dataDoc[this.fieldKey + '_nativeWidth']) { this.dataDoc[this.fieldKey + '_nativeWidth'] = 1200; // Default width } if (!this.dataDoc[this.fieldKey + '_nativeHeight']) { this.dataDoc[this.fieldKey + '_nativeHeight'] = this._fullHeight; } // Set document height if needed if (this.layoutDoc._layout_autoHeight) { this.layoutDoc._nativeHeight = this._fullHeight; this._props.setHeight?.(this._fullHeight * (this._props.NativeDimScaling?.() || 1)); } // Apply initial scroll if needed if (this._initialScroll !== undefined) { this.setScrollPos(this._initialScroll); } console.log(`Screenshot captured successfully: ${this._screenshotUrl} with height: ${this._fullHeight}px`); }); } catch (error: any) { // Handle error from the API console.error('Error capturing screenshot:', error); let errorMessage = 'Failed to capture webpage screenshot'; // Try to extract detailed error message from response if (error.response && error.response.data && error.response.data.error) { errorMessage = error.response.data.error; } else if (error.message) { errorMessage = error.message; } runInAction(() => { this._screenshotError = errorMessage; this._isLoadingScreenshot = false; }); } } catch (error: any) { // Handle unexpected errors runInAction(() => { console.error('Unexpected error in captureWebScreenshot:', error); this._screenshotError = 'An unexpected error occurred'; this._isLoadingScreenshot = false; }); } }; @action 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.setData(newurl); e.stopPropagation(); }; @action setData = (data: FieldType | Promise) => { if (!(typeof data === 'string') && !(data instanceof WebField)) return false; if (Field.toString(data) === this._url) return false; // Reset state for new URL this._scrollHeight = 0; this._screenshotUrl = null; this._fullHeight = 0; this._isLoadingScreenshot = false; // Clear stored screenshot metadata for the previous URL this.dataDoc[this.fieldKey + '_screenshotUrl'] = undefined; this.dataDoc[this.fieldKey + '_screenshotHeight'] = undefined; const oldUrl = this._url; const history = Cast(this.dataDoc[this.fieldKey + '_history'], listSpec('string'), []); const weburl = new WebField(Field.toString(data)); this.dataDoc[this.fieldKey + '_future'] = new List([]); this.dataDoc[this.fieldKey + '_history'] = new List([...(history || []), oldUrl]); this.dataDoc[this.fieldKey] = weburl; // Capture screenshot for the new URL this._webUrl = weburl.toString(); this.captureWebScreenshot(); return true; }; onWebUrlValueKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') this.setData(this._keyInput.current!.value); e.stopPropagation(); }; specificContextMenu = (): void => { const cm = ContextMenu.Instance; const funcs: ContextMenuProps[] = []; if (!cm.findByDescription('Options...')) { !Doc.noviceMode && funcs.push({ description: (this.layoutDoc[this.fieldKey + '_useCors'] ? "Don't Use" : 'Use') + ' Cors', event: () => { this.layoutDoc[this.fieldKey + '_useCors'] = !this.layoutDoc[this.fieldKey + '_useCors']; // Re-capture screenshot with the new setting this.captureWebScreenshot(); }, icon: 'snowflake', }); // Remove the "Allow Scripts" option since it's not relevant for screenshots funcs.push({ description: (!this.layoutDoc.layout_reflowHorizontal ? 'Force' : 'Prevent') + ' Reflow', event: () => { const nw = !this.layoutDoc.layout_reflowHorizontal ? undefined : Doc.NativeWidth(this.layoutDoc) - this.sidebarWidth() / (this._props.NativeDimScaling?.() || 1); this.layoutDoc.layout_reflowHorizontal = !nw; if (nw) { Doc.SetInPlace(this.layoutDoc, this.fieldKey + '_nativeWidth', nw, true); } }, icon: 'snowflake', }); // Add a refresh option to re-capture the screenshot funcs.push({ description: 'Refresh Screenshot', event: () => this.captureWebScreenshot(), icon: 'sync-alt', }); !Doc.noviceMode && funcs.push({ description: 'Update Icon', event: () => this.updateIcon(), icon: 'portrait', }); cm.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' }); } }; /** * This gets called when some other child of the webbox is selected and a pointer down occurs on the webbox. * it's also called for html clippings when you click outside the bounds of the clipping * @param e */ @action onMarqueeDown = (e: React.PointerEvent) => { const sel = window.document.getSelection(); this._textAnnotationCreator = undefined; if (sel?.empty) sel.empty(); // Chrome else if (sel?.removeAllRanges) sel.removeAllRanges(); // Firefox this.marqueeing = [e.clientX, e.clientY]; if (!e.altKey && e.button === 0 && this._props.isContentActive() && Doc.ActiveTool !== InkTool.Ink) { setupMoveUpEvents( this, e, action(() => { MarqueeAnnotator.clearAnnotations(this._savedAnnotations); return true; }), returnFalse, action(() => { this.marqueeing = undefined; MarqueeAnnotator.clearAnnotations(this._savedAnnotations); }), false ); } else { this.marqueeing = undefined; } }; @action finishMarquee = (x?: number, y?: number) => { this._getAnchor = AnchorMenu.Instance?.GetAnchor; this.marqueeing = undefined; this._setPreviewCursor?.(x ?? 0, y ?? 0, false, !this._marqueeref.current?.isEmpty, this.Document); }; @observable lighttext = false; @computed get urlContent() { if (this.ScreenToLocalBoxXf().Scale > 25) return
; setTimeout( action(() => { if (this._initialScroll === undefined && !this._webPageHasBeenRendered) { this.setScrollPos(NumCast(this.layoutDoc.thumbScrollTop, NumCast(this.layoutDoc.layout_scrollTop))); } this._webPageHasBeenRendered = true; }) ); const field = this.dataDoc[this._props.fieldKey]; // Handle HTML field (text content) if (field instanceof HtmlField) { return ( { if (r) { this._scrollHeight = DivHeight(r); this.lighttext = Array.from(r.children).some((c: Element) => c instanceof HTMLElement && lightOrDark(getComputedStyle(c).color) !== Colors.WHITE); } })} contentEditable onPointerDown={this.webClipDown} dangerouslySetInnerHTML={{ __html: field.html }} /> ); } // Handle WebField (screenshot of webpage) if (field instanceof WebField) { // Show loading state with spinner if (this._isLoadingScreenshot) { return (
{this._loadingFromCache ? 'Loading cached webpage preview...' : 'Loading webpage preview...'}
); } // Show error state with retry button if (this._screenshotError) { return (
{this._screenshotError}
); } // Show screenshot in scrollable container if (this._screenshotUrl) { return (
Webpage screenshot) => { console.error('Error loading screenshot:', e); this._screenshotError = 'Failed to load screenshot image'; this._isLoadingScreenshot = false; this.dataDoc[this.fieldKey + '_screenshotUrl'] = undefined; this.dataDoc[this.fieldKey + '_screenshotHeight'] = undefined; })} onLoad={() => { this._scrollHeight = this._fullHeight; if (this._initialScroll !== undefined) { this.setScrollPos(this._initialScroll); } }} />
); } // Fall back to a placeholder if no screenshot yet return (
Preparing webpage preview...
); } // Default placeholder return (
No content to display
); } addDocumentWrapper = (docs: Doc | Doc[], annotationKey?: string) => { this._url && toList(docs).forEach(doc => { doc.config_data = new WebField(this._url); }); return this.addDocument(docs, annotationKey); }; sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { if (!this.layoutDoc._layout_showSidebar) this.toggleSidebar(); return this.addDocumentWrapper(doc, sidebarKey); }; @observable _draggingSidebar = false; sidebarBtnDown = (e: React.PointerEvent, onButton: boolean) => { const batch = UndoManager.StartBatch('sidebar'); // onButton determines whether the width of the pdf box changes, or just the ratio of the sidebar to the pdf setupMoveUpEvents( this, e, action((moveEv, down, delta) => { this._draggingSidebar = true; const localDelta = this._props .ScreenToLocalTransform() .scale(this._props.NativeDimScaling?.() || 1) .transformDirection(delta[0], delta[1]); const nativeWidth = NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth']); const nativeHeight = NumCast(this.layoutDoc[this.fieldKey + '_nativeHeight']); const curNativeWidth = NumCast(this.layoutDoc.nativeWidth, nativeWidth); const ratio = (curNativeWidth + ((onButton ? 1 : -1) * localDelta[0]) / (this._props.NativeDimScaling?.() || 1)) / nativeWidth; if (ratio >= 1) { this.layoutDoc.nativeWidth = nativeWidth * ratio; this.layoutDoc.nativeHeight = nativeHeight * (1 + ratio); onButton && (this.layoutDoc._width = NumCast(this.layoutDoc._width) + localDelta[0]); this.layoutDoc._layout_showSidebar = nativeWidth !== this.layoutDoc._nativeWidth; } return false; }), action((upEv, movement, isClick) => { this._draggingSidebar = false; !isClick && batch.end(); }), () => { this.toggleSidebar(); batch.end(); } ); }; @computed get sidebarHandle() { return (
this.sidebarBtnDown(e, true)}>
); } @observable _previewNativeWidth: Opt = undefined; @observable _previewWidth: Opt = undefined; toggleSidebar = action((preview: boolean = false) => { let nativeWidth = NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth']); if (!nativeWidth) { const defaultNativeWidth = NumCast(this.Document.nativeWidth, this.dataDoc[this.fieldKey] instanceof WebField ? 850 : NumCast(this.Document._width)); Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(this.dataDoc) || defaultNativeWidth); Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(this.dataDoc) || (NumCast(this.Document._height) / NumCast(this.Document._width)) * defaultNativeWidth); nativeWidth = NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth']); } const sideratio = ((!this.layoutDoc.nativeWidth || this.layoutDoc.nativeWidth === nativeWidth ? WebBox.openSidebarWidth : 0) + nativeWidth) / nativeWidth; const pdfratio = ((!this.layoutDoc.nativeWidth || this.layoutDoc.nativeWidth === nativeWidth ? WebBox.openSidebarWidth + WebBox.sidebarResizerWidth : 0) + NumCast(this.layoutDoc.width)) / NumCast(this.layoutDoc.width); const curNativeWidth = NumCast(this.layoutDoc.nativeWidth, nativeWidth); if (preview) { this._previewNativeWidth = nativeWidth * sideratio; this._previewWidth = (NumCast(this.layoutDoc._width) * nativeWidth * sideratio) / curNativeWidth; this._showSidebar = true; } else { this.layoutDoc._layout_showSidebar = !this.layoutDoc._layout_showSidebar; this.layoutDoc._width = (NumCast(this.layoutDoc._width) * nativeWidth * pdfratio) / curNativeWidth; if (!this.layoutDoc._layout_showSidebar && !(this.dataDoc[this.fieldKey] instanceof WebField)) { this.layoutDoc.nativeWidth = this.dataDoc[this.fieldKey + '_nativeWidth'] = undefined; } else { !this.layoutDoc._layout_showSidebar && (this.dataDoc[this.fieldKey + '_nativeWidth'] = this.dataDoc[this.fieldKey + '_nativeHeight'] = undefined); this.layoutDoc.nativeWidth = nativeWidth * pdfratio; } } }); @action onZoomWheel = (e: React.WheelEvent) => { if (this._props.isContentActive()) { e.stopPropagation(); } }; sidebarWidth = () => { if (!this.SidebarShown) return 0; if (this._previewWidth) return WebBox.sidebarResizerWidth + WebBox.openSidebarWidth; // return default sidebar if previewing (as in viewing a link target) const nativeDiff = NumCast(this.layoutDoc.nativeWidth) - Doc.NativeWidth(this.dataDoc); return WebBox.sidebarResizerWidth + nativeDiff * (this._props.NativeDimScaling?.() || 1); }; _innerCollectionView: CollectionFreeFormView | undefined; zoomScaling = () => this._innerCollectionView?.zoomScaling() ?? 1; setInnerContent = (component: ViewBoxInterface) => { this._innerCollectionView = component as CollectionFreeFormView; }; @computed get content() { const interactive = this._props.isContentActive() && this._props.pointerEvents?.() !== 'none' && Doc.ActiveTool === InkTool.None; return (
e.stopPropagation()} style={{ width: !this.layoutDoc.layout_reflowHorizontal ? NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth']) || `100%` : '100%', transform: `scale(${this.zoomScaling()}) translate(${-NumCast(this.layoutDoc.freeform_panX)}px, ${-NumCast(this.layoutDoc.freeform_panY)}px)`, }}> {this._hackHide ? null : this.urlContent}
); } @computed get annotationLayer() { TraceMobx(); return (
{this.inlineTextAnnotations .sort((a, b) => NumCast(a.y) - NumCast(b.y)) .map(anno => ( ))}
); } @computed get SidebarShown() { return !!(this._showSidebar || this.layoutDoc._layout_showSidebar); } renderAnnotations = (childFilters: () => string[]) => ( ); @computed get renderOpaqueAnnotations() { return this.renderAnnotations(this.opaqueFilter); } @computed get renderTransparentAnnotations() { return this.renderAnnotations(this.transparentFilter); } childPointerEvents = () => (this._props.isContentActive() ? 'all' : undefined); @computed get webpage() { TraceMobx(); const containerWidth = NumCast(this.layoutDoc._width) || this._props.PanelWidth(); const pointerEvents = this.layoutDoc._lockedPosition ? 'none' : (this._props.pointerEvents?.() as Property.PointerEvents | undefined); return (
this.setDashScrollTop(this._outerRef.current?.scrollTop || 0)} onPointerDown={this.onMarqueeDown}>
{this.content}
{this.renderTransparentAnnotations}
{this.renderOpaqueAnnotations} {this.annotationLayer}
); } @computed get searchUI() { return (
e.stopPropagation()} style={{ display: this._props.isContentActive() ? 'flex' : 'none' }}>
e.stopPropagation()} style={{ left: `${this._searching ? 0 : 100}%` }}>
{/* Refresh button */}
); } searchStringChanged = (e: React.ChangeEvent) => { this._searchString = e.currentTarget.value; }; setPreviewCursor = (func?: (x: number, y: number, drag: boolean, hide: boolean, doc: Opt) => void) => { this._setPreviewCursor = func; }; panelWidth = () => this._props.PanelWidth() / (this._props.NativeDimScaling?.() || 1) - this.sidebarWidth() + WebBox.sidebarResizerWidth; panelHeight = () => this._props.PanelHeight() / (this._props.NativeDimScaling?.() || 1); scrollXf = () => this.ScreenToLocalBoxXf().translate(0, NumCast(this.layoutDoc._layout_scrollTop)); anchorMenuClick = () => this._sidebarRef.current?.anchorMenuClick; transparentFilter = () => [...this._props.childFilters(), ClientUtils.TransparentBackgroundFilter]; opaqueFilter = () => [...this._props.childFilters(), ClientUtils.noDragDocsFilter, ...(SnappingManager.CanEmbed ? [] : [ClientUtils.OpaqueBackgroundFilter])]; childStyleProvider = (doc: Doc | undefined, props: Opt, property: string) => { if (doc instanceof Doc && property === StyleProp.PointerEvents) { if (this.inlineTextAnnotations.includes(doc)) return 'none'; } return this._props.styleProvider?.(doc, props, property); }; pointerEvents = () => !this._draggingSidebar && this._props.isContentActive() && !MarqueeOptionsMenu.Instance?.isShown() ? 'all' // : 'none'; annotationPointerEvents = () => (this._props.isContentActive() && (SnappingManager.IsDragging || Doc.ActiveTool !== InkTool.None) ? 'all' : 'none'); render() { TraceMobx(); const containerWidth = NumCast(this.layoutDoc._width) || this._props.PanelWidth(); const pointerEvents = this.layoutDoc._lockedPosition ? 'none' : (this._props.pointerEvents?.() as Property.PointerEvents); // Force the component to be square this.layoutDoc._height = containerWidth; this.layoutDoc._width = containerWidth; this.layoutDoc._forceActive = true; return (
{this.webpage}
{!this._mainCont.current || !this.DocumentView || !this._annotationLayer.current ? null : (
)}
this.sidebarBtnDown(e, false)} />
{!this._props.isContentActive() || SnappingManager.IsDragging ? null : this.sidebarHandle} {!this._props.isContentActive() || SnappingManager.IsDragging ? null : this.searchUI}
); } get marqueeing() { return this._marqueeing; } set marqueeing(val) { val && this._marqueeref.current?.onInitiateSelection(val); !val && this._marqueeref.current?.onTerminateSelection(); this._marqueeing = val; } } // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function urlHash(url: string) { return stringHash(url); }); Docs.Prototypes.TemplateMap.set(DocumentType.WEB, { layout: { view: WebBox, dataField: 'data' }, options: { acl: '', _height: 300, _layout_fitWidth: true, _layout_nativeDimEditable: true, _layout_reflowVertical: true, waitForDoubleClickToClick: 'always', systemIcon: 'BsGlobe' }, }); // Add CSS styles for screenshot mode const webBoxStyles = ` .webBox-screenshot-container { width: 100%; position: relative; overflow: visible; display: flex; align-items: flex-start; justify-content: center; background-color: #f5f5f5; } .webBox-screenshot { width: 100%; pointer-events: none; display: block; user-select: none; object-fit: contain; transition: opacity 0.3s ease; } .webBox-loading { padding: 20px; text-align: center; color: #666; background-color: #f5f5f5; border-radius: 4px; min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; } .webBox-loading-message { font-size: 16px; margin-bottom: 15px; color: #555; } .webBox-loading-spinner { margin-top: 10px; color: #1976d2; } .webBox-error { padding: 20px; color: #d32f2f; text-align: center; background-color: #ffebee; border-radius: 4px; min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; } .webBox-error-icon { color: #d32f2f; margin-bottom: 10px; } .webBox-error-message { color: #d32f2f; font-size: 14px; max-width: 80%; line-height: 1.5; } .webBox-error-actions { margin-top: 10px; } .webBox-retry-button { background-color: #f44336; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; transition: background-color 0.3s; } .webBox-retry-button:hover { background-color: #d32f2f; } .webBox-placeholder { padding: 20px; text-align: center; color: #757575; background-color: #fafafa; border-radius: 4px; min-height: 200px; display: flex; align-items: center; justify-content: center; } .webBox-refreshButton { margin-right: 5px; } .webBox-innerContent { position: relative; width: 100%; background-color: #f5f5f5; overflow: visible; } .webBox-outerContent { overflow: auto; width: 100%; background-color: #f5f5f5; position: relative; } .webBox-container { position: relative; display: flex; flex-direction: column; height: 100%; background-color: white; border-radius: 4px; overflow: hidden; } .webBox { position: relative; height: 100%; width: 100%; overflow: hidden; background-color: white; border-radius: 4px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); } `; // Add the styles to the document const styleEl = document.createElement('style'); styleEl.textContent = webBoxStyles; document.head.appendChild(styleEl);