diff options
Diffstat (limited to 'src')
34 files changed, 1011 insertions, 311 deletions
diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index dba7ff907..161dff6e0 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -2,17 +2,17 @@ export enum DocumentType { NONE = "none", // core data types - RTF = "rtf", // rich text - IMG = "image", // image box - WEB = "web", // web page or html clipping - COL = "collection", // collection - KVP = "kvp", // key value pane - VID = "video", // video - AUDIO = "audio", // audio - PDF = "pdf", // pdf - INK = "inks", // ink stroke - SCREENSHOT = "screenshot", // view of a desktop application - FONTICON = "fonticonbox", // font icon + RTF = "rtf", + IMG = "image", + WEB = "web", + COL = "collection", + KVP = "kvp", + VID = "video", + AUDIO = "audio", + PDF = "pdf", + INK = "inks", + SCREENSHOT = "screenshot", + FONTICON = "fonticonbox", FILTER = "filter", SEARCH = "search", // search query LABEL = "label", // simple text label @@ -23,19 +23,20 @@ export enum DocumentType { SCRIPTING = "script", // script editor EQUATION = "equation", // equation editor FUNCPLOT = "funcplot", // function plotter + MAP = "map", // special purpose wrappers that either take no data or are compositions of lower level types - LINK = "link", // link (view of a document that acts as a link) - LINKANCHOR = "linkanchor", // blue dot link anchor (view of a link document's anchor) - IMPORT = "import", // directory import box (file system directory) - SLIDER = "slider", // number slider (view of a number) - PRES = "presentation", // presentation (view of a collection) --- shouldn't this be a view type? technically requires a special view in which documents must have their aliasOf fields filled in - PRESELEMENT = "preselement",// presentation item (view of a document in a collection) - COLOR = "color", // color picker (view of a color picker for a color string) - YOUTUBE = "youtube", // youtube directory (view of you tube search results) + LINK = "link", + LINKANCHOR = "linkanchor", + IMPORT = "import", + SLIDER = "slider", + PRES = "presentation", + PRESELEMENT = "preselement", + COLOR = "color", + YOUTUBE = "youtube", SEARCHITEM = "searchitem", - COMPARISON = "comparison", // before/after view with slider (view of 2 images) - GROUP = "group", // group of users + COMPARISON = "comparison", + GROUP = "group", LINKDB = "linkdb", // database of links ??? why do we have this SCRIPTDB = "scriptdb", // database of scripts diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index f50f306a3..369876428 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -11,7 +11,7 @@ import { RichTextField } from "../../fields/RichTextField"; import { SchemaHeaderField } from "../../fields/SchemaHeaderField"; import { ComputedField, ScriptField } from "../../fields/ScriptField"; import { Cast, NumCast, StrCast } from "../../fields/Types"; -import { AudioField, ImageField, PdfField, VideoField, WebField, YoutubeField } from "../../fields/URLField"; +import { AudioField, ImageField, MapField, PdfField, VideoField, WebField, YoutubeField } from "../../fields/URLField"; import { SharingPermissions } from "../../fields/util"; import { Upload } from "../../server/SharedMediaTypes"; import { OmitKeys, Utils } from "../../Utils"; @@ -60,6 +60,7 @@ import { SearchBox } from "../views/search/SearchBox"; import { DashWebRTCVideo } from "../views/webcam/DashWebRTCVideo"; import { DocumentType } from "./DocumentTypes"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { MapBox } from "../views/nodes/MapBox/MapBox"; const path = require('path'); const defaultNativeImageDim = Number(DFLT_IMAGE_NATIVE_DIM.replace("px", "")); @@ -155,6 +156,8 @@ export class DocumentOptions { x?: number; y?: number; z?: number; // whether document is in overlay (1) or not (0 or undefined) + lat?: number; + lng?: number; author?: string; _layoutKey?: string; unrendered?: boolean; // denotes an annotation that is not rendered with a DocumentView (e.g, rtf/pdf text selections and links to scroll locations in web/pdf) @@ -394,6 +397,10 @@ export namespace Docs { layout: { view: PDFBox, dataField: defaultDataKey }, options: { _curPage: 1, _fitWidth: true, links: ComputedField.MakeFunction("links(self)") as any } }], + [DocumentType.MAP, { + layout: { view: MapBox, dataField: defaultDataKey }, + options: { _height: 600, _width: 800, links: ComputedField.MakeFunction("links(self)") as any } + }], [DocumentType.IMPORT, { layout: { view: DirectoryImportBox, dataField: defaultDataKey }, options: { _height: 150 } @@ -761,6 +768,14 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.WEB), new HtmlField(html), options); } + export function MapDocument(documents: Array<Doc>, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.MAP), new List(documents), options); + } + + export function MapMarkerDocument(lat: number, lng: number, documents: Array<Doc>, options: DocumentOptions, id?: string) { + return InstanceFromProto(Prototypes.get(DocumentType.MARKER), new List(documents), { lat, lng, ...options }, id); + } + export function KVPDocument(document: Doc, options: DocumentOptions = {}) { return InstanceFromProto(Prototypes.get(DocumentType.KVP), document, { title: document.title + ".kvp", ...options }); } @@ -791,7 +806,7 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _viewType: CollectionViewType.Linear }, id); } - export function MapDocument(documents: Array<Doc>, options: DocumentOptions = {}) { + export function MapCollectionDocument(documents: Array<Doc>, options: DocumentOptions = {}) { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _viewType: CollectionViewType.Map }); } @@ -1121,7 +1136,11 @@ export namespace DocUtils { } else if (field instanceof List && field[0] instanceof Doc) { created = Docs.Create.StackingDocument(DocListCast(field), resolved); layout = CollectionView.LayoutString; - } else { + } else if (field instanceof MapField) { + created = Docs.Create.MapDocument(DocListCast(field), resolved); + layout = MapBox.LayoutString; + } + else { created = Docs.Create.TextDocument("", { ...{ _width: 200, _height: 25, _autoHeight: true }, ...resolved }); layout = FormattedTextBox.LayoutString; } @@ -1152,6 +1171,12 @@ export namespace DocUtils { if (!options._width) options._width = 400; if (!options._height) options._height = (options._width as number) * 1200 / 927; } + //TODO:al+glr + // if (type.indexOf("map") !== -1) { + // ctor = Docs.Create.MapDocument; + // if (!options._width) options._width = 800; + // if (!options._height) options._height = (options._width as number) * 3 / 4; + // } if (type.indexOf("html") !== -1) { if (path.includes(window.location.hostname)) { const s = path.split('/'); @@ -1358,6 +1383,25 @@ export namespace DocUtils { return optionsCollection; } + /** + * + * @param dms Degree Minute Second format exif gps data + * @param ref ref that determines negativity of decimal coordinates + * @returns a decimal format of gps latitude / longitude + */ + function getDecimalfromDMS(dms?: number[], ref?: string) { + if (dms && ref) { + let degrees = dms[0] / dms[1]; + let minutes = dms[2] / dms[3] / 60.0; + let seconds = dms[4] / dms[5] / 3600.0; + + if (['S', 'W'].includes(ref)) { + degrees = -degrees; minutes = -minutes; seconds = -seconds + } + return (degrees + minutes + seconds).toFixed(5); + } + } + async function processFileupload(generatedDocuments: Doc[], name: string, type: string, result: Error | Upload.FileInformation, options: DocumentOptions) { if (result instanceof Error) { alert(`Upload failed: ${result.message}`); @@ -1380,6 +1424,9 @@ export namespace DocUtils { proto["data-nativeWidth"] = (result.nativeWidth < result.nativeHeight) ? maxNativeDim : maxNativeDim / (result.nativeWidth / result.nativeHeight); } proto.contentSize = result.contentSize; + // exif gps data coordinates are stored in DMS (Degrees Minutes Seconds), the following operation converts that to decimal coordinates + proto.lat = getDecimalfromDMS(result.exifData?.data?.gps?.GPSLatitude, result.exifData?.data?.gps?.GPSLatitudeRef); + proto.lng = getDecimalfromDMS(result.exifData?.data?.gps?.GPSLongitude, result.exifData?.data?.gps?.GPSLongitudeRef); } generatedDocuments.push(doc); } diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index d5dc9e2be..f40cae676 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -503,6 +503,10 @@ export class CurrentUserUtils { if (doc.emptyWebpage === undefined) { doc.emptyWebpage = Docs.Create.WebDocument("http://www.bing.com/", { title: "webpage", _nativeWidth: 850, _height: 512, _width: 400, useCors: true, system: true, cloneFieldFilter: new List<string>(["system"]) }); } + if (doc.emptyMap === undefined) { + doc.emptyMap = Docs.Create.MapDocument([], { title: "map", _showSidebar: true, _width: 800, _height: 600, system: true, cloneFieldFilter: new List<string>(["system"]) }); + ((doc.emptyMap as Doc).proto as Doc)["dragFactory-count"] = 0; + } if (doc.activeMobileMenu === undefined) { this.setupActiveMobileMenu(doc); } @@ -522,6 +526,7 @@ export class CurrentUserUtils { { toolTip: "Tap to create a mobile view in a new pane, drag for a mobile view", title: "Phone", icon: "mobile", click: 'openOnRight(Doc.UserDoc().activeMobileMenu)', drag: 'this.dragFactory', dragFactory: doc.activeMobileMenu as Doc }, { toolTip: "Tap to create a custom header note document, drag for a custom header note", title: "Custom", icon: "window-maximize", click: 'openOnRight(delegateDragFactory(this.dragFactory))', drag: 'delegateDragFactory(this.dragFactory)', dragFactory: doc.emptyHeader as Doc }, { toolTip: "Toggle a Calculator REPL", title: "repl", icon: "calculator", click: 'addOverlayWindow("ScriptingRepl", { x: 300, y: 100, width: 200, height: 200, title: "Scripting REPL" })' }, + { toolTip: "Tap to create a map in the new pane, drag for a map", title: "Map", icon: "map-marker-alt", click: 'openOnRight(copyDragFactory(this.dragFactory))', drag: 'copyDragFactory(this.dragFactory)', dragFactory: doc.emptyMap as Doc, noviceMode: true } ]; } diff --git a/src/client/util/type_decls.d b/src/client/util/type_decls.d index ac0bea46a..9063dc894 100644 --- a/src/client/util/type_decls.d +++ b/src/client/util/type_decls.d @@ -208,6 +208,7 @@ declare const Docs: { PdfDocument(url: string, options?: DocumentOptions): Doc; WebDocument(url: string, options?: DocumentOptions): Doc; HtmlDocument(html: string, options?: DocumentOptions): Doc; + MapDocument(url: string, options?: DocumentOptions): Doc; KVPDocument(document: Doc, options?: DocumentOptions): Doc; FreeformDocument(documents: Doc[], options?: DocumentOptions): Doc; SchemaDocument(columns: string[], documents: Doc[], options?: DocumentOptions): Doc; diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 5bda9f6bf..ea48a72b5 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -187,7 +187,7 @@ export class MainView extends React.Component { fa.faArrowAltCircleDown, fa.faArrowAltCircleUp, fa.faArrowAltCircleLeft, fa.faArrowAltCircleRight, fa.faStopCircle, fa.faCheckCircle, fa.faGripVertical, fa.faSortUp, fa.faSortDown, fa.faTable, fa.faTh, fa.faThList, fa.faProjectDiagram, fa.faSignature, fa.faColumns, fa.faChevronCircleUp, fa.faUpload, fa.faBorderAll, fa.faBraille, fa.faChalkboard, fa.faPencilAlt, fa.faEyeSlash, fa.faSmile, fa.faIndent, fa.faOutdent, fa.faChartBar, fa.faBan, fa.faPhoneSlash, fa.faGripLines, - fa.faSave, fa.faBookmark, fa.faList, fa.faListOl, fa.faFolderPlus, fa.faLightbulb, fa.faBookOpen); + fa.faSave, fa.faBookmark, fa.faList, fa.faListOl, fa.faFolderPlus, fa.faLightbulb, fa.faBookOpen, fa.faMapMarkedAlt); this.initAuthenticationRouters(); } diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx index 659eb86d1..b29953b19 100644 --- a/src/client/views/SidebarAnnos.tsx +++ b/src/client/views/SidebarAnnos.tsx @@ -23,6 +23,7 @@ interface ExtraProps { layoutDoc: Doc; rootDoc: Doc; dataDoc: Doc; + // usePanelWidth: boolean; showSidebar: boolean; nativeWidth: number; whenChildContentsActiveChanged: (isActive: boolean) => void; @@ -77,7 +78,10 @@ export class SidebarAnnos extends React.Component<FieldViewProps & ExtraProps> { get sidebarKey() { return this.props.fieldKey + "-sidebar"; } filtersHeight = () => 38; screenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(Doc.NativeWidth(this.props.dataDoc), 0).scale(this.props.scaling?.() || 1); - panelWidth = () => !this.props.showSidebar ? 0 : this.props.layoutDoc.type === DocumentType.RTF ? this.props.PanelWidth() : (NumCast(this.props.nativeWidth) - Doc.NativeWidth(this.props.dataDoc)) * this.props.PanelWidth() / NumCast(this.props.nativeWidth); + // panelWidth = () => !this.props.layoutDoc._showSidebar ? 0 : + // this.props.usePanelWidth ? this.props.PanelWidth() : + // (NumCast(this.props.layoutDoc.nativeWidth) - Doc.NativeWidth(this.props.dataDoc)) * this.props.PanelWidth() / NumCast(this.props.layoutDoc.nativeWidth); + panelWidth = () => !this.props.showSidebar ? 0 : this.props.layoutDoc.type === DocumentType.RTF || this.props.layoutDoc.type === DocumentType.MAP ? this.props.PanelWidth() : (NumCast(this.props.nativeWidth) - Doc.NativeWidth(this.props.dataDoc)) * this.props.PanelWidth() / NumCast(this.props.nativeWidth); panelHeight = () => this.props.PanelHeight() - this.filtersHeight(); addDocument = (doc: Doc | Doc[]) => this.props.sidebarAddDocument(doc, this.sidebarKey); moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => this.props.moveDocument(doc, targetCollection, addDocument, this.sidebarKey); diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 3c88a4830..c5614506b 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -132,6 +132,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps case DocumentType.IMG: case DocumentType.WEB: case DocumentType.PDF: + case DocumentType.MAP: case DocumentType.SCREENSHOT: case DocumentType.VID: docColor = docColor || (darkScheme() ? Colors.DARK_GRAY : Colors.LIGHT_GRAY); break; case DocumentType.COL: diff --git a/src/client/views/animationtimeline/Timeline.tsx b/src/client/views/animationtimeline/Timeline.tsx index 66afad0ac..c7e62c15d 100644 --- a/src/client/views/animationtimeline/Timeline.tsx +++ b/src/client/views/animationtimeline/Timeline.tsx @@ -76,7 +76,7 @@ export class Timeline extends React.Component<FieldViewProps> { */ @computed private get children(): Doc[] { - const annotatedDoc = [DocumentType.IMG, DocumentType.VID, DocumentType.PDF].includes(StrCast(this.props.Document.type) as any); + const annotatedDoc = [DocumentType.IMG, DocumentType.VID, DocumentType.PDF, DocumentType.MAP].includes(StrCast(this.props.Document.type) as any); if (annotatedDoc) { return DocListCast(this.props.Document[Doc.LayoutFieldKey(this.props.Document) + "-annotations"]); } diff --git a/src/client/views/collections/CollectionMapView.tsx b/src/client/views/collections/CollectionMapView.tsx deleted file mode 100644 index 2d7569d45..000000000 --- a/src/client/views/collections/CollectionMapView.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { GoogleApiWrapper, IMapProps, Map as GeoMap, Marker } from "google-maps-react"; -import { action, computed, Lambda, runInAction } from "mobx"; -import { observer } from "mobx-react"; -import { Doc, DocListCast, Field, FieldResult, Opt } from "../../../fields/Doc"; -import { documentSchema } from "../../../fields/documentSchemas"; -import { Id } from "../../../fields/FieldSymbols"; -import { makeInterface } from "../../../fields/Schema"; -import { Cast, NumCast, ScriptCast, StrCast } from "../../../fields/Types"; -import { LinkManager } from "../../util/LinkManager"; -import { undoBatch, UndoManager } from "../../util/UndoManager"; -import "./CollectionMapView.scss"; -import { CollectionSubView } from "./CollectionSubView"; -import React = require("react"); -import requestPromise = require("request-promise"); - -type MapSchema = makeInterface<[typeof documentSchema]>; -const MapSchema = makeInterface(documentSchema); - -export type LocationData = google.maps.LatLngLiteral & { - address?: string - resolvedAddress?: string; - zoom?: number; -}; - -interface DocLatLng { - lat: FieldResult<Field>; - lng: FieldResult<Field>; -} - -// Nowhere, Oklahoma -const defaultLocation = { lat: 35.1592238, lng: -98.444512, zoom: 15 }; -const noResults = "ZERO_RESULTS"; - -const query = async (data: string | google.maps.LatLngLiteral) => { - const contents = typeof data === "string" ? `address=${data.replace(/\s+/g, "+")}` : `latlng=${data.lat},${data.lng}`; - const target = `https://maps.googleapis.com/maps/api/geocode/json?${contents}&key=${process.env.GOOGLE_MAPS_GEO}`; - try { - return JSON.parse(await requestPromise.get(target)); - } catch { - return undefined; - } -}; - -@observer -export class CollectionMapView extends CollectionSubView<MapSchema, Partial<IMapProps> & { google: any }>(MapSchema) { - - private _cancelAddrReq = new Map<string, boolean>(); - private _cancelLocReq = new Map<string, boolean>(); - private _initialLookupPending = new Map<string, boolean>(); - private responders: { locationDisposer: Lambda, addressDisposer: Lambda }[] = []; - - /** - * Note that all the uses of runInAction below are not included - * as a way to update observables (documents handle this already - * in their property setters), but rather to create a single bulk - * update and thus prevent uneeded invocations of the location- - * and address–updating reactions. - */ - - private getLocation = (doc: Opt<Doc>, fieldKey: string, returnDefault: boolean = true): Opt<LocationData> => { - if (doc) { - const titleLoc = StrCast(doc.title).startsWith("@") ? StrCast(doc.title).substring(1) : undefined; - const lat = Cast(doc[`${fieldKey}-lat`], "number", null) || (Cast(doc[`${fieldKey}-lat`], "string", null) && Number(Cast(doc[`${fieldKey}-lat`], "string", null))) || undefined; - const lng = Cast(doc[`${fieldKey}-lng`], "number", null) || (Cast(doc[`${fieldKey}-lng`], "string", null) && Number(Cast(doc[`${fieldKey}-lng`], "string", null))) || undefined; - const zoom = Cast(doc[`${fieldKey}-zoom`], "number", null) || (Cast(doc[`${fieldKey}-zoom`], "string", null) && Number(Cast(doc[`${fieldKey}-zoom`], "string", null))) || undefined; - const address = titleLoc || StrCast(doc[`${fieldKey}-address`], StrCast(doc.title).replace(/^-/, "")); - if (titleLoc || (address && (lat === undefined || lng === undefined))) { - const id = doc[Id]; - if (!this._initialLookupPending.get(id)) { - this._initialLookupPending.set(id, true); - setTimeout(() => { - titleLoc && Doc.SetInPlace(doc, `${fieldKey}-address`, titleLoc, true); - this.respondToAddressChange(doc, fieldKey, address).then(() => this._initialLookupPending.delete(id)); - }); - } - } - return (lat === undefined || lng === undefined) ? (returnDefault ? defaultLocation : undefined) : { lat, lng, zoom }; - } - return undefined; - } - - private markerClick = async (layout: Doc, { lat, lng, zoom }: LocationData) => { - const batch = UndoManager.StartBatch("marker click"); - const { fieldKey } = this.props; - runInAction(() => { - this.layoutDoc[`${fieldKey}-mapCenter-lat`] = lat; - this.layoutDoc[`${fieldKey}-mapCenter-lng`] = lng; - zoom && (this.layoutDoc[`${fieldKey}-mapCenter-zoom`] = zoom); - }); - if (layout.isLinkButton && DocListCast(layout.links).length) { - await LinkManager.traverseLink(undefined, layout, (doc: Doc, where: string, finished?: () => void) => { - this.props.addDocTab(doc, where); - finished?.(); - }, false, this.props.ContainingCollectionDoc, batch.end, undefined); - } else { - ScriptCast(layout.onClick)?.script.run({ this: layout, self: Cast(layout.rootDocument, Doc, null) || layout }); - batch.end(); - } - } - - private renderMarkerIcon = (layout: Doc) => { - const { Document } = this.props; - const fieldKey = Doc.LayoutFieldKey(layout); - const iconUrl = StrCast(layout.mapIconUrl, StrCast(Document.mapIconUrl)); - if (iconUrl) { - const iconWidth = NumCast(layout[`${fieldKey}-iconWidth`], 45); - const iconHeight = NumCast(layout[`${fieldKey}-iconHeight`], 45); - const iconSize = new google.maps.Size(iconWidth, iconHeight); - return { - size: iconSize, - scaledSize: iconSize, - url: iconUrl - }; - } - } - - private renderMarker = (layout: Doc, fieldKey?: string) => { - const location = this.getLocation(layout, fieldKey || Doc.LayoutFieldKey(layout)); - return !location ? (null) : - <Marker - key={layout[Id]} - label={StrCast(layout[`${this.props.fieldKey}-address`])} - position={location} - onClick={() => this.markerClick(layout, location)} - icon={this.renderMarkerIcon(layout)} - />; - } - - private respondToAddressChange = async (doc: Doc, fieldKey: string, newAddress: string, oldAddress?: string) => { - if (newAddress === oldAddress) { - return false; - } - const response = await query(newAddress); - const id = doc[Id]; - if (!response || response.status === noResults) { - this._cancelAddrReq.set(id, true); - doc[`${fieldKey}-address`] = oldAddress; - return false; - } - const { geometry, formatted_address } = response.results[0]; - const { lat, lng } = geometry.location; - runInAction(() => { - if (doc[`${fieldKey}-lat`] !== lat || doc[`${fieldKey}-lng`] !== lng) { - this._cancelLocReq.set(id, true); - Doc.SetInPlace(doc, `${fieldKey}-lat`, lat, true); - Doc.SetInPlace(doc, `${fieldKey}-lng`, lng, true); - } - if (formatted_address !== newAddress) { - this._cancelAddrReq.set(id, true); - Doc.SetInPlace(doc, `${fieldKey}-address`, formatted_address, true); - } - }); - return true; - } - - private respondToLocationChange = async (doc: Doc, fieldKey: string, newLatLng: DocLatLng, oldLatLng: Opt<DocLatLng>) => { - if (newLatLng === oldLatLng) { - return false; - } - const response = await query({ lat: NumCast(newLatLng.lat), lng: NumCast(newLatLng.lng) }); - const id = doc[Id]; - if (!response || response.status === noResults) { - this._cancelLocReq.set(id, true); - runInAction(() => { - doc[`${fieldKey}-lat`] = oldLatLng?.lat; - doc[`${fieldKey}-lng`] = oldLatLng?.lng; - }); - return false; - } - const { formatted_address } = response.results[0]; - if (formatted_address !== doc[`${fieldKey}-address`]) { - this._cancelAddrReq.set(doc[Id], true); - Doc.SetInPlace(doc, `${fieldKey}-address`, formatted_address, true); - } - return true; - } - - @computed get reactiveContents() { - this.responders.forEach(({ locationDisposer, addressDisposer }) => { - locationDisposer(); - addressDisposer(); - }); - this.responders = []; - return this.childLayoutPairs.map(({ layout }) => { - const fieldKey = Doc.LayoutFieldKey(layout); - const id = layout[Id]; - this.responders.push({ - locationDisposer: computed(() => ({ lat: layout[`${fieldKey}-lat`], lng: layout[`${fieldKey}-lng`] })) - .observe(({ oldValue, newValue }) => { - if (this._cancelLocReq.get(id)) { - this._cancelLocReq.set(id, false); - } else if (newValue.lat !== undefined && newValue.lng !== undefined) { - this.respondToLocationChange(layout, fieldKey, newValue, oldValue); - } - }), - addressDisposer: computed(() => Cast(layout[`${fieldKey}-address`], "string", null)) - .observe(({ oldValue, newValue }) => { - if (this._cancelAddrReq.get(id)) { - this._cancelAddrReq.set(id, false); - } else if (newValue?.length) { - this.respondToAddressChange(layout, fieldKey, newValue, oldValue); - } - }) - }); - return this.renderMarker(layout); - }); - } - - render() { - const { childLayoutPairs } = this; - const { Document, fieldKey, isContentActive: active, google } = this.props; - const mapLoc = this.getLocation(this.rootDoc, `${fieldKey}-mapCenter`, false); - let center = mapLoc; - if (center === undefined) { - const childLocations = childLayoutPairs.map(({ layout }) => this.getLocation(layout, Doc.LayoutFieldKey(layout), false)); - center = childLocations.find(location => location) || defaultLocation; - } - return <div className="collectionMapView" ref={this.createDashEventsTarget}> - <div className={"collectionMapView-contents"} - style={{ pointerEvents: active() ? undefined : "none" }} - onWheel={e => e.stopPropagation()} - onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} > - <GeoMap - google={google} - zoom={center.zoom || 10} - initialCenter={center} - center={center} - onIdle={(_props?: IMapProps, map?: google.maps.Map) => { - if (this.layoutDoc._lockedTransform) { - // reset zoom (ideally, we could probably can tell the map to disallow zooming somehow instead) - map?.setZoom(center?.zoom || 10); - map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); - } else { - const zoom = map?.getZoom(); - (center?.zoom !== zoom) && undoBatch(action(() => { - Document[`${fieldKey}-mapCenter-zoom`] = zoom; - }))(); - } - }} - onDragend={(_props?: IMapProps, map?: google.maps.Map) => { - if (this.layoutDoc._lockedTransform) { - // reset the drag (ideally, we could probably can tell the map to disallow dragging somehow instead) - map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); - } else { - undoBatch(action(({ lat, lng }) => { - Document[`${fieldKey}-mapCenter-lat`] = lat(); - Document[`${fieldKey}-mapCenter-lng`] = lng(); - }))(map?.getCenter()); - } - }} - > - {this.reactiveContents} - {mapLoc && StrCast(this.rootDoc[`${fieldKey}-mapCenter-address`]) ? this.renderMarker(this.rootDoc, `${fieldKey}-mapCenter`) : undefined} - </GeoMap> - </div> - </div>; - } - -} - -export default GoogleApiWrapper({ - apiKey: process.env.GOOGLE_MAPS!, - LoadingContainer: () => { - console.log(process.env.GOOGLE_MAPS); - return <div className={"loadingWrapper"}> - <img className={"loadingGif"} src={"/assets/loading.gif"} /> - </div>; - } -})(CollectionMapView) as any;
\ No newline at end of file diff --git a/src/client/views/collections/CollectionMenu.tsx b/src/client/views/collections/CollectionMenu.tsx index 2c2d5dc75..131f5ba46 100644 --- a/src/client/views/collections/CollectionMenu.tsx +++ b/src/client/views/collections/CollectionMenu.tsx @@ -346,6 +346,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu case DocumentType.WEB: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={false} isDoc={true} />); case DocumentType.VID: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={false} isDoc={true} />); case DocumentType.RTF: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={this.props.type === CollectionViewType.Invalid} isDoc={true} />); + case DocumentType.MAP: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={false} isDoc={true} />); } } @@ -499,7 +500,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu const scroll = targetDoc._scrollTop; activeDoc.presPinView = true; activeDoc.presPinViewScroll = scroll; - } else if ((targetDoc.type === DocumentType.COL && targetDoc._viewType === CollectionViewType.Freeform) || targetDoc.type === DocumentType.IMG) { + } else if ((targetDoc.type === DocumentType.COL && targetDoc._viewType === CollectionViewType.Freeform) || targetDoc.type === DocumentType.IMG || targetDoc.type === DocumentType.MAP) { const x = targetDoc._panX; const y = targetDoc._panY; const scale = targetDoc._viewScale; diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 5c9c8063b..0dd1e6e36 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -25,7 +25,7 @@ import { CollectionDockingView } from "./CollectionDockingView"; import { CollectionFreeFormView } from './collectionFreeForm/CollectionFreeFormView'; import { CollectionGridView } from './collectionGrid/CollectionGridView'; import { CollectionLinearView } from './collectionLinear'; -import CollectionMapView from './CollectionMapView'; +import CollectionMapView from './MapView/CollectionMapView'; import { CollectionMulticolumnView } from './collectionMulticolumn/CollectionMulticolumnView'; import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; import { CollectionPileView } from './CollectionPileView'; diff --git a/src/client/views/collections/CollectionMapView.scss b/src/client/views/collections/MapView/CollectionMapView.scss index 870b7fda8..0dc226c04 100644 --- a/src/client/views/collections/CollectionMapView.scss +++ b/src/client/views/collections/MapView/CollectionMapView.scss @@ -1,13 +1,34 @@ .collectionMapView { width: 100%; height: 100%; + overflow: hidden; .collectionMapView-contents { width: 100%; height: 100%; + overflow: hidden; > div { position: unset !important; // when the sidebar filter flys out, this prevents the map from extending outside the document box } + + .map-wrapper { + .searchbox { + box-sizing: border-box; + border: 1px solid transparent; + width: 300px; + height: 32px; + padding: 0 12px; + border-radius: 3px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + font-size: 14px; + outline: none; + text-overflow: ellipses; + position: absolute; + left: 50%; + margin-left: -120px; + margin-top: 5px; + } + } } } @@ -27,4 +48,4 @@ width: 50px; height: 50px; } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/MapView/CollectionMapView.tsx b/src/client/views/collections/MapView/CollectionMapView.tsx new file mode 100644 index 000000000..1166de61c --- /dev/null +++ b/src/client/views/collections/MapView/CollectionMapView.tsx @@ -0,0 +1,272 @@ +import { GoogleMap, Marker, InfoWindow, InfoBox, useJsApiLoader, LoadScript, GoogleMapProps, StandaloneSearchBox, Autocomplete } from '@react-google-maps/api'; +import { observable, action, computed, Lambda, runInAction, IReactionDisposer } from "mobx"; +import { observer } from "mobx-react"; +import { Doc, DocListCast, Field, FieldResult, Opt } from "../../../../fields/Doc"; +import { documentSchema } from "../../../../fields/documentSchemas"; +import { Id } from "../../../../fields/FieldSymbols"; +import { makeInterface } from "../../../../fields/Schema"; +import { Cast, NumCast, ScriptCast, StrCast } from "../../../../fields/Types"; +import { LinkManager } from "../../../util/LinkManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; +import "./CollectionMapView.scss"; +import { CollectionSubView } from "../CollectionSubView"; +import React = require("react"); +import requestPromise = require("request-promise"); +import ReactDOM from 'react-dom'; +import { DragManager } from '../../../util/DragManager'; +import { MapMarker } from '../../nodes/MapBox/MapMarker'; + + +/** + * Idea behind storing a marker: + * 1. on the map api, have a button "add marker" that adds the marker on the map & store the marker as a node in the collection + * (but don't render the marker in the collection itself) + * 2. each marker, as a node, has the same feature as all other nodes for linking (the same way one could form a link between a node inside a child collection + * and a node outside the child collection) + * + * /util/LinkManager.ts -- link relations + * + */ + +type MapSchema = makeInterface<[typeof documentSchema]>; +const MapSchema = makeInterface(documentSchema); + +export type Coordinates = { + lat: number, + lng: number, +} + +export type LocationData = { + id: string; + pos: Coordinates; +}; + +const mapContainerStyle = { + height: '100%', +}; + +const defaultCenter = { + lat: 38.685, + lng: -115.234, +}; + +const mapOptions = { + fullscreenControl: false, +} + +const drawingManager = new google.maps.drawing.DrawingManager({ + drawingControl: true, + drawingControlOptions: { + position: google.maps.ControlPosition.TOP_RIGHT, + drawingModes: [ + google.maps.drawing.OverlayType.MARKER, + // currently we are not supporting the following drawing mode on map, a thought for future development + // google.maps.drawing.OverlayType.CIRCLE, + // google.maps.drawing.OverlayType.POLYLINE, + ], + }, +}); + +const options = { + fields: ["formatted_address", "geometry", "name"], // note: level of details is charged by item per retrieval, not recommended to return all fields + strictBounds: false, + types: ["establishment"], // type pf places, subject of change according to user need +} as google.maps.places.AutocompleteOptions; + +@observer +export default class CollectionMapView extends CollectionSubView<MapSchema, Partial<GoogleMapProps>>(MapSchema) { + private _dropDisposer?: DragManager.DragDropDisposer; + private _disposers: { [name: string]: IReactionDisposer } = {}; + + + @observable private _map = null as unknown as google.maps.Map; + @observable private selectedPlace: LocationData | undefined; + @observable private markerMap: { [id: string]: google.maps.Marker } = {}; + @observable private center = defaultCenter; + @observable private zoom = 2.5; + @observable private infoWindowOpen = false; + @observable private bounds = new window.google.maps.LatLngBounds(); + @observable private inputRef = React.createRef<HTMLInputElement>(); + @observable private searchMarkers: google.maps.Marker[] = []; + @observable private searchBox = new window.google.maps.places.Autocomplete(this.inputRef.current!, options); + + @observable private myPlaces: LocationData[] = [ + { id: "id1", pos: { lat: 39.09366509575983, lng: -94.58751660204751 } }, + { id: "id2", pos: { lat: 41.82399, lng: -71.41283 } }, + { id: "id3", pos: { lat: 47.606214, lng: -122.33207 } }, + ]; + + @action + private setSearchBox = (searchBox: any) => { + this.searchBox = searchBox; + } + + // iterate myPlaces to size, center, and zoom map to contain all markers + private fitBounds = (map: google.maps.Map) => { + console.log('map bound is:' + this.bounds); + this.myPlaces ? this.myPlaces.map(place => { + this.bounds.extend(place.pos!); + }) : null; + map.fitBounds(this.bounds); + } + + // store a reference to google map instance; fit map bounds to contain all markers + @action + private loadHandler = (map: google.maps.Map) => { + this._map = map; + drawingManager.setMap(map); + this.fitBounds(map); + } + + @action + private markerClickHandler = (e: MouseEvent, place: any) => { + // set which place was clicked + this.selectedPlace = place; + + console.log(this.selectedPlace); + + // used so clicking a second marker works + if (this.infoWindowOpen) { + this.infoWindowOpen = false; + console.log("closeinfowindow") + } + this.infoWindowOpen = true; + console.log("open infowindow") + } + + @action + private handleInfoWindowClose = () => { + if (this.infoWindowOpen) { + this.infoWindowOpen = false; + } + this.infoWindowOpen = false; + this.selectedPlace = undefined; + } + + @action + private handlePlaceChanged = () => { + console.log(this.searchBox); + const place = this.searchBox.getPlace(); + + if (!place.geometry || !place.geometry.location) { + // user entered the name of a place that wasn't suggested & pressed the enter key, or place details request failed + window.alert("No details available for input: '" + place.name + "'"); + return; + } + + // zoom in on the location of the search result + if (place.geometry.viewport) { + console.log(this._map); + this._map.fitBounds(place.geometry.viewport); + } else { + console.log(this._map); + this._map.setCenter(place.geometry.location); + this._map.setZoom(17); + } + + // customize icon => customized icon for the nature of the location selected + const icon = { + url: place.icon as string, + size: new google.maps.Size(71, 71), + origin: new google.maps.Point(0, 0), + anchor: new google.maps.Point(17, 34), + scaledSize: new google.maps.Size(25, 25), + }; + + // put temporary cutomized marker on searched location + this.searchMarkers.forEach((marker) => { + marker.setMap(null); + }); + this.searchMarkers = []; + this.searchMarkers.push( + new window.google.maps.Marker({ + map: this._map, + icon, + title: place.name, + position: place.geometry.location, + }) + ) + } + + + @action + private addMarker = (location: google.maps.LatLng | undefined, map: google.maps.Map) => { + new window.google.maps.Marker({ + position: location, + map: map + }); + } + + @action + private markerLoadHandler = (marker: google.maps.Marker, place: LocationData) => { + place.id ? this.markerMap[place.id] = marker : null; + } + + render() { + const { Document, fieldKey, isContentActive: active } = this.props; + + return <div className="collectionMapView" ref={this.createDashEventsTarget}> + + <div className={"collectionMapView-contents"} + style={{ pointerEvents: active() ? undefined : "none", overflow: 'hidden' }} + onWheel={e => e.stopPropagation()} + onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} > + {/* <LoadScript + googleMapsApiKey={process.env.GOOGLE_MAPS!} + libraries={['places', 'drawing']} + > */} + <div className="map-wrapper"> + <GoogleMap + mapContainerStyle={mapContainerStyle} + zoom={this.zoom} + center={this.center} + onLoad={map => this.loadHandler(map)} + options={mapOptions} + > + <Autocomplete + onLoad={this.setSearchBox} + onPlaceChanged={this.handlePlaceChanged}> + <input ref={this.inputRef} className="searchbox" type="text" placeholder="Search anywhere:" /> + </Autocomplete> + + {this.myPlaces ? this.myPlaces.map(place => + <Marker + position={place.pos} + onLoad={marker => this.markerLoadHandler(marker, place)} + onClick={e => this.markerClickHandler(e, place)} + draggable={false} + /> + ) : null} + {this.infoWindowOpen && this.selectedPlace && ( + <InfoWindow + anchor={this.markerMap[this.selectedPlace.id!]} + onCloseClick={this.handleInfoWindowClose} + > + <div style={{ backgroundColor: 'white', opacity: 0.75, padding: 12 }}> + <div style={{ fontSize: 16 }}> + <div> + <img src="http://placekitten.com/200/300" /> + <hr /> + <form> + <label>Title: </label><br /> + <input type="text" id="fname" name="fname"></input><br /> + <label>Desription: </label><br /> + <textarea style={{ height: 150 }} id="lname" name="lname" placeholder="Notes, a short description of this location, a brief comment, etc."></textarea> + </form> + <hr /> + <div> + <button>New link+</button> + </div> + </div> + </div> + </div> + </InfoWindow> + )} + </GoogleMap> + </div> + {/* </LoadScript > */} + </div > + </div >; + } + +}
\ No newline at end of file diff --git a/src/client/views/collections/TabDocView.scss b/src/client/views/collections/TabDocView.scss index 7f62ecaa0..0d045bada 100644 --- a/src/client/views/collections/TabDocView.scss +++ b/src/client/views/collections/TabDocView.scss @@ -66,7 +66,6 @@ input.lm_title { right: 15; bottom: 15; border: solid 1px; - box-shadow: black 0.4vw 0.4vw 0.8vw; width: 100%; height: 100%; transition: all 0.5s; @@ -101,4 +100,4 @@ input.lm_title { &:hover { box-shadow: none; } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index f63e5f844..73c065482 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -438,8 +438,8 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { case StyleProp.PointerEvents: return "none"; case StyleProp.DocContents: const background = doc.type === DocumentType.PDF ? "red" : doc.type === DocumentType.IMG ? "blue" : doc.type === DocumentType.RTF ? "orange" : - doc.type === DocumentType.VID ? "purple" : doc.type === DocumentType.WEB ? "yellow" : "gray"; - return doc.type === DocumentType.COL || doc.type === DocumentType.INK ? + doc.type === DocumentType.VID ? "purple" : doc.type === DocumentType.WEB ? "yellow" : doc.type === DocumentType.MAP ? "blue" : "gray"; + return doc.type === DocumentType.COL ? undefined : <div style={{ width: doc[WidthSym](), height: doc[HeightSym](), position: "absolute", display: "block", background }} />; } diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index dbab5e762..005133eb0 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -40,6 +40,7 @@ import { VideoBox } from "./VideoBox"; import { WebBox } from "./WebBox"; import React = require("react"); import XRegExp = require("xregexp"); +import { MapBox } from "./MapBox/MapBox"; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? @@ -225,7 +226,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & Fo FormattedTextBox, ImageBox, DirectoryImportBox, FontIconBox, LabelBox, EquationBox, SliderBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, PresBox, YoutubeBox, PresElementBox, SearchBox, FilterBox, FunctionPlotBox, - ColorBox, DashWebRTCVideo, LinkAnchorBox, InkingStroke, LinkBox, ScriptingBox, + ColorBox, DashWebRTCVideo, LinkAnchorBox, InkingStroke, LinkBox, ScriptingBox, MapBox, ScreenshotBox, HTMLtag, ComparisonBox }} diff --git a/src/client/views/nodes/MapBox/MapBox.scss b/src/client/views/nodes/MapBox/MapBox.scss new file mode 100644 index 000000000..4fae8d8ff --- /dev/null +++ b/src/client/views/nodes/MapBox/MapBox.scss @@ -0,0 +1,40 @@ +.mapBox { + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + > div { + position: unset !important; // when the sidebar filter flys out, this prevents the map from extending outside the document box + } + + .mapBox-wrapper { + width: 100%; + .searchbox { + box-sizing: border-box; + border: 1px solid transparent; + width: 240px; + height: 32px; + padding: 0 12px; + border-radius: 3px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + font-size: 14px; + outline: none; + text-overflow: ellipses; + position: absolute; + left: 50%; + margin-left: -120px; + } + } + + .mapBox-sidebar-handle { + position: absolute !important; + top: 0; + //top: calc(50% - 17.5px); // use this to center vertically -- make sure it looks okay for slide views + width: 10px; + height: 100%; + max-height: 35px; + background: lightgray; + border-radius: 20px; + cursor:grabbing; + } +} diff --git a/src/client/views/nodes/MapBox/MapBox.tsx b/src/client/views/nodes/MapBox/MapBox.tsx new file mode 100644 index 000000000..0314aa419 --- /dev/null +++ b/src/client/views/nodes/MapBox/MapBox.tsx @@ -0,0 +1,433 @@ +import { Autocomplete, GoogleMap, GoogleMapProps, InfoWindow, Marker } from '@react-google-maps/api'; +import { action, computed, IReactionDisposer, observable, ObservableMap } from 'mobx'; +import { observer } from "mobx-react"; +import * as React from "react"; +import { DataSym, Doc, DocListCast, FieldsSym, WidthSym } from '../../../../fields/Doc'; +import { documentSchema } from '../../../../fields/documentSchemas'; +import { makeInterface } from '../../../../fields/Schema'; +import { NumCast, StrCast } from '../../../../fields/Types'; +import { emptyFunction, setupMoveUpEvents } from '../../../../Utils'; +import { Docs } from '../../../documents/Documents'; +import { DragManager } from '../../../util/DragManager'; +import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from '../../DocComponent'; +import { AnchorMenu } from '../../pdf/AnchorMenu'; +import { SidebarAnnos } from '../../SidebarAnnos'; +import { StyleProp } from '../../StyleProvider'; +import { FieldView, FieldViewProps } from '../FieldView'; +import "./MapBox.scss"; +import { MapMarker } from './MapMarker'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { identity } from 'lodash'; +import { Id } from '../../../../fields/FieldSymbols'; + +type MapDocument = makeInterface<[typeof documentSchema]>; +const MapDocument = makeInterface(documentSchema); + +export type Coordinates = { + lat: number, + lng: number, +} + +export type LocationData = { + id: string; + pos: Coordinates; +}; + +const mapContainerStyle = { + height: '100%', +}; + +const defaultCenter = { + lat: 38.685, + lng: -115.234, +}; + +const mapOptions = { + fullscreenControl: false, +} + +const drawingManager = new google.maps.drawing.DrawingManager({ + drawingControl: true, + drawingControlOptions: { + position: google.maps.ControlPosition.TOP_RIGHT, + drawingModes: [ + google.maps.drawing.OverlayType.MARKER, + // currently we are not supporting the following drawing mode on map, a thought for future development + // google.maps.drawing.OverlayType.CIRCLE, + // google.maps.drawing.OverlayType.POLYLINE, + ], + }, +}); + +const options = { + fields: ["formatted_address", "geometry", "name"], // note: level of details is charged by item per retrieval, not recommended to return all fields + strictBounds: false, + types: ["establishment"], // type pf places, subject of change according to user need +} as google.maps.places.AutocompleteOptions; + +@observer +export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps & Partial<GoogleMapProps>, MapDocument>(MapDocument) { + private _dropDisposer?: DragManager.DragDropDisposer; + private _disposers: { [name: string]: IReactionDisposer } = {}; + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(MapBox, fieldKey); } + + @observable private _map: google.maps.Map = null as unknown as google.maps.Map; + @observable private selectedPlace: MapMarker | undefined; + @observable private markerMap: { [id: string]: google.maps.Marker } = {}; + @observable private center = navigator.geolocation ? navigator.geolocation.getCurrentPosition : defaultCenter; + @observable private zoom = 2.5; + @observable private infoWindowOpen = false; + @observable private bounds = new window.google.maps.LatLngBounds(); + @observable private inputRef = React.createRef<HTMLInputElement>(); + @observable private searchMarkers: google.maps.Marker[] = []; + @observable private searchBox = new window.google.maps.places.Autocomplete(this.inputRef.current!, options); + @observable private _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); }; + @computed get allMapMarkers() { return DocListCast(this.dataDoc[this.annotationKey]); }; + @observable private allMarkers: Doc[] = []; + //TODO: change all markers to a filter function to change + + + @observable _showSidebar = false; + @computed get SidebarShown() { return this._showSidebar || this.layoutDoc._showSidebar ? true : false; } + + static _canAnnotate = true; + static _hadSelection: boolean = false; + private _sidebarRef = React.createRef<SidebarAnnos>(); + private _ref: React.RefObject<HTMLDivElement> = React.createRef(); + + constructor(props: any) { + super(props); + } + + @action + private setSearchBox = (searchBox: any) => { + this.searchBox = searchBox; + } + + // iterate allMarkers to size, center, and zoom map to contain all markers + private fitBounds = (map: google.maps.Map) => { + console.log('map bound is:' + this.bounds); + this.allMarkers.map(place => { + this.bounds.extend({ lat: NumCast(place.lat), lng: NumCast(place.lng) }); + return place._markerId; + }); + map.fitBounds(this.bounds) + } + + + private hasGeolocation = (doc: Doc) => { + return doc.type === DocumentType.IMG + + } + + + /** + * A function that examines allMapMarkers docs in the map node and form MapMarkers + */ + private fillMarkers = () => { + this.allMapMarkers?.forEach(doc => { + // search for if the map marker exists, else create marker + if (doc.lat !== undefined && doc.lng !== undefined) { + const marker = Docs.Create.MapMarkerDocument(NumCast(doc.lat), NumCast(doc.lng), [doc], {}) + this.allMarkers.push(marker) + } + }) + } + + + /** + * store a reference to google map instance + * setup the drawing manager on the top right corner of map + * fit map bounds to contain all markers + * @param map + */ + @action + private loadHandler = (map: google.maps.Map) => { + this._map = map; + drawingManager.setMap(map); + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position: GeolocationPosition) => { + const pos = { + lat: position.coords.latitude, + lng: position.coords.longitude, + }; + this._map.setCenter(pos); + } + ); + } else { + alert("Your geolocation is not supported by browser.") + } + this.fitBounds(map); + this.fillMarkers(); + // this._map.addListener(drawingManager, 'markercomplete', this.addMarker) + } + + //TODO: Is this a valid way for adding marker from drawing manager..? If so, how do we update the allMarkers & render so info window appears + @action + private addMarker = (marker: google.maps.Marker) => { + const markerPosition = marker.getPosition(); + const newMapMarker = Docs.Create.MapMarkerDocument(NumCast(markerPosition?.lat()), NumCast(markerPosition?.lng()), [], {}) + this.allMarkers.push(newMapMarker) + } + + @action + private markerLoadHandler = (marker: google.maps.Marker, place: Doc) => { + place[Id] ? this.markerMap[place[Id]] = marker : null; + } + + @action + private markerClickHandler = (e: MouseEvent, place: any) => { + // set which place was clicked + this.selectedPlace = place; + + console.log(this.selectedPlace); + + // used so clicking a second marker works + if (this.infoWindowOpen) { + this.infoWindowOpen = false; + console.log("closeinfowindow") + } + this.infoWindowOpen = true; + console.log("open infowindow") + } + + /** + * Called when dragging documents into map sidebar + * @param doc + * @param sidebarKey + * @returns + */ + sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { + if (!this.layoutDoc._showSidebar) this.toggleSidebar(); + const docs = doc instanceof Doc ? [doc] : doc + docs.forEach(doc => { + if (doc.lat !== undefined && doc.lng !== undefined) { + const marker = Docs.Create.MapMarkerDocument(NumCast(doc.lat), NumCast(doc.lng), [], {}) + this.addDocument(marker, this.annotationKey) + } + }) //add to annotation list + return this.addDocument(doc, sidebarKey); // add to sidebar list + } + + /** + * What does this do exactly? How to operate on sidebar? + * @param e + */ + sidebarBtnDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, (e, down, delta) => { + const localDelta = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformDirection(delta[0], delta[1]); + const nativeWidth = NumCast(this.layoutDoc[this.fieldKey + "-nativeWidth"]); + const curNativeWidth = NumCast(this.layoutDoc.nativeWidth, nativeWidth); + const ratio = (curNativeWidth + localDelta[0] / (this.props.scaling?.() || 1)) / nativeWidth; + if (ratio >= 1) { + this.layoutDoc.nativeWidth = nativeWidth * ratio; + this.layoutDoc._width = this.layoutDoc[WidthSym]() + localDelta[0]; + this.layoutDoc._showSidebar = nativeWidth !== this.layoutDoc._nativeWidth; + } + return false; + }, emptyFunction, this.toggleSidebar); + } + + sidebarWidth = () => Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100 * this.props.PanelWidth(); + @computed get sidebarWidthPercent() { return StrCast(this.layoutDoc._sidebarWidthPercent, "0%"); } + @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebarColor, StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], "#e4e4e4")); } + + + /** + * function that reads the place inputed from searchbox, then zoom in on the location that's been autocompleted; + * add a customized temporary marker on the map + */ + @action + private handlePlaceChanged = () => { + console.log(this.searchBox); + const place = this.searchBox.getPlace(); + + if (!place.geometry || !place.geometry.location) { + // user entered the name of a place that wasn't suggested & pressed the enter key, or place details request failed + window.alert("No details available for input: '" + place.name + "'"); + return; + } + + // zoom in on the location of the search result + if (place.geometry.viewport) { + console.log(this._map); + this._map.fitBounds(place.geometry.viewport); + } else { + console.log(this._map); + this._map.setCenter(place.geometry.location); + this._map.setZoom(17); + } + + // customize icon => customized icon for the nature of the location selected + const icon = { + url: place.icon as string, + size: new google.maps.Size(71, 71), + origin: new google.maps.Point(0, 0), + anchor: new google.maps.Point(17, 34), + scaledSize: new google.maps.Size(25, 25), + }; + + // put temporary cutomized marker on searched location + this.searchMarkers.forEach((marker) => { + marker.setMap(null); + }); + this.searchMarkers = []; + this.searchMarkers.push( + new window.google.maps.Marker({ + map: this._map, + icon, + title: place.name, + position: place.geometry.location, + }) + ) + } + + + @action + private handleInfoWindowClose = () => { + if (this.infoWindowOpen) { + this.infoWindowOpen = false; + } + this.infoWindowOpen = false; + this.selectedPlace = undefined; + } + + public get SidebarKey() { return this.fieldKey + "-sidebar"; } + + @computed get sidebarHandle() { + const annotated = DocListCast(this.dataDoc[this.SidebarKey]).filter(d => d?.author).length; + return (!annotated && !this.isContentActive()) ? (null) : <div className="mapBox-sidebar-handle" onPointerDown={this.sidebarDown} + style={{ + left: `max(0px, calc(100% - ${this.sidebarWidthPercent} ${this.sidebarWidth() ? "- 5px" : "- 10px"}))`, + background: this.props.styleProvider?.(this.rootDoc, this.props as any, StyleProp.WidgetColor + (annotated ? ":annotated" : "")) + }} />; + } + + @action + toggleSidebar = () => { + const prevWidth = this.sidebarWidth(); + this.layoutDoc._showSidebar = ((this.layoutDoc._sidebarWidthPercent = StrCast(this.layoutDoc._sidebarWidthPercent, "0%") === "0%" ? "50%" : "0%")) !== "0%"; + this.layoutDoc._width = this.layoutDoc._showSidebar ? NumCast(this.layoutDoc._width) * 2 : Math.max(20, NumCast(this.layoutDoc._width) - prevWidth); + } + sidebarDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, this.sidebarMove, emptyFunction, () => setTimeout(this.toggleSidebar), false); + } + sidebarMove = (e: PointerEvent, down: number[], delta: number[]) => { + const bounds = this._ref.current!.getBoundingClientRect(); + this.layoutDoc._sidebarWidthPercent = "" + 100 * Math.max(0, (1 - (e.clientX - bounds.left) / bounds.width)) + "%"; + this.layoutDoc._showSidebar = this.layoutDoc._sidebarWidthPercent !== "0%"; + e.preventDefault(); + return false; + } + + // TODO what's the difference between savedAnnotations & allMapMarkers? + getAnchor = () => { + const anchor = + AnchorMenu.Instance?.GetAnchor(this._savedAnnotations) ?? + this.rootDoc // if anchormenu pops up else return rootDoc (map) + // Docs.Create.MapMarkerDocument(this.allMapMarkers, { + // title: StrCast(this.rootDoc.title + " " + this.layoutDoc._scrollTop), + // annotationOn: this.rootDoc, + // y: NumCast(this.layoutDoc._scrollTop), + // }); + // this.addDocument(anchor); + return anchor; + } + + private saveMarkerInfo = () => { + + } + // create marker prop --> func that + private renderMarkers = () => { + return this.allMarkers.map(place => ( + <Marker + key={place[Id]} + position={{ lat: NumCast(place.lat), lng: NumCast(place.lng) }} + onLoad={marker => this.markerLoadHandler(marker, place)} + onClick={e => this.markerClickHandler(e, place)} + /> + )) + } + + private renderInfoWindow = () => { + return this.infoWindowOpen && this.selectedPlace && ( + <InfoWindow + anchor={this.markerMap[this.selectedPlace._markerId!]} + onCloseClick={this.handleInfoWindowClose} + > + <div style={{ backgroundColor: 'white', opacity: 0.75, padding: 12 }}> + <div style={{ fontSize: 16 }}> + <div> + {/* {// TODO need to figure out how to render these childDocs of the MapMarker in InfoWindow + marker.childDocs} */} + <hr /> + <form> + <label>Title: </label><br /> + <input type="text" id="title" name="title"></input><br /> + <label>Desription: </label><br /> + <textarea style={{ height: 150 }} id="description" name="description" placeholder="Notes, a short description of this location, a brief comment, etc."></textarea> + <button type="submit">Save</button> + </form> + <hr /> + <div> + <button>New link+</button> + </div> + </div> + </div> + </div> + </InfoWindow> + ) + } + + render() { + return <div className="mapBox" ref={this._ref} + style={{ pointerEvents: this.isContentActive() ? undefined : "none" }} > + {/* // {/* <LoadScript + // googleMapsApiKey={process.env.GOOGLE_MAPS!} + // libraries={['places', 'drawing']} + // > */} + <div className="mapBox-wrapper" + onWheel={e => e.stopPropagation()} + onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} + style={{ width: `calc(100% - ${this.sidebarWidthPercent})` }}> + <GoogleMap + mapContainerStyle={mapContainerStyle} + zoom={this.zoom} + // center={this.center} + onLoad={map => this.loadHandler(map)} + options={mapOptions} + > + <Autocomplete + onLoad={this.setSearchBox} + onPlaceChanged={this.handlePlaceChanged}> + <input ref={this.inputRef} className="searchbox" type="text" placeholder="Search anywhere:" /> + </Autocomplete> + + {this.renderMarkers()} + {this.renderInfoWindow()} + </GoogleMap> + </div> + {/* {/* </LoadScript > */} + <div className="mapBox-sidebar" + style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${this.sidebarColor}` }}> + <SidebarAnnos ref={this._sidebarRef} + {...this.props} + fieldKey={this.annotationKey} + rootDoc={this.rootDoc} + layoutDoc={this.layoutDoc} + dataDoc={this.dataDoc} + showSidebar={this.SidebarShown} + nativeWidth={NumCast(this.layoutDoc._nativeWidth)} + PanelWidth={this.sidebarWidth} + sidebarAddDocument={this.sidebarAddDocument} + moveDocument={this.moveDocument} + removeDocument={this.removeDocument} + isContentActive={this.isContentActive} + /> + </div> + {this.sidebarHandle} + </div>; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/MapBox/MapMarker.tsx b/src/client/views/nodes/MapBox/MapMarker.tsx new file mode 100644 index 000000000..fbad0cf65 --- /dev/null +++ b/src/client/views/nodes/MapBox/MapMarker.tsx @@ -0,0 +1,117 @@ +import { action, computed, IReactionDisposer, observable } from "mobx"; +import { observer } from "mobx-react"; +import { Transaction } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import * as React from "react"; +import { Doc, DocListCast, Opt } from "../../../../fields/Doc"; +import { documentSchema } from "../../../../fields/documentSchemas"; +import { Id } from "../../../../fields/FieldSymbols"; +import { createSchema, makeInterface } from "../../../../fields/Schema"; +import { Cast, NumCast } from "../../../../fields/Types"; +import { CurrentUserUtils } from "../../../util/CurrentUserUtils"; +import { DragManager } from "../../../util/DragManager"; +import { CollectionViewType } from "../../collections/CollectionView"; +import { TabDocView } from "../../collections/TabDocView"; +import { ViewBoxAnnotatableProps, ViewBoxAnnotatableComponent } from "../../DocComponent"; +import { AnchorMenu } from "../../pdf/AnchorMenu"; +import { FieldView, FieldViewProps } from "../FieldView"; +import { FormattedTextBox } from "../formattedText/FormattedTextBox"; +import { RichTextMenu } from "../formattedText/RichTextMenu"; + +type MarkerDocument = makeInterface<[typeof documentSchema]>; +const MarkerDocument = makeInterface(documentSchema); + +export type Coordinates = { + lat: number, + lng: number, +} + +//TODO: MapMarkerBox +@observer +export class MapMarker extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps, MarkerDocument>(MarkerDocument) { + makeLinkAnchor(arg1: string, undefined: undefined, arg3: string) { + throw new Error("Method not implemented."); + } + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(MapMarker, fieldKey); } + private _markerRef: React.RefObject<google.maps.Marker> = React.createRef(); + private _disposers: { [name: string]: IReactionDisposer } = {}; + _latlngLocation!: Coordinates; + _markerId!: number; + private _editorView: Opt<EditorView> // we'll see if this becomes useful for marker annotation/create link + @observable _title: string = ""; // the title of the marker + @observable _description: string = ""; // the description of the marker contents + @observable isMarkerActive: boolean = false; // whether the marker is selected (we'll see if we need this) + @observable activeLinks: Doc[] = []; //TBD: what linking data structure looks like + @computed get childDocs() { return DocListCast(this.dataDoc[this.fieldKey]); } // a list of documents with the same/similar geographic coordinates + @computed get tagDocs() { // might come in handy for filtering + const tagDocs: Doc[] = []; + for (const doc of this.childDocs) { + const tagDoc = Cast(doc.presentationTargetDoc, Doc, null); + tagDocs.push(tagDoc); + } + return tagDocs; + } + + @computed get lat() { return NumCast(this.dataDoc.lat) } + @computed get lng() { return NumCast(this.dataDoc.lng) } + + /** + * Methods + */ + componentDidMount() { } + + componentWillMount() { } + + @computed private get filterAssociatedDocs() { + return + } + + addLinkToMarker = () => { } + + + + @action + setupAnchorMenu = () => { + AnchorMenu.Instance.Status = "marquee"; + AnchorMenu.Instance.Highlight = action((color: string, isLinkButton: boolean) => { + this._editorView?.state && RichTextMenu.Instance.insertHighlight(color, this._editorView.state, this._editorView?.dispatch); + return undefined; + }); + /** + * This function is used by the PDFmenu to create an anchor highlight and a new linked text annotation. + * It also initiates a Drag/Drop interaction to place the text annotation. + */ + AnchorMenu.Instance.StartDrag = action(async (e: PointerEvent, ele: HTMLElement) => { + e.preventDefault(); + e.stopPropagation(); + const targetCreator = (annotationOn?: Doc) => { + const target = CurrentUserUtils.GetNewTextDoc("Note linked to " + this.rootDoc.title, 0, 0, 100, 100, undefined, annotationOn); + FormattedTextBox.SelectOnLoad = target[Id]; + return target; + }; + + // DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.docViewPath().lastElement(), this.getAnchor, targetCreator), e.pageX, e.pageY); + }); + const coordsB = this._editorView!.coordsAtPos(this._editorView!.state.selection.to); + this.props.isSelected(true) && AnchorMenu.Instance.jumpTo(coordsB.left, coordsB.bottom); + } + + // will see if end up using this + dispatchTransaction = (tx: Transaction) => { } + + + //will see if needed + // for inserting timestamps + insertTime = () => { } + + //for setting the title of the marker + @action + private updateTitle = () => { } + + //for updating the description of the marker + @action + private updateDescrption = () => { } + + + +}
\ No newline at end of file diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index ce851b830..972dcc0be 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -130,6 +130,8 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } searchStringChanged = (e: React.ChangeEvent<HTMLInputElement>) => this._searchString = e.currentTarget.value; + // adding external documents; to sidebar key + // if (doc.Geolocation) this.addDocument(doc, this.fieldkey+"-annotation") sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { if (!this.layoutDoc._showSidebar) this.toggleSidebar(); return this.addDocument(doc, sidebarKey); diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index caca215e5..63d2c1007 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -1527,6 +1527,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp rootDoc={this.rootDoc} layoutDoc={this.layoutDoc} dataDoc={this.dataDoc} + // usePanelWidth={true} nativeWidth={NumCast(this.layoutDoc._nativeWidth)} showSidebar={this.SidebarShown} PanelWidth={this.sidebarWidth} diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index add84ff83..f34b4a8ac 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -47,6 +47,11 @@ const PresBoxDocument = makeInterface(documentSchema); export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema>(PresBoxDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresBox, fieldKey); } + /** + * transitions & effects for documents + * @param renderDoc + * @param layoutDoc + */ static renderEffectsDoc(renderDoc: any, layoutDoc: Doc) { const effectProps = { left: layoutDoc.presEffectDirection === PresEffect.Left, @@ -224,6 +229,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> } } + //TODO: al: it seems currently that tempMedia doesn't stop onslidechange after clicking the button; the time the tempmedia stop depends on the start & end time // No more frames in current doc and next slide is defined, therefore move to next slide nextSlide = (activeNext: Doc) => { const targetNext = Cast(activeNext.presentationTargetDoc, Doc, null); @@ -417,7 +423,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> /** * Uses the viewfinder to progressivize through the different views of a single collection. - * @param presTargetDoc: document for which internal zoom is used + * @param activeItem: document for which internal zoom is used */ zoomProgressivizeNext = (activeItem: Doc) => { const targetDoc: Doc = this.targetDoc; @@ -533,6 +539,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> load(); } + // The function pauses the auto presentation @action pauseAutoPres = () => { if (this.layoutDoc.presStatus === PresStatus.Autoplay) { @@ -556,6 +563,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> }); } + // The function allows for viewing the pres path on toggle @action togglePath = (srcContext: Doc, off?: boolean) => { if (off) { this._pathBoolean = false; @@ -566,6 +574,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> } } + // The function allows for expanding the view of pres on toggle @action toggleExpandMode = () => { runInAction(() => this._expandBoolean = !this._expandBoolean); this.rootDoc.expandBoolean = this._expandBoolean; @@ -1810,6 +1819,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> case DocumentType.VID: type = "Video"; break; case DocumentType.IMG: type = "Image"; break; case DocumentType.WEB: type = "Web page"; break; + case DocumentType.MAP: type = "Map"; break; default: type = "Other node"; break; } } diff --git a/src/client/views/nodes/trails/PresElementBox.tsx b/src/client/views/nodes/trails/PresElementBox.tsx index 5e713c3cf..238d025dc 100644 --- a/src/client/views/nodes/trails/PresElementBox.tsx +++ b/src/client/views/nodes/trails/PresElementBox.tsx @@ -166,6 +166,9 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc e.preventDefault(); } + /** + * Function to drag and drop the pres element to a diferent location + */ startDrag = (e: PointerEvent) => { const miniView: boolean = this.toolbarWidth <= 100; const activeItem = this.rootDoc; @@ -244,6 +247,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc e.stopPropagation(); }); + // set the value/title of the individual pres element @undoBatch @action onSetValue = (value: string) => { diff --git a/src/client/views/search/IconBar.tsx b/src/client/views/search/IconBar.tsx index f1dd106a7..6103b245c 100644 --- a/src/client/views/search/IconBar.tsx +++ b/src/client/views/search/IconBar.tsx @@ -14,7 +14,7 @@ export interface IconBarProps { @observer export class IconBar extends React.Component<IconBarProps> { - public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.RTF, DocumentType.VID, DocumentType.WEB]; + public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.RTF, DocumentType.VID, DocumentType.WEB, DocumentType.MAP]; @observable private _icons: string[] = this._allIcons; diff --git a/src/client/views/search/IconButton.tsx b/src/client/views/search/IconButton.tsx index 2dd6b1b79..6cf3a5f72 100644 --- a/src/client/views/search/IconButton.tsx +++ b/src/client/views/search/IconButton.tsx @@ -70,6 +70,7 @@ export class IconButton extends React.Component<IconButtonProps>{ case (DocumentType.RTF): return "sticky-note"; case (DocumentType.VID): return "video"; case (DocumentType.WEB): return "globe-asia"; + case (DocumentType.MAP): return "map-marker-alt"; default: return "caret-down"; } } diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 4d040f3bc..49dfb14a7 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -20,7 +20,7 @@ import { RichTextField } from "./RichTextField"; import { listSpec } from "./Schema"; import { ComputedField, ScriptField } from "./ScriptField"; import { Cast, FieldValue, NumCast, StrCast, ToConstructor } from "./Types"; -import { AudioField, ImageField, PdfField, VideoField, WebField } from "./URLField"; +import { AudioField, ImageField, MapField, PdfField, VideoField, WebField } from "./URLField"; import { deleteProperty, GetEffectiveAcl, getField, getter, inheritParentAcls, makeEditable, makeReadOnly, normalizeEmail, setter, SharingPermissions, updateFunction } from "./util"; import JSZip = require("jszip"); import { CurrentUserUtils } from "../client/util/CurrentUserUtils"; @@ -638,6 +638,7 @@ export namespace Doc { else if (value instanceof AudioField) return { url: value.url.href, __type: "audio" }; else if (value instanceof VideoField) return { url: value.url.href, __type: "video" }; else if (value instanceof WebField) return { url: value.url.href, __type: "web" }; + else if (value instanceof MapField) return { url: value.url.href, __type: "map" }; else if (value instanceof DateField) return { date: value.toString(), __type: "date" }; else if (value instanceof ProxyField) return { fieldId: value.fieldId, __type: "proxy" }; else if (value instanceof Array && key !== "fields") return { fields: value, __type: "list" }; @@ -1240,6 +1241,7 @@ export namespace Doc { case DocumentType.INK: return "pen-nib"; case DocumentType.PDF: return "file-pdf"; case DocumentType.LINK: return "link"; + case DocumentType.MAP: return "map-marker-alt" default: return "question"; } } diff --git a/src/fields/URLField.ts b/src/fields/URLField.ts index d96e8a70a..4d3776a2c 100644 --- a/src/fields/URLField.ts +++ b/src/fields/URLField.ts @@ -57,6 +57,7 @@ export const nullAudio = "https://actions.google.com/sounds/v1/alarms/beep_short @scriptingGlobal @Deserializable("video") export class VideoField extends URLField { } @scriptingGlobal @Deserializable("pdf") export class PdfField extends URLField { } @scriptingGlobal @Deserializable("web") export class WebField extends URLField { } +@scriptingGlobal @Deserializable("map") export class MapField extends URLField { } @scriptingGlobal @Deserializable("youtube") export class YoutubeField extends URLField { } @scriptingGlobal @Deserializable("webcam") export class WebCamField extends URLField { } diff --git a/src/fields/documentSchemas.ts b/src/fields/documentSchemas.ts index db2c6ca5b..c35c52699 100644 --- a/src/fields/documentSchemas.ts +++ b/src/fields/documentSchemas.ts @@ -26,6 +26,8 @@ export const documentSchema = createSchema({ z: "number", // z "coordinate" - non-zero specifies the overlay layer of a freeformview zIndex: "number", // zIndex of a document in a freeform view _scrollTop: "number", // scroll position of a scrollable document (pdf, text, web) + lat: "number", + lng: "number", // appearance properties on the layout "_backgroundGrid-spacing": "number", // the size of the grid for collection views diff --git a/src/server/ApiManagers/DownloadManager.ts b/src/server/ApiManagers/DownloadManager.ts index 0d4472fdc..2175b6db6 100644 --- a/src/server/ApiManagers/DownloadManager.ts +++ b/src/server/ApiManagers/DownloadManager.ts @@ -112,7 +112,7 @@ async function getDocs(id: string) { const pathname = new URL(urlString).pathname; files.add(pathname); } - } else if (["audio", "image", "video", "pdf", "web"].includes(field.__type)) { + } else if (["audio", "image", "video", "pdf", "web", "map"].includes(field.__type)) { const url = new URL(field.url); const pathname = url.pathname; files.add(pathname); diff --git a/src/server/ApiManagers/SearchManager.ts b/src/server/ApiManagers/SearchManager.ts index 775e90520..a74e13a62 100644 --- a/src/server/ApiManagers/SearchManager.ts +++ b/src/server/ApiManagers/SearchManager.ts @@ -185,6 +185,7 @@ export namespace SolrManager { "pdf": ["_t", "url"], "audio": ["_t", "url"], "web": ["_t", "url"], + "map": ["_t", "url"], "date": ["_d", value => new Date(value.date).toISOString()], "proxy": ["_i", "fieldId"], "prefetch_proxy": ["_i", "fieldId"], diff --git a/src/server/GarbageCollector.ts b/src/server/GarbageCollector.ts index 7c441e3c0..c60880882 100644 --- a/src/server/GarbageCollector.ts +++ b/src/server/GarbageCollector.ts @@ -45,7 +45,7 @@ function addDoc(doc: any, ids: string[], files: { [name: string]: string[] }) { } exts.push(ext); } - } else if (["audio", "image", "video", "pdf", "web"].includes(field.__type)) { + } else if (["audio", "image", "video", "pdf", "web", "map"].includes(field.__type)) { const url = new URL(field.url); const pathname = url.pathname; const ext = path.extname(pathname); diff --git a/src/server/remapUrl.ts b/src/server/remapUrl.ts index 7178add93..e9f9da25a 100644 --- a/src/server/remapUrl.ts +++ b/src/server/remapUrl.ts @@ -8,7 +8,8 @@ const suffixMap: { [type: string]: true } = { "pdf": true, "audio": true, "web": true, - "image": true + "image": true, + "map": true, }; async function update() { diff --git a/src/server/updateProtos.ts b/src/server/updateProtos.ts index e9860bd61..c5552f6bf 100644 --- a/src/server/updateProtos.ts +++ b/src/server/updateProtos.ts @@ -1,7 +1,7 @@ import { Database } from "./database"; const protos = - ["text", "image", "web", "collection", "kvp", "video", "audio", "pdf", "icon", "import", "linkdoc"]; + ["text", "image", "web", "collection", "kvp", "video", "audio", "pdf", "icon", "import", "linkdoc", "map"]; (async function () { await Promise.all( diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 224a9eefb..13d7237f6 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -229,6 +229,7 @@ export namespace WebSocket { "pdf": ["_t", "url"], "audio": ["_t", "url"], "web": ["_t", "url"], + "map": ["_t", "url"], "script": ["_t", value => value.script.originalScript], "RichTextField": ["_t", value => value.Text], "date": ["_d", value => new Date(value.date).toISOString()], |