import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { Colors } from 'browndash-components'; import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction } from 'mobx'; import { observer } from 'mobx-react'; import { extname } from 'path'; import * as React from 'react'; import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents } from '../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; import { ObjectField } from '../../../fields/ObjectField'; import { Cast, ImageCast, NumCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { DocUtils } from '../../documents/DocUtils'; import { Networking } from '../../Network'; import { DragManager } from '../../util/DragManager'; import { undoBatch } from '../../util/UndoManager'; import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { MarqueeAnnotator } from '../MarqueeAnnotator'; import { OverlayView } from '../OverlayView'; import { AnchorMenu } from '../pdf/AnchorMenu'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; export class ImageEditorData { // eslint-disable-next-line no-use-before-define private static _instance: ImageEditorData; private static get imageData() { return (ImageEditorData._instance ?? new ImageEditorData()).imageData; } // prettier-ignore @observable imageData: { rootDoc: Doc | undefined; open: boolean; source: string; addDoc: Opt<(doc: Doc | Doc[], annotationKey?: string) => boolean> } = observable({ rootDoc: undefined, open: false, source: '', addDoc: undefined }); @action private static set = (open: boolean, rootDoc: Doc | undefined, source: string, addDoc: Opt<(doc: Doc | Doc[], annotationKey?: string) => boolean>) => { this._instance.imageData = { open, rootDoc, source, addDoc }; }; constructor() { makeObservable(this); ImageEditorData._instance = this; } public static get Open() { return ImageEditorData.imageData.open; } // prettier-ignore public static set Open(open: boolean) { ImageEditorData.set(open, this.imageData.rootDoc, this.imageData.source, this.imageData.addDoc); } // prettier-ignore public static get Source() { return ImageEditorData.imageData.source; } // prettier-ignore public static set Source(source: string) { ImageEditorData.set(this.imageData.open, this.imageData.rootDoc, source, this.imageData.addDoc); } // prettier-ignore public static get RootDoc() { return ImageEditorData.imageData.rootDoc; } // prettier-ignore public static set RootDoc(rootDoc: Opt) { ImageEditorData.set(this.imageData.open, rootDoc, this.imageData.source, this.imageData.addDoc); } // prettier-ignore public static get AddDoc() { return ImageEditorData.imageData.addDoc; } // prettier-ignore public static set AddDoc(addDoc: Opt<(doc: Doc | Doc[], annotationKey?: string) => boolean>) { ImageEditorData.set(this.imageData.open, this.imageData.rootDoc, this.imageData.source, addDoc); } // prettier-ignore } @observer export class ImageBox extends ViewBoxAnnotatableComponent() { 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; private _overlayIconRef = React.createRef(); private _marqueeref = React.createRef(); private _mainCont: React.RefObject = React.createRef(); private _annotationLayer: React.RefObject = React.createRef(); @observable _savedAnnotations = new ObservableMap(); @observable _curSuffix = ''; @observable _error = ''; @observable _isHovering = false; // flag to switch between primary and alternate images on hover _ffref = React.createRef(); constructor(props: FieldViewProps) { super(props); makeObservable(this); this._props.setContentViewBox?.(this); } protected createDropTarget = (ele: HTMLDivElement) => { this._dropDisposer?.(); ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document)); }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor const anchor = visibleAnchor ?? Docs.Create.ConfigDocument({ title: 'ImgAnchor:' + this.Document.title, config_panX: NumCast(this.layoutDoc._freeform_panX), config_panY: NumCast(this.layoutDoc._freeform_panY), config_viewScale: Cast(this.layoutDoc._freeform_scale, 'number', null), annotationOn: this.Document, }); if (anchor) { if (!addAsAnnotation) anchor.backgroundColor = 'transparent'; addAsAnnotation && this.addDocument(anchor); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), pannable: !visibleAnchor } }, this.Document); return anchor; } return this.Document; }; componentDidMount() { this._disposers.sizer = reaction( () => ({ forceFull: this._props.renderDepth < 1 || this.layoutDoc._showFullRes, scrSize: (this.ScreenToLocalBoxXf().inverse().transformDirection(this.nativeSize.nativeWidth, this.nativeSize.nativeHeight)[0] / this.nativeSize.nativeWidth) * NumCast(this.layoutDoc._freeform_scale, 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; this._disposers.path = reaction( () => ({ nativeSize: this.nativeSize, width: NumCast(this.layoutDoc._width) }), ({ 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.layout_scrollTop, sTop => { this._forcedScroll = true; !this._ignoreScroll && this._mainCont.current && (this._mainCont.current.scrollTop = NumCast(sTop)); this._mainCont.current?.scrollTo({ top: NumCast(sTop) }); this._forcedScroll = false; }, { fireImmediately: true } ); } componentWillUnmount() { Object.values(this._disposers).forEach(disposer => disposer?.()); } @undoBatch drop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData) { let added: boolean | undefined; const targetIsBullseye = (ele: HTMLElement): boolean => { if (!ele) return false; if (ele === this._overlayIconRef.current) return true; return targetIsBullseye(ele.parentElement as HTMLElement); }; if (de.metaKey || targetIsBullseye(e.target as HTMLElement)) { added = de.complete.docDragData.droppedDocuments.reduce((last: boolean, drop: Doc) => { this.layoutDoc[this.fieldKey + '_usePath'] = 'alternate:hover'; return last && Doc.AddDocToList(this.dataDoc, this.fieldKey + '_alternates', drop); }, true); } else if (de.altKey || !this.dataDoc[this.fieldKey]) { const layoutDoc = de.complete.docDragData?.draggedDocuments[0]; const targetField = Doc.LayoutFieldKey(layoutDoc); const targetDoc = layoutDoc[DocData]; if (targetDoc[targetField] instanceof ImageField) { added = true; 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); } } added === false && e.preventDefault(); added !== undefined && e.stopPropagation(); return added; } return false; }; @undoBatch resolution = () => { this.layoutDoc._showFullRes = !this.layoutDoc._showFullRes; }; @undoBatch setNativeSize = action(() => { const nscale = NumCast(this._props.PanelWidth()) * NumCast(this.layoutDoc._freeform_scale, 1); const nw = nscale / NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']); this.dataDoc[this.fieldKey + '_nativeHeight'] = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight']) * nw; this.dataDoc[this.fieldKey + '_nativeWidth'] = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']) * nw; this.dataDoc._freeform_panX = nw * NumCast(this.dataDoc._freeform_panX); this.dataDoc._freeform_panY = nw * NumCast(this.dataDoc._freeform_panY); this.dataDoc._freeform_panX_max = this.dataDoc._freeform_panX_max ? nw * NumCast(this.dataDoc._freeform_panX_max) : undefined; this.dataDoc._freeform_panX_min = this.dataDoc._freeform_panX_min ? nw * NumCast(this.dataDoc._freeform_panX_min) : undefined; this.dataDoc._freeform_panY_max = this.dataDoc._freeform_panY_max ? nw * NumCast(this.dataDoc._freeform_panY_max) : undefined; this.dataDoc._freeform_panY_min = this.dataDoc._freeform_panY_min ? nw * NumCast(this.dataDoc._freeform_panY_min) : 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 undefined; const cropping = Doc.MakeCopy(region, true); const regionData = region[DocData]; regionData.lockedPosition = true; regionData.title = 'region:' + this.Document.title; regionData.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.dataDoc[this.fieldKey + '_nativeWidth']) / anchw; cropping.title = 'crop: ' + this.Document.title; cropping.x = NumCast(this.Document.x) + NumCast(this.layoutDoc._width); cropping.y = NumCast(this.Document.y); cropping._width = anchw * (this._props.NativeDimScaling?.() || 1); cropping._height = anchh * (this._props.NativeDimScaling?.() || 1); cropping.onClick = undefined; const croppingProto = cropping[DocData]; croppingProto.annotationOn = undefined; croppingProto.isDataDoc = true; croppingProto.backgroundColor = undefined; croppingProto.proto = Cast(this.Document.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.dataDoc[this.fieldKey] as ObjectField); croppingProto.data_nativeWidth = anchw; croppingProto.data_nativeHeight = anchh; croppingProto.freeform_scale = viewScale; croppingProto.freeform_scale_min = viewScale; croppingProto.freeform_panX = anchx / viewScale; croppingProto.freeform_panY = anchy / viewScale; croppingProto.freeform_panX_min = anchx / viewScale; croppingProto.freeform_panX_max = anchw / viewScale; croppingProto.freeform_panY_min = anchy / viewScale; croppingProto.freeform_panY_max = anchh / viewScale; if (addCrop) { DocUtils.MakeLink(region, cropping, { link_relationship: 'cropped image' }); cropping.x = NumCast(this.Document.x) + NumCast(this.layoutDoc._width); cropping.y = NumCast(this.Document.y); this._props.addDocTab(cropping, OpenWhere.inParent); } DocumentView.addViewRenderedCb(cropping, dv => setTimeout(() => (dv.ComponentView as ImageBox).setNativeSize(), 200)); this._props.bringToFront?.(cropping); return cropping; }; specificContextMenu = (): void => { const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (field) { const funcs: ContextMenuProps[] = []; funcs.push({ description: 'Rotate Clockwise 90', event: this.rotate, icon: 'redo-alt' }); funcs.push({ description: `Show ${this.layoutDoc._showFullRes ? 'Dynamic Res' : 'Full Res'}`, event: this.resolution, icon: 'expand' }); funcs.push({ description: 'Set Native Pixel Size', event: this.setNativeSize, icon: 'expand-arrows-alt' }); funcs.push({ description: 'Copy path', event: () => ClientUtils.CopyText(this.choosePath(field.url)), icon: 'copy' }); funcs.push({ description: 'Open Image Editor', event: action(() => { ImageEditorData.Open = true; ImageEditorData.Source = this.choosePath(field.url); ImageEditorData.AddDoc = this._props.addDocument; ImageEditorData.RootDoc = this.Document; }), icon: 'pencil-alt', }); ContextMenu.Instance?.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' }); } }; choosePath = (url: URL) => { if (!url?.href) return ''; const lower = url.href.toLowerCase(); if (url.protocol === 'data') return url.href; if (url.href.indexOf(window.location.origin) === -1 && url.href.indexOf('dashblobstore') === -1) return ClientUtils.CorsProxy(url.href); if (!/\.(png|jpg|jpeg|gif|webp)$/.test(lower) || lower.endsWith('/assets/unknown-file-icon-hi.png')) return `/assets/unknown-file-icon-hi.png`; const ext = extname(url.href); return url.href.replace(ext, (this._error ? '_o' : this._curSuffix) + ext); }; getScrollHeight = () => (this._props.fitWidth?.(this.Document) !== false && NumCast(this.layoutDoc._freeform_scale, 1) === NumCast(this.dataDoc._freeform_scaleMin, 1) ? this.nativeSize.nativeHeight : undefined); @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'], 500)); const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '_nativeOrientation'], 1); return { nativeWidth, nativeHeight, nativeOrientation }; } @computed get overlayImageIcon() { const usePath = this.layoutDoc[`_${this.fieldKey}_usePath`]; return ( toggle between primary, alternate, and show alternate on hover }>
setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => { this.layoutDoc[`_${this.fieldKey}_usePath`] = usePath === undefined ? 'alternate' : usePath === 'alternate' ? 'alternate:hover' : undefined; }) } style={{ display: (this._props.isContentActive() !== false && DragManager.DocDragData?.canEmbed) || this.dataDoc[this.fieldKey + '_alternates'] ? 'block' : 'none', width: 'min(10%, 25px)', height: 'min(10%, 25px)', background: usePath === undefined ? 'white' : usePath === 'alternate' ? 'black' : 'gray', color: usePath === undefined ? 'black' : 'white', }}>
); } @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 defaultUrl = new URL(ClientUtils.prepend('/assets/unknown-file-icon-hi.png')); const altpaths = alts ?.map(doc => (doc instanceof Doc ? ImageCast(doc[Doc.LayoutFieldKey(doc)])?.url ?? defaultUrl : defaultUrl)) .filter(url => url) .map(url => this.choosePath(url)) ?? []; // acc ess the primary layout data of the alternate documents const paths = field ? [this.choosePath(field.url), ...altpaths] : altpaths; return paths.length ? paths : [defaultUrl.href]; } @computed get content() { TraceMobx(); const backColor = DashColor(this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) ?? Colors.WHITE); const backAlpha = backColor.red() === 0 && backColor.green() === 0 && backColor.blue() === 0 ? backColor.alpha() : 1; 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})`; } const usePath = this.layoutDoc[`_${this.fieldKey}_usePath`]; return (
{ this._isHovering = true; })} onPointerLeave={action(() => { this._isHovering = false; })} key={this.layoutDoc[Id]} ref={this.createDropTarget} onPointerDown={this.marqueeDown}>
{ this._error = e.toString(); })} draggable={false} width={nativeWidth} /> {fadepath === srcpath ? null : (
)}
{this.overlayImageIcon}
); } @computed get annotationLayer() { TraceMobx(); return
; } screenToLocalTransform = () => this.ScreenToLocalBoxXf().translate(0, NumCast(this.layoutDoc._layout_scrollTop) * this.ScreenToLocalBoxXf().Scale); marqueeDown = (e: React.PointerEvent) => { if (!this.dataDoc[this.fieldKey]) { this.chooseImage(); } else if ( !e.altKey && e.button === 0 && NumCast(this.layoutDoc._freeform_scale, 1) <= NumCast(this.dataDoc.freeform_scaleMin, 1) && this._props.isContentActive() && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(Doc.ActiveTool) ) { setupMoveUpEvents( this, e, action(moveEv => { MarqueeAnnotator.clearAnnotations(this._savedAnnotations); this._marqueeref.current?.onInitiateSelection([moveEv.clientX, moveEv.clientY]); return true; }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false ); } }; @action finishMarquee = () => { this._getAnchor = AnchorMenu.Instance?.GetAnchor; this._marqueeref.current?.onTerminateSelection(); this._props.select(false); }; focus = (anchor: Doc, options: FocusViewOptions) => (anchor.type === DocumentType.CONFIG ? undefined : this._ffref.current?.focus(anchor, options)); renderedPixelDimensions = async () => { const { nativeWidth: width, nativeHeight: height } = await Networking.PostToServer('/inspectImage', { source: this.paths[0] }); return { width, height }; }; 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._layout_scrollTop || this._mainCont.current?.scrollTop) { this._ignoreScroll = true; this.layoutDoc._layout_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.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : undefined, }}> {this.content} {this.annotationLayer} {!this._mainCont.current || !this.DocumentView || !this._annotationLayer.current ? null : ( )}
); } public chooseImage = () => { const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.accept = 'image/*'; input.onchange = async () => { const file = input.files?.[0]; if (file) { const disposer = OverlayView.ShowSpinner(); const [{ result }] = await Networking.UploadFilesToServer({ file }); if (result instanceof Error) { alert('Error uploading files - possibly due to unsupported file types'); } else { this.dataDoc[this.fieldKey] = new ImageField(result.accessPaths.agnostic.client); !(result instanceof Error) && DocUtils.assignImageInfo(result, this.dataDoc); } disposer(); } else { console.log('No file selected'); } }; input.click(); }; } Docs.Prototypes.TemplateMap.set(DocumentType.IMG, { layout: { view: ImageBox, dataField: 'data' }, options: { acl: '', freeform: '', systemIcon: 'BsFileEarmarkImageFill' }, });