import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { extname } from 'path'; import { DataSym, Doc, DocListCast, Opt, WidthSym } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { createSchema } from '../../../fields/Schema'; import { ComputedField } from '../../../fields/ScriptField'; import { BoolCast, Cast, NumCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { DashColor, emptyFunction, OmitKeys, returnEmptyString, returnFalse, returnOne, setupMoveUpEvents, Utils } from '../../../Utils'; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; import { CognitiveServices, Confidence, Service, Tag } from '../../cognitive_services/CognitiveServices'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { Networking } from '../../Network'; import { DragManager } from '../../util/DragManager'; import { undoBatch } from '../../util/UndoManager'; import { ContextMenu } from '../../views/ContextMenu'; import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; import { ContextMenuProps } from '../ContextMenuItem'; import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from '../DocComponent'; import { MarqueeAnnotator } from '../MarqueeAnnotator'; import { AnchorMenu } from '../pdf/AnchorMenu'; import { StyleProp } from '../StyleProvider'; import { DocFocusOptions, OpenWhere } from './DocumentView'; import { FaceRectangles } from './FaceRectangles'; import { FieldView, FieldViewProps } from './FieldView'; import './ImageBox.scss'; import { PresBox } from './trails'; import React = require('react'); import Color = require('color'); import { LinkDocPreview } from './LinkDocPreview'; import { DocumentManager } from '../../util/DocumentManager'; export const pageSchema = createSchema({ googlePhotosUrl: 'string', googlePhotosTags: 'string', }); const uploadIcons = { idle: 'downarrow.png', loading: 'loading.gif', success: 'greencheck.png', failure: 'redx.png', }; @observer export class ImageBox extends ViewBoxAnnotatableComponent() { protected _multiTouchDisposer?: import('../../util/InteractionUtils').InteractionUtils.MultiTouchEventDisposer | undefined; public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ImageBox, fieldKey); } private _ignoreScroll = false; private _forcedScroll = false; private _dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [name: string]: IReactionDisposer } = {}; private _getAnchor: (savedAnnotations: Opt>, addAsAnnotation: boolean) => Opt = () => undefined; @observable _curSuffix = ''; @observable _uploadIcon = uploadIcons.idle; constructor(props: any) { super(props); this.props.setContentView?.(this); } protected createDropTarget = (ele: HTMLDivElement) => { this._dropDisposer?.(); ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.props.Document)); }; @action scrollFocus = (anchor: Doc, options: DocFocusOptions) => { const focusSpeed = options.instant ? 0 : options.zoomTime ?? 500; return PresBox.restoreTargetDocView( this.props.DocumentView?.(), // { pinDocLayout: BoolCast(anchor.presPinDocLayout) }, anchor, focusSpeed, !anchor.presPinData ? {} : { pannable: true, dataannos: anchor.presAnnotations !== undefined, dataview: true, } ) ? focusSpeed : undefined; }; // sets viewing information for a componentview, typically when following a link. 'preview' tells the view to use the values without writing to the document getAnchor = (addAsAnnotation: boolean) => { const anchor = this._getAnchor?.(this._savedAnnotations, false) ?? // use marquee anchor, otherwise, save zoom/pan as anchor Docs.Create.ImageanchorDocument({ presTransition: 1000, unrendered: true, annotationOn: this.rootDoc }); if (anchor) { PresBox.pinDocView(anchor, { pinData: { pannable: true, dataview: true, dataannos: true } }, this.rootDoc); addAsAnnotation && this.addDocument(anchor); return anchor; } return this.rootDoc; }; componentDidMount() { this._disposers.sizer = reaction( () => ({ forceFull: this.props.renderDepth < 1 || this.layoutDoc._showFullRes, scrSize: (this.props.ScreenToLocalTransform().inverse().transformDirection(this.nativeSize.nativeWidth, this.nativeSize.nativeHeight)[0] / this.nativeSize.nativeWidth) * NumCast(this.rootDoc._viewScale, 1), selected: this.props.isSelected(), }), ({ forceFull, scrSize, selected }) => (this._curSuffix = selected ? '_o' : this.fieldKey === 'icon' ? '_m' : forceFull ? '_o' : scrSize < 0.25 ? '_s' : scrSize < 0.5 ? '_m' : scrSize < 0.8 ? '_l' : '_o'), { fireImmediately: true, delay: 1000 } ); const layoutDoc = this.layoutDoc; this._disposers.path = reaction( () => ({ nativeSize: this.nativeSize, width: this.layoutDoc[WidthSym]() }), ({ nativeSize, width }) => { if (layoutDoc === this.layoutDoc || !this.layoutDoc._height) { this.layoutDoc._height = (width * nativeSize.nativeHeight) / nativeSize.nativeWidth; } }, { fireImmediately: true } ); this._disposers.scroll = reaction( () => this.layoutDoc._scrollTop, s_top => { this._forcedScroll = true; !this._ignoreScroll && this._mainCont.current && (this._mainCont.current.scrollTop = NumCast(s_top)); this._mainCont.current?.scrollTo({ top: NumCast(s_top) }); this._forcedScroll = false; }, { fireImmediately: true } ); } componentWillUnmount() { Object.values(this._disposers).forEach(disposer => disposer?.()); } @undoBatch @action drop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData) { if (de.metaKey) { de.complete.docDragData.droppedDocuments.forEach( action((drop: Doc) => { Doc.AddDocToList(this.dataDoc, this.fieldKey + '-alternates', drop); e.stopPropagation(); }) ); } else if (de.altKey || !this.dataDoc[this.fieldKey]) { const layoutDoc = de.complete.docDragData?.draggedDocuments[0]; const targetField = Doc.LayoutFieldKey(layoutDoc); const targetDoc = layoutDoc[DataSym]; if (targetDoc[targetField] instanceof ImageField) { this.dataDoc[this.fieldKey] = ObjectField.MakeCopy(targetDoc[targetField] as ImageField); Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(targetDoc), this.fieldKey); Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(targetDoc), this.fieldKey); e.stopPropagation(); } } } }; @undoBatch resolution = () => (this.layoutDoc._showFullRes = !this.layoutDoc._showFullRes); @undoBatch setUseAlt = () => (this.layoutDoc[this.fieldKey + '-useAlt'] = !this.layoutDoc[this.fieldKey + '-useAlt']); @undoBatch setNativeSize = action(() => { const scaling = (this.props.DocumentView?.().props.ScreenToLocalTransform().Scale || 1) / NumCast(this.rootDoc._viewScale, 1); const nscale = NumCast(this.props.PanelWidth()) / scaling; const nh = nscale / NumCast(this.dataDoc[this.fieldKey + '-nativeHeight']); const nw = nscale / NumCast(this.dataDoc[this.fieldKey + '-nativeWidth']); this.dataDoc[this.fieldKey + '-nativeHeight'] = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight']) * nh; this.dataDoc[this.fieldKey + '-nativeWidth'] = NumCast(this.dataDoc[this.fieldKey + '-nativeWidth']) * nw; this.rootDoc._panX = nh * NumCast(this.rootDoc._panX); this.rootDoc._panY = nw * NumCast(this.rootDoc._panY); this.dataDoc._panXMax = this.dataDoc._panXMax ? nh * NumCast(this.dataDoc._panXMax) : undefined; this.dataDoc._panXMin = this.dataDoc._panXMin ? nh * NumCast(this.dataDoc._panXMin) : undefined; this.dataDoc._panYMax = this.dataDoc._panYMax ? nw * NumCast(this.dataDoc._panYMax) : undefined; this.dataDoc._panYMin = this.dataDoc._panYMin ? nw * NumCast(this.dataDoc._panYMin) : undefined; }); @undoBatch rotate = action(() => { const nw = NumCast(this.dataDoc[this.fieldKey + '-nativeWidth']); const nh = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight']); const w = this.layoutDoc._width; const h = this.layoutDoc._height; this.dataDoc[this.fieldKey + '-rotation'] = (NumCast(this.dataDoc[this.fieldKey + '-rotation']) + 90) % 360; this.dataDoc[this.fieldKey + '-nativeWidth'] = nh; this.dataDoc[this.fieldKey + '-nativeHeight'] = nw; this.layoutDoc._width = h; this.layoutDoc._height = w; }); crop = (region: Doc | undefined, addCrop?: boolean) => { if (!region) return; const cropping = Doc.MakeCopy(region, true); Doc.GetProto(region).lockedPosition = true; Doc.GetProto(region).title = 'region:' + this.rootDoc.title; Doc.GetProto(region).followLinkToggle = true; this.addDocument(region); const anchx = NumCast(cropping.x); const anchy = NumCast(cropping.y); const anchw = NumCast(cropping._width); const anchh = NumCast(cropping._height); const viewScale = NumCast(this.rootDoc[this.fieldKey + '-nativeWidth']) / anchw; cropping.title = 'crop: ' + this.rootDoc.title; cropping.x = NumCast(this.rootDoc.x) + NumCast(this.rootDoc._width); cropping.y = NumCast(this.rootDoc.y); cropping._width = anchw * (this.props.NativeDimScaling?.() || 1); cropping._height = anchh * (this.props.NativeDimScaling?.() || 1); cropping.isLinkButton = undefined; const croppingProto = Doc.GetProto(cropping); croppingProto.annotationOn = undefined; croppingProto.isPrototype = true; croppingProto.backgroundColor = undefined; croppingProto.proto = Cast(this.rootDoc.proto, Doc, null)?.proto; // set proto of cropping's data doc to be IMAGE_PROTO croppingProto.type = DocumentType.IMG; croppingProto.layout = ImageBox.LayoutString('data'); croppingProto.data = ObjectField.MakeCopy(this.rootDoc[this.fieldKey] as ObjectField); croppingProto['data-nativeWidth'] = anchw; croppingProto['data-nativeHeight'] = anchh; croppingProto.viewScale = viewScale; croppingProto.viewScaleMin = viewScale; croppingProto.panX = anchx / viewScale; croppingProto.panY = anchy / viewScale; croppingProto.panXMin = anchx / viewScale; croppingProto.panXMax = anchw / viewScale; croppingProto.panYMin = anchy / viewScale; croppingProto.panYMax = anchh / viewScale; if (addCrop) { DocUtils.MakeLink({ doc: region }, { doc: cropping }, 'cropped image', ''); cropping.x = NumCast(this.rootDoc.x) + this.rootDoc[WidthSym](); cropping.y = NumCast(this.rootDoc.y); this.props.addDocTab(cropping, OpenWhere.inParent); } DocumentManager.Instance.AddViewRenderedCb(cropping, dv => setTimeout(() => (dv.ComponentView as ImageBox).setNativeSize(), 200)); this.props.bringToFront(cropping); return cropping; }; specificContextMenu = (e: React.MouseEvent): void => { const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (field) { const funcs: ContextMenuProps[] = []; funcs.push({ description: 'Rotate Clockwise 90', event: this.rotate, icon: 'expand-arrows-alt' }); funcs.push({ description: `Show ${this.layoutDoc._showFullRes ? 'Dynamic Res' : 'Full Res'}`, event: this.resolution, icon: 'expand-arrows-alt' }); funcs.push({ description: 'Set Native Pixel Size', event: this.setNativeSize, icon: 'expand-arrows-alt' }); funcs.push({ description: `${this.layoutDoc[this.fieldKey + '-useAlt'] ? 'Show Alternate' : 'Show Primary'}`, event: this.setUseAlt, icon: 'expand-arrows-alt' }); funcs.push({ description: 'Copy path', event: () => Utils.CopyText(this.choosePath(field.url)), icon: 'expand-arrows-alt' }); if (!Doc.noviceMode) { funcs.push({ description: 'Export to Google Photos', event: () => GooglePhotos.Transactions.UploadImages([this.props.Document]), icon: 'caret-square-right' }); const existingAnalyze = ContextMenu.Instance?.findByDescription('Analyzers...'); const modes: ContextMenuProps[] = existingAnalyze && 'subitems' in existingAnalyze ? existingAnalyze.subitems : []; modes.push({ description: 'Generate Tags', event: this.generateMetadata, icon: 'tag' }); modes.push({ description: 'Find Faces', event: this.extractFaces, icon: 'camera' }); //modes.push({ description: "Recommend", event: this.extractText, icon: "brain" }); !existingAnalyze && ContextMenu.Instance?.addItem({ description: 'Analyzers...', subitems: modes, icon: 'hand-point-right' }); } ContextMenu.Instance?.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' }); } }; extractFaces = () => { const converter = (results: any) => { return results.map((face: CognitiveServices.Image.Face) => Doc.Get.FromJson({ data: face, title: `Face: ${face.faceId}` })!); }; this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.fieldKey + '-faces'], this.url, Service.Face, converter); }; generateMetadata = (threshold: Confidence = Confidence.Excellent) => { const converter = (results: any) => { const tagDoc = new Doc(); const tagsList = new List(); results.tags.map((tag: Tag) => { tagsList.push(tag.name); const sanitized = tag.name.replace(' ', '_'); tagDoc[sanitized] = ComputedField.MakeFunction(`(${tag.confidence} >= this.confidence) ? ${tag.confidence} : "${ComputedField.undefined}"`); }); this.dataDoc[this.fieldKey + '-generatedTags'] = tagsList; tagDoc.title = 'Generated Tags Doc'; tagDoc.confidence = threshold; return tagDoc; }; this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.fieldKey + '-generatedTagsDoc'], this.url, Service.ComputerVision, converter); }; @computed private get url() { const data = Cast(this.dataDoc[this.fieldKey], ImageField); return data ? data.url.href : undefined; } choosePath(url: URL) { const lower = url.href.toLowerCase(); if (url.protocol === 'data') return url.href; if (url.href.indexOf(window.location.origin) === -1) return Utils.CorsProxy(url.href); if (!/\.(png|jpg|jpeg|gif|webp)$/.test(lower)) return `/assets/unknown-file-icon-hi.png`; const ext = extname(url.href); return url.href.replace(ext, this._curSuffix + ext); } considerGooglePhotosLink = () => { const remoteUrl = this.dataDoc.googlePhotosUrl; return !remoteUrl ? null : window.open(remoteUrl)} />; }; considerGooglePhotosTags = () => { const tags = this.dataDoc.googlePhotosTags; return !tags ? null : ; }; getScrollHeight = () => (this.props.fitWidth?.(this.rootDoc) !== false && NumCast(this.rootDoc._viewScale, 1) === NumCast(this.rootDoc._viewScaleMin, 1) ? this.nativeSize.nativeHeight : undefined); @computed private get considerDownloadIcon() { const data = this.dataDoc[this.fieldKey]; if (!(data instanceof ImageField)) { return null; } const primary = data.url.href; if (primary.includes(window.location.origin)) { return null; } return ( { const { dataDoc } = this; const { success, failure, idle, loading } = uploadIcons; runInAction(() => (this._uploadIcon = loading)); const [{ accessPaths }] = await Networking.PostToServer('/uploadRemoteImage', { sources: [primary] }); dataDoc[this.props.fieldKey + '-originalUrl'] = primary; let succeeded = true; let data: ImageField | undefined; try { data = new ImageField(accessPaths.agnostic.client); } catch { succeeded = false; } runInAction(() => (this._uploadIcon = succeeded ? success : failure)); setTimeout( action(() => { this._uploadIcon = idle; data && (dataDoc[this.fieldKey] = data); }), 2000 ); }} /> ); } @computed get nativeSize() { TraceMobx(); const nativeWidth = NumCast(this.dataDoc[this.fieldKey + '-nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '-nativeWidth'], 500)); const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '-nativeHeight'], 1)); const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '-nativeOrientation'], 1); return { nativeWidth, nativeHeight, nativeOrientation }; } @computed get paths() { const field = Cast(this.dataDoc[this.fieldKey], ImageField, null); // retrieve the primary image URL that is being rendered from the data doc const alts = DocListCast(this.dataDoc[this.fieldKey + '-alternates']); // retrieve alternate documents that may be rendered as alternate images const altpaths = alts .map(doc => Cast(doc[Doc.LayoutFieldKey(doc)], ImageField, null)?.url) .filter(url => url) .map(url => this.choosePath(url)); // access the primary layout data of the alternate documents const paths = field ? [this.choosePath(field.url), ...altpaths] : altpaths; return paths.length ? paths : [Utils.CorsProxy('http://www.cs.brown.edu/~bcz/noImage.png')]; } @computed get content() { TraceMobx(); const col = DashColor(this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.BackgroundColor)); const srcpath = this.layoutDoc.hideImage ? '' : this.paths[0]; const fadepath = this.layoutDoc.hideImage ? '' : this.paths.lastElement(); const { nativeWidth, nativeHeight, nativeOrientation } = this.nativeSize; const rotation = NumCast(this.dataDoc[this.fieldKey + '-rotation']); const aspect = rotation % 180 ? nativeHeight / nativeWidth : 1; let transformOrigin = 'center center'; let transform = `translate(0%, 0%) rotate(${rotation}deg) scale(${aspect})`; if (rotation === 90 || rotation === -270) { transformOrigin = 'top left'; transform = `translate(100%, 0%) rotate(${rotation}deg) scale(${aspect})`; } else if (rotation === 180) { transform = `rotate(${rotation}deg) scale(${aspect})`; } else if (rotation === 270 || rotation === -90) { transformOrigin = 'right top'; transform = `translate(-100%, 0%) rotate(${rotation}deg) scale(${aspect})`; } return (
{fadepath === srcpath ? null : (
)}
{!Doc.noviceMode && this.considerDownloadIcon} {this.considerGooglePhotosLink()}
); } contentFunc = () => [this.content]; private _mainCont: React.RefObject = React.createRef(); private _annotationLayer: React.RefObject = React.createRef(); @observable _marqueeing: number[] | undefined; @observable _savedAnnotations = new ObservableMap(); @computed get annotationLayer() { TraceMobx(); return
; } screenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(0, NumCast(this.layoutDoc._scrollTop) * this.props.ScreenToLocalTransform().Scale); marqueeDown = (e: React.PointerEvent) => { if (!e.altKey && e.button === 0 && NumCast(this.rootDoc._viewScale, 1) <= NumCast(this.rootDoc.viewScaleMin, 1) && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(Doc.ActiveTool)) { 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 = () => { this._getAnchor = AnchorMenu.Instance?.GetAnchor; this._marqueeing = undefined; this.props.select(false); }; savedAnnotations = () => this._savedAnnotations; render() { TraceMobx(); const borderRad = this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding); const borderRadius = borderRad?.includes('px') ? `${Number(borderRad.split('px')[0]) / (this.props.NativeDimScaling?.() || 1)}px` : borderRad; return (
{ if (!this._forcedScroll) { if (this.layoutDoc._scrollTop || this._mainCont.current?.scrollTop) { this._ignoreScroll = true; this.layoutDoc._scrollTop = this._mainCont.current?.scrollTop; this._ignoreScroll = false; } } })} style={{ width: this.props.PanelWidth() ? undefined : `100%`, height: this.props.PanelWidth() ? undefined : `100%`, pointerEvents: this.layoutDoc._lockedPosition ? 'none' : undefined, borderRadius, overflow: this.layoutDoc.fitWidth || this.props.fitWidth?.(this.rootDoc) ? 'auto' : undefined, }}> {this.contentFunc} {this.annotationLayer} {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? null : ( )}
); } }