diff --git a/README.md b/README.md index 58294c6..fd8570e 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ You can set different icons for different note types, filter the displayed notes It also provides a wide range of tools to add geolocations to your notes, including address searches and parsing of URLs from external sources such as Google Maps. -![](sample.png) +![](img/sample.png) -![](intro.gif) +![](img/intro.gif) The plugin's guiding philosophy and goal is to provide a **personal GIS system** as a complementary view for your notes. I wrote it because I wanted my ever-growing Zettelkasten to be able to answer questions like... @@ -93,7 +93,7 @@ Map View adds an Obsidian command named "New geolocation note", which you can ma This opens a dialog on which you can search (address or location based on your [configured geocoding provider](#changing-a-geocoding-provider)) or paste a URL using the built-in or custom [URL parsing rules](#url-parsing-rules). -![](new-note-popup.gif) +![](img/new-note-popup.gif) ### In an Existing Note @@ -113,15 +113,15 @@ The map offers several tools to create notes. 1. Use "new note here" when right-clicking the map. This will create a new note (based on the template you can change in the settings) with the location you clicked. You can create either an empty note with a front matter (single geolocation) or an empty note with an inline geolocation. -![](new-note.png) +![](img/new-note.png) Note that the map can be searched using the tool on the upper-right side. -![](search.png) +![](img/search.png) 2. If you prefer to enter geolocations as text, use one of the "copy geolocation" options when you right-click the map. If you use "copy geolocation", just remember you need the note to start with a front matter that has an empty `locations:` line. -![](copy.png) +![](img/copy.png) ## Paste as Geolocation @@ -155,7 +155,7 @@ A single marker is defined with a *tag pattern* and *icon details*. The tag pattern is usually a tag name (e.g. `#dogs`), but it can also be with a wildcard (e.g. `#trips/*`). Icon details are a few properties: icon name (taken from the Font Awesome catalog), color and shape. -![](marker-rules.png) +![](img/marker-rules.png) A single marker is defined in the following JSON structure: `{"prefix": "fas", "icon": "fa-bus", "shape": "circle", "color": "red"}` @@ -195,7 +195,7 @@ This command inserts an empty inline location template: `[](geo:)`. When editing an inline location in this format, whether if you added it manually or using the command, if you start entering a link name, Map View will start offering locations based on a geocoding service. Selecting one of the suggestions will fill-in the coordinates of the chosen locations, *not* change your link name (assuming you prefer your own name rather than the formal one offered by the geocoding service), and jump the cursor to beyond the link so you can continue typing. -![](geosearch-suggest.gif) +![](img/geosearch-suggest.gif) If your note is not yet marked as one including locations (by a `locations:`) tag in the front matter, this is added automatically. @@ -230,7 +230,7 @@ If a dark theme is detected, or if you specifically change the map source type t Many context menus of Map View display a customizable Open In list, which can open a given location in external sources. These sources can be Google Maps, OpenStreetMap, specialized mapping tools or pretty much anything you use for viewing locations. -![](open-in.png) +![](img/open-in.png) The Open In list is shown: - When right-clicking on the map. @@ -240,7 +240,7 @@ The Open In list is shown: This list can be edited through the plugin's settings menu, with a name that will be displayed in the context menus and a URL pattern. The URL pattern has two parameters -- `{x}` and `{y}` -- that will be replaced by the latitude and longitude of the clicked location. -![](custom-open-in.png) +![](img/custom-open-in.png) Popular choices may be: - Google Maps: `https://maps.google.com/?q={x},{y}` @@ -263,7 +263,7 @@ The syntax expects two captures group and you can configure if they are parsed a And if you think your added regular expressions are solid enough, please add them to the plugin using a PR so others can benefit! -![](url-parsing.png) +![](img/url-parsing.png) ## Relation to Other Obsidian Plugins diff --git a/convert-command.png b/img/convert-command.png similarity index 100% rename from convert-command.png rename to img/convert-command.png diff --git a/convert-to-location.png b/img/convert-to-location.png similarity index 100% rename from convert-to-location.png rename to img/convert-to-location.png diff --git a/copy.png b/img/copy.png similarity index 100% rename from copy.png rename to img/copy.png diff --git a/custom-open-in.png b/img/custom-open-in.png similarity index 100% rename from custom-open-in.png rename to img/custom-open-in.png diff --git a/geosearch-suggest.gif b/img/geosearch-suggest.gif similarity index 100% rename from geosearch-suggest.gif rename to img/geosearch-suggest.gif diff --git a/intro.gif b/img/intro.gif similarity index 100% rename from intro.gif rename to img/intro.gif diff --git a/marker-rules.png b/img/marker-rules.png similarity index 100% rename from marker-rules.png rename to img/marker-rules.png diff --git a/new-note-popup.gif b/img/new-note-popup.gif similarity index 100% rename from new-note-popup.gif rename to img/new-note-popup.gif diff --git a/new-note.png b/img/new-note.png similarity index 100% rename from new-note.png rename to img/new-note.png diff --git a/open-in.png b/img/open-in.png similarity index 100% rename from open-in.png rename to img/open-in.png diff --git a/sample.png b/img/sample.png similarity index 100% rename from sample.png rename to img/sample.png diff --git a/search.png b/img/search.png similarity index 100% rename from search.png rename to img/search.png diff --git a/url-parsing.png b/img/url-parsing.png similarity index 100% rename from url-parsing.png rename to img/url-parsing.png diff --git a/package.json b/package.json index 7d42504..0c58093 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "An interactive map view for Obsidian.md", "main": "main.js", "scripts": { - "dev": "rollup --config rollup.config.js -w", + "dev": "rollup --config rollup.config.js -w --environment BUILD:development", "build": "rollup --config rollup.config.js --environment BUILD:production" }, "keywords": [], diff --git a/rollup.config.js b/rollup.config.js index 3f58be0..d290ffb 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,7 +17,7 @@ if you want to view the source visit the plugins github repository export default { input: 'src/main.ts', output: { - dir: './dist', + dir: process.env.BUILD === 'development' ? '.' : './dist', sourcemap: isProd ? false : 'inline', sourcemapExcludeSources: isProd, format: 'cjs', @@ -30,11 +30,13 @@ export default { nodeResolve({browser: true}), commonjs(), postcss({ extensions: ['.css'], plugins: [postcss_url({url: 'inline'})] }), - copy({ - targets: [ - { src: './manifest.json', dest: 'dist' }, - { src: './styles.css', dest: 'dist' } - ] - }) + ...(process.env.BUILD !== 'development' ? [ + copy({ + targets: [ + { src: './manifest.json', dest: 'dist' }, + { src: './styles.css', dest: 'dist' } + ] + }) + ] : []), ] }; diff --git a/src/consts.ts b/src/consts.ts index e1fefd7..ae65f11 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -3,7 +3,7 @@ import * as leaflet from 'leaflet'; export const MAP_VIEW_NAME = 'map'; // SVG editor used: https://svgedit.netlify.app/editor/index.html -export const RIBBON_ICON = ''; +export const GLOBE_ICON = ''; export const TILES_URL_OPENSTREETMAP = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; export const SEARCH_RESULT_MARKER = {prefix: 'fas', icon: 'fa-search', markerColor: 'blue'}; diff --git a/src/coordinateParser.ts b/src/coordinateParser.ts new file mode 100644 index 0000000..882315a --- /dev/null +++ b/src/coordinateParser.ts @@ -0,0 +1,109 @@ +import { App, Editor, EditorPosition } from 'obsidian'; + +import * as leaflet from 'leaflet'; +import { PluginSettings } from 'src/settings'; +import * as utils from 'src/utils'; + + +interface FindResult { + location: leaflet.LatLng; + index: number, + matchLength: number, + ruleName: string +} + +/** A class to convert a string (usually a URL) into coordinate format */ +export class CoordinateParser { + private settings: PluginSettings; + + /** + * construct an instance of CoordinateParser + * @param app The obsidian App instance + * @param settings The plugin settings + */ + constructor(app: App, settings: PluginSettings) { + this.settings = settings; + } + + /** + * Get coordinate from an encoded string (usually a URL). + * Will try each url parsing rule until one succeeds. + * @param str The string to decode + */ + parseString(str: string): FindResult | null { + for (const rule of this.settings.urlParsingRules) { + const regexp = RegExp(rule.regExp, 'g'); + const results = str.matchAll(regexp); + for (let result of results) { + try { + return { + location: new leaflet.LatLng(parseFloat(result[1]), parseFloat(result[2])), + index: result.index, + matchLength: result[0].length, + ruleName: rule.name + }; + } + catch (e) { } + } + } + return null; + } + + /** + * Parse the line where the cursor is in the editor + * @param editor The obsidian Editor instance to use + */ + parseEditorLine(editor: Editor): FindResult | null { + const cursor = editor.getCursor(); + const line = editor.getLine(cursor.line); + return this.parseString(line); + } + + /** + * Insert a geo link into the editor at the cursor position + * @param editor The obsidian Editor instance + * @param coordinate The coordinate to insert into the editor + * @param replaceStart The EditorPosition to start the replacement at. If null will replace any text selected + * @param replaceEnd The EditorPosition to stop the replacement at. If null will replace any text selected + */ + editorInsertGeolocation(editor: Editor, coordinate: leaflet.LatLng, replaceStart?: EditorPosition, replaceEnd?: EditorPosition) { + const locationString = `[](geo:${coordinate.lat},${coordinate.lng})`; + let newCursorPos: EditorPosition; + if (replaceStart && replaceEnd) { + editor.replaceRange(locationString, replaceStart, replaceEnd); + newCursorPos = {line: replaceStart.line, ch: replaceStart.ch + 1}; + } else { + const cursor = editor.getCursor(); + editor.replaceSelection(locationString); + newCursorPos = {line: cursor.line, ch: cursor.ch + 1}; + } + + // Put the cursor after the opening square bracket + editor.setCursor(newCursorPos); + // TODO: This will modify in a second operation. + // Is there a way to make it apply in one operation so that one undo undoes both changes + utils.frontMatterSetDefault(editor, 'locations'); + } + + /** + * Replace the text at the cursor location with a geo link + * @param editor The obsidian Editor instance + */ + editorLineToGeolocation(editor: Editor): void { + const result = this.parseEditorLine(editor) + if (result) { + this.editorInsertGeolocation( + editor, + result.location, + { + line: editor.getCursor().line, + ch: result.index + }, + { + line: editor.getCursor().line, + ch: result.index + result.matchLength + } + ); + } + } +} diff --git a/src/geosearch.ts b/src/geosearch.ts index 6c1870d..771abe0 100644 --- a/src/geosearch.ts +++ b/src/geosearch.ts @@ -58,7 +58,7 @@ export class LocationSuggest extends EditorSuggest { finalResult, {line: currentCursor.line, ch: linkOfCursor.index}, {line: currentCursor.line, ch: linkOfCursor.linkEnd}); - if (utils.verifyOrAddFrontMatter(value.context.editor, 'locations', '')) + if (utils.frontMatterSetDefault(value.context.editor, 'locations')) new Notice("The note's front matter was updated to denote locations are present"); } @@ -100,7 +100,7 @@ export class LocationSuggest extends EditorSuggest { const firstResult = results[0]; editor.replaceSelection(`[${selection}](geo:${firstResult.y},${firstResult.x})`); new Notice(firstResult.label, 10 * 1000); - if (utils.verifyOrAddFrontMatter(editor, 'locations', '')) + if (utils.frontMatterSetDefault(editor, 'locations')) new Notice("The note's front matter was updated to denote locations are present"); } else { diff --git a/src/main.ts b/src/main.ts index 0bb257a..dbb0019 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,39 +2,49 @@ import { addIcon, Notice, Editor, FileView, MarkdownView, MenuItem, Menu, TFile, import * as consts from 'src/consts'; import * as leaflet from 'leaflet'; import { LocationSuggest } from 'src/geosearch'; -import { UrlConvertor } from 'src/urlConvertor'; +import { CoordinateParser } from 'src/coordinateParser'; import { MapView } from 'src/mapView'; import { PluginSettings, DEFAULT_SETTINGS, convertLegacyMarkerIcons, convertLegacyTilesUrl } from 'src/settings'; -import { getFrontMatterLocation, matchInlineLocation, verifyLocation } from 'src/markers'; +import { getFrontMatterCoordinate, matchInlineCoordinates, verifyLocation } from 'src/markers'; import { SettingsTab } from 'src/settingsTab'; import { NewNoteDialog } from 'src/newNoteDialog'; import * as utils from 'src/utils'; +/** A plugin to implement map support */ export default class MapViewPlugin extends Plugin { settings: PluginSettings; public highestVersionSeen: number = 0; private suggestor: LocationSuggest; - private urlConvertor: UrlConvertor; + private coordinateParser: CoordinateParser; async onload() { - addIcon('globe', consts.RIBBON_ICON); + // When the plugin loads + // Register a new icon + addIcon('globe', consts.GLOBE_ICON); + + // Load the settings await this.loadSettings(); + // Add a new ribbon entry to the left bar this.addRibbonIcon('globe', 'Open map view', () => { + // when clicked change the active view to the map this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); }); + // Register a new viewer for maps this.registerView(consts.MAP_VIEW_NAME, (leaf: WorkspaceLeaf) => { + // Create a new map instance return new MapView(leaf, this.settings, this); }); this.suggestor = new LocationSuggest(this.app, this.settings); - this.urlConvertor = new UrlConvertor(this.app, this.settings); + this.coordinateParser = new CoordinateParser(this.app, this.settings); this.registerEditorSuggest(this.suggestor); + // convert old data if (convertLegacyMarkerIcons(this.settings)) { await this.saveSettings(); new Notice("Map View: legacy marker icons were converted to the new format"); @@ -44,24 +54,31 @@ export default class MapViewPlugin extends Plugin { new Notice("Map View: legacy tiles URL was converted to the new format"); } + // Register commands to the command palette + // command that opens the map view (same as clicking the map icon) this.addCommand({ id: 'open-map-view', name: 'Open Map View', callback: () => { + // The command to run this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); }, }); + // command that looks up the selected text to find the location this.addCommand({ id: 'convert-selection-to-location', name: 'Convert Selection to Geolocation', editorCheckCallback: (checking, editor, view) => { + // This is run once when building the command list and again when it is actually run + // In the former checking is true and in the latter it is false if (checking) return editor.getSelection().length > 0; this.suggestor.selectionToLink(editor); } }); + // command that adds a blank inline location at the cursor location this.addCommand({ id: 'insert-geolink', name: 'Add inline geolocation link', @@ -72,6 +89,7 @@ export default class MapViewPlugin extends Plugin { } }); + // command that opens the location search dialog and creates a new note from this location this.addCommand({ id: 'new-geolocation-note', name: 'New geolocation note', @@ -81,6 +99,7 @@ export default class MapViewPlugin extends Plugin { } }); + // command that opens the location search dialog and adds the location to the current note this.addCommand({ id: 'add-frontmatter-geolocation', name: 'Add geolocation (front matter) to current note', @@ -92,87 +111,105 @@ export default class MapViewPlugin extends Plugin { this.addSettingTab(new SettingsTab(this.app, this)); + // Modify the file context menu (run when the context menu is built) this.app.workspace.on('file-menu', (menu: Menu, file: TAbstractFile, _source: string, leaf?: WorkspaceLeaf) => { if (file instanceof TFile) { - const location = getFrontMatterLocation(file, this.app); - if (location) { + const coordinate = getFrontMatterCoordinate(file, this.app); + if (coordinate) { + // If there is a coordinate in the front matter of the file + // add an option to open it in the map menu.addItem((item: MenuItem) => { item.setTitle('Show on map'); item.setIcon('globe'); - item.onClick(async (evt: MouseEvent) => await this.openMapWithLocation(location, evt.ctrlKey)); + item.onClick(async (evt: MouseEvent) => await this.openMapAtCoordinate(coordinate, evt.ctrlKey)); }); + // add an option to open it in the default app menu.addItem((item: MenuItem) => { item.setTitle('Open with default app'); item.onClick(_ev => { - open(`geo:${location.lat},${location.lng}`); + open(`geo:${coordinate.lat},${coordinate.lng}`); }); }); - utils.populateOpenInItems(menu, location, this.settings); - } else { - if (leaf && leaf.view instanceof MarkdownView) { - const editor = leaf.view.editor; - menu.addItem((item: MenuItem) => { - item.setTitle('Add geolocation (front matter)'); - item.setIcon('globe'); - item.onClick(async (evt: MouseEvent) => { - const dialog = new NewNoteDialog(this.app, this.settings, 'addToNote', editor); - dialog.open(); - }); + // populate user defined url formats + utils.populateOpenInItems(menu, coordinate, this.settings); + } else if (leaf && leaf.view instanceof MarkdownView) { + // If there is no valid location field in the file + // and the context menu came from the leaf (three dots in the top right) + const editor = leaf.view.editor; + // add a menu item to create the front matter + menu.addItem((item: MenuItem) => { + item.setTitle('Add geolocation (front matter)'); + item.setIcon('globe'); + item.onClick(async (evt: MouseEvent) => { + const dialog = new NewNoteDialog(this.app, this.settings, 'addToNote', editor); + dialog.open(); }); - - } + }); } } }); + // Modify the editor context menu (run when the context menu is built) this.app.workspace.on('editor-menu', async (menu: Menu, editor: Editor, view: MarkdownView) => { if (view instanceof FileView) { - const location = this.getLocationOnEditorLine(editor, view); - if (location) { + const coordinate = this.getEditorLineCoordinate(editor, view); + if (coordinate) { + // If there is a coordinate one the line + // add an option to open it in the map menu.addItem((item: MenuItem) => { item.setTitle('Show on map'); item.setIcon('globe'); - item.onClick(async (evt: MouseEvent) => await this.openMapWithLocation(location, evt.ctrlKey)); + item.onClick(async (evt: MouseEvent) => await this.openMapAtCoordinate(coordinate, evt.ctrlKey)); }); + // add an option to open it in the default app menu.addItem((item: MenuItem) => { item.setTitle('Open with default app'); item.onClick(_ev => { - open(`geo:${location.lat},${location.lng}`); + open(`geo:${coordinate.lat},${coordinate.lng}`); }); }); - utils.populateOpenInItems(menu, location, this.settings); + // populate user defined url formats + utils.populateOpenInItems(menu, coordinate, this.settings); } if (editor.getSelection()) { + // If there is text selected add a menu item to convert it to coordinates menu.addItem((item: MenuItem) => { item.setTitle('Convert to geolocation (geosearch)'); item.onClick(async () => await this.suggestor.selectionToLink(editor)); }); } - if (this.urlConvertor.findMatchInLine(editor)) + if (this.coordinateParser.parseEditorLine(editor)) + // if the line contains a valid string coordinate menu.addItem((item: MenuItem) => { item.setTitle('Convert to geolocation'); item.onClick(async () => { - this.urlConvertor.convertUrlAtCursorToGeolocation(editor); + this.coordinateParser.editorLineToGeolocation(editor); }); }) const clipboard = await navigator.clipboard.readText(); - const clipboardLocation = this.urlConvertor.parseLocationFromUrl(clipboard)?.location; + const clipboardLocation = this.coordinateParser.parseString(clipboard)?.location; if (clipboardLocation) { + // if the clipboard contains a valid string coordinate menu.addItem((item: MenuItem) => { item.setTitle('Paste as geolocation'); item.onClick(async () => { - this.urlConvertor.insertLocationToEditor(clipboardLocation, editor); + this.coordinateParser.editorInsertGeolocation(editor, clipboardLocation); }); }) } } }); - } - private async openMapWithLocation(location: leaflet.LatLng, ctrlKey: boolean) { + /** + * Open an instance of the map at the given coordinate + * @param coordinate The coordinate to open the map at + * @param ctrlKey Was the control key pressed. If true will open a map in the current leaf rather than using an open map. + * @private + */ + private async openMapAtCoordinate(coordinate: leaflet.LatLng, ctrlKey: boolean) { // Find the best candidate for a leaf to open the map view on. // If there's an open map view, use that, otherwise use the current leaf. // If Ctrl is pressed, override that behavior and always use the current leaf. @@ -188,20 +225,27 @@ export default class MapViewPlugin extends Plugin { type: consts.MAP_VIEW_NAME, state: { version: this.highestVersionSeen + 1, // Make sure this overrides any existing state - mapCenter: location, + mapCenter: coordinate, mapZoom: this.settings.zoomOnGoFromNote - } as any}); + } as any + }); } - private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { + /** + * Get the coordinate on the current editor line + * @param editor obsidian Editor instance + * @param view obsidian FileView instance + * @private + */ + private getEditorLineCoordinate(editor: Editor, view: FileView): leaflet.LatLng { const line = editor.getLine(editor.getCursor().line); - const match = matchInlineLocation(line)[0]; + const match = matchInlineCoordinates(line)[0]; let selectedLocation = null; if (match) - selectedLocation = new leaflet.LatLng(parseFloat(match[2]), parseFloat(match[3])); + selectedLocation = new leaflet.LatLng(parseFloat(match.groups.lat), parseFloat(match.groups.long)); else { - const fmLocation = getFrontMatterLocation(view.file, this.app); + const fmLocation = getFrontMatterCoordinate(view.file, this.app); if (line.indexOf('location') > -1 && fmLocation) selectedLocation = fmLocation; } @@ -215,10 +259,12 @@ export default class MapViewPlugin extends Plugin { onunload() { } + /** Load the settings from disk */ async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } + /** Save the settings to disk */ async saveSettings() { await this.saveData(this.settings); } diff --git a/src/mapView.ts b/src/mapView.ts index 1e226d4..6c0d06f 100644 --- a/src/mapView.ts +++ b/src/mapView.ts @@ -1,11 +1,11 @@ -import { App, TAbstractFile, Editor, ButtonComponent, MarkdownView, getAllTags, ItemView, MenuItem, Menu, TFile, TextComponent, DropdownComponent, WorkspaceLeaf } from 'obsidian'; +import {TAbstractFile, Editor, ButtonComponent, getAllTags, ItemView, MenuItem, Menu, TFile, TextComponent, DropdownComponent, WorkspaceLeaf, ViewStateResult} from 'obsidian'; import * as leaflet from 'leaflet'; // Ugly hack for obsidian-leaflet compatability, see https://github.com/esm7/obsidian-map-view/issues/6 // @ts-ignore import * as leafletFullscreen from 'leaflet-fullscreen'; import '@fortawesome/fontawesome-free/js/all.min'; import 'leaflet/dist/leaflet.css'; -import { GeoSearchControl, OpenStreetMapProvider } from 'leaflet-geosearch'; +import { GeoSearchControl } from 'leaflet-geosearch'; import 'leaflet-geosearch/dist/geosearch.css'; import 'leaflet.markercluster/dist/MarkerCluster.css'; import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; @@ -13,40 +13,84 @@ import 'leaflet.markercluster'; import * as consts from 'src/consts'; import { PluginSettings, MapLightDark, DEFAULT_SETTINGS } from 'src/settings'; -import { MarkersMap, FileMarker, buildMarkers, getIconFromOptions, buildAndAppendFileMarkers } from 'src/markers'; +import { MarkersMap, BaseGeoLayer, buildMarkers, getIconFromOptions, buildAndAppendGeoLayers } from 'src/markers'; import { LocationSuggest } from 'src/geosearch'; import MapViewPlugin from 'src/main'; import * as utils from 'src/utils'; +/** The state of the map instance */ type MapState = { + /** The zoom level of the map */ mapZoom: number; + /** The viewed center of the map */ mapCenter: leaflet.LatLng; + /** The tags that the user specified */ tags: string[]; + /** The version of the state to work out which is newest */ version: number; -} +} +/** The map viewer class */ export class MapView extends ItemView { - private settings: PluginSettings; - // The private state needs to be updated solely via updateMapToState + /** The settings for the plugin */ + readonly settings: PluginSettings; + /** + * The default map state + * @private + */ + private defaultState: MapState; + /** + * The private map state. Must only be updated in setMapState + * @private + */ private state: MapState; + /** + * The map data + * @private + */ private display = new class { + /** The leaflet map instance for this map viewer */ map: leaflet.Map; + /** The cluster management class */ clusterGroup: leaflet.MarkerClusterGroup; + /** The markers currently on the map */ markers: MarkersMap = new Map(); + /** The HTML element containing the map controls */ controlsDiv: HTMLDivElement; + /** The HTML element holding the map */ mapDiv: HTMLDivElement; + /** The HTML text entry the user can type tags in */ tagsBox: TextComponent; }; + /** + * The plugin instance + * @private + */ private plugin: MapViewPlugin; - private defaultState: MapState; + /** + * The leaf (obsidian sub-window) that a note was last opened in. + * This is cached so that it can be reused when opening notes in the future + * @private + */ private newPaneLeaf: WorkspaceLeaf; + /** + * Has the view been opened + * @private + */ private isOpen: boolean = false; + /** TODO: unused what does this do? */ public onAfterOpen: (map: leaflet.Map, markers: MarkersMap) => any = null; + /** + * Construct a new map instance + * @param leaf The leaf the map should be put in + * @param settings The plugin settings + * @param plugin The plugin instance + */ constructor(leaf: WorkspaceLeaf, settings: PluginSettings, plugin: MapViewPlugin) { super(leaf); - this.navigation = true; + this.navigation = true; // TODO: unused? this.settings = settings; this.plugin = plugin; // Create the default state by the configuration @@ -56,39 +100,61 @@ export class MapView extends ItemView { tags: this.settings.defaultTags || consts.DEFAULT_TAGS, version: 0 }; - this.setState = async (state: MapState, result) => { - if (state) { - if (!state.version) { - // We give the given state priority by setting a high version - state.version = this.plugin.highestVersionSeen + 1; - } - if (!state.mapCenter || !state.mapZoom) { - state.mapCenter = this.defaultState.mapCenter; - state.mapZoom = this.defaultState.mapZoom; - } - await this.updateMapToState(state, false); - } - } - this.getState = (): MapState => { - return this.state; - } - this.app.vault.on('delete', file => this.updateMarkersWithRelationToFile(file.path, null, true)); - this.app.vault.on('rename', (file, oldPath) => this.updateMarkersWithRelationToFile(oldPath, file, true)); - this.app.metadataCache.on('changed', file => this.updateMarkersWithRelationToFile(file.path, file, false)); + // Listen to file changes so we can rebuild the UI + this.app.vault.on( + 'delete', + file => this.onFileChange(file.path, null, true) + ); + this.app.vault.on( + 'rename', + (file, oldPath) => this.onFileChange(oldPath, file, true) + ); + this.app.metadataCache.on( + 'changed', + file => this.onFileChange(file.path, file, false) + ); this.app.workspace.on('css-change', () => { console.log('Map view: map refresh due to CSS change'); this.refreshMap(); }); } - getViewType() { return 'map'; } + /** + * Set the maps state. Used as part of the forwards/backwards arrows + * @param state The map state to set + * @param result + */ + async setState(state: MapState, result: ViewStateResult): Promise { + if (state) { + if (!state.version) { + // We give the given state priority by setting a high version + state.version = this.plugin.highestVersionSeen + 1; + } + if (!state.mapCenter || !state.mapZoom) { + state.mapCenter = this.defaultState.mapCenter; + state.mapZoom = this.defaultState.mapZoom; + } + await this.setMapState(state, false); + } + } + + /** Get the maps state. Used as part of the forwards/backwards arrows */ + async getState(): Promise { + return this.state; + } + + /** The name of the view type */ + getViewType() { return consts.MAP_VIEW_NAME; } + + /** The display name for the view */ getDisplayText() { return 'Interactive Map View'; } - isDarkMode(settings: PluginSettings): boolean { - if (settings.chosenMapMode === 'dark') + /** Is the map in dark mode */ + isDarkMode(): boolean { + if (this.settings.chosenMapMode === 'dark') return true; - if (settings.chosenMapMode === 'light') + if (this.settings.chosenMapMode === 'light') return false; // Auto mode - check if the theme is dark if ((this.app.vault as any).getConfig('theme') === 'obsidian') @@ -98,6 +164,7 @@ export class MapView extends ItemView { public updateMapSources = () => {}; + /** Run when the view is opened */ async onOpen() { this.isOpen = true; this.state = this.defaultState; @@ -116,11 +183,14 @@ export class MapView extends ItemView { return super.onOpen(); } + /** Set up the map controls */ createControls() { - var that = this; + // html div container for the controls let controlsDiv = createDiv({ 'cls': 'graph-controls' }); + + // html div container for the filter entries let filtersDiv = controlsDiv.createDiv({'cls': 'graph-control-div'}); filtersDiv.innerHTML = ` @@ -133,12 +203,16 @@ export class MapView extends ItemView { this.plugin.saveSettings(); } let filtersContent = filtersDiv.createDiv({'cls': 'graph-control-content'}); + + // tag text entry this.display.tagsBox = new TextComponent(filtersContent); this.display.tagsBox.setPlaceholder('Tags, e.g. "#one,#two"'); this.display.tagsBox.onChange(async (tagsBox: string) => { - that.state.tags = tagsBox.split(',').filter(t => t.length > 0); - await this.updateMapToState(this.state, this.settings.autoZoom); + this.state.tags = tagsBox.split(',').filter(t => t.length > 0); + await this.setMapState(this.state, this.settings.autoZoom); }); + + // tag drop down let tagSuggestions = new DropdownComponent(filtersContent); tagSuggestions.setValue('Quick add tag'); tagSuggestions.addOption('', 'Quick add tag'); @@ -154,6 +228,7 @@ export class MapView extends ItemView { this.display.tagsBox.onChanged(); }); + // html div container for the view entries let viewDiv = controlsDiv.createDiv({'cls': 'graph-control-div'}); viewDiv.innerHTML = ` @@ -165,6 +240,8 @@ export class MapView extends ItemView { this.settings.mapControls.viewDisplayed = viewButton.checked; this.plugin.saveSettings(); } + + // map source drop down let viewDivContent = viewDiv.createDiv({'cls': 'graph-control-content'}); let mapSource = new DropdownComponent(viewDivContent); for (const [index, source] of this.settings.mapSources.entries()) { @@ -178,6 +255,8 @@ export class MapView extends ItemView { }); const chosenMapSource = this.settings.chosenMapSource ?? 0; mapSource.setValue(chosenMapSource.toString()); + + // the map theme drop down let sourceMode = new DropdownComponent(viewDivContent); sourceMode.addOptions({auto: 'Auto', light: 'Light', dark: 'Dark'}) .setValue(this.settings.chosenMapMode ?? 'auto') @@ -186,6 +265,8 @@ export class MapView extends ItemView { await this.plugin.saveSettings(); this.refreshMap(); }); + + // The reset view button let goDefault = new ButtonComponent(viewDivContent); goDefault .setButtonText('Reset') @@ -197,13 +278,17 @@ export class MapView extends ItemView { tags: this.settings.defaultTags || consts.DEFAULT_TAGS, version: this.state.version + 1 }; - await this.updateMapToState(newState, false); + await this.setMapState(newState, false); }); + + // The fit view button let fitButton = new ButtonComponent(viewDivContent); fitButton .setButtonText('Fit') .setTooltip('Set the map view to fit all currently-displayed markers.') .onClick(() => this.autoFitMapToMarkers()); + + // The set view as default button let setDefault = new ButtonComponent(viewDivContent); setDefault .setButtonText('Set as Default') @@ -214,41 +299,51 @@ export class MapView extends ItemView { this.settings.defaultTags = this.state.tags; await this.plugin.saveSettings(); }); + this.contentEl.style.padding = '0px 0px'; this.contentEl.append(controlsDiv); return controlsDiv; } + /** On view close */ onClose() { this.isOpen = false; return super.onClose(); } + /** On window resize */ onResize() { this.display.map.invalidateSize(); } async refreshMap() { + // remove all event listeners this.display?.map?.off(); + // destroy the map and event listeners this.display?.map?.remove(); + // clear the marker storage this.display?.markers?.clear(); this.display?.controlsDiv.remove(); this.display.controlsDiv = this.createControls(); this.createMap(); - this.updateMapToState(this.state, false, true); + this.setMapState(this.state, false, true); } + /** Create the leaflet map */ async createMap() { - var that = this; - const isDark = this.isDarkMode(this.settings); + const isDark = this.isDarkMode(); // LeafletJS compatability: disable tree-shaking for the full-screen module var dummy = leafletFullscreen; - this.display.map = new leaflet.Map(this.display.mapDiv, { - center: this.defaultState.mapCenter, - zoom: this.defaultState.mapZoom, - zoomControl: false, - worldCopyJump: true, - maxBoundsViscosity: 1.0}); + this.display.map = new leaflet.Map( + this.display.mapDiv, + { + center: this.defaultState.mapCenter, + zoom: this.defaultState.mapZoom, + zoomControl: false, + worldCopyJump: true, + maxBoundsViscosity: 1.0 + } + ); leaflet.control.zoom({ position: 'topright' }).addTo(this.display.map); @@ -263,14 +358,18 @@ export class MapView extends ItemView { else revertMap = true; } - this.display.map.addLayer(new leaflet.TileLayer(mapSourceUrl, { - maxZoom: 20, - subdomains:['mt0','mt1','mt2','mt3'], - attribution: attribution, - className: revertMap ? "dark-mode" : "" - })); + this.display.map.addLayer( + new leaflet.TileLayer(mapSourceUrl, { + maxZoom: 25, + maxNativeZoom: 19, + subdomains:['mt0','mt1','mt2','mt3'], + attribution: attribution, + className: revertMap ? "dark-mode" : "" + }) + ); this.display.clusterGroup = new leaflet.MarkerClusterGroup({ - maxClusterRadius: this.settings.maxClusterRadiusPixels ?? DEFAULT_SETTINGS.maxClusterRadiusPixels}); + maxClusterRadius: this.settings.maxClusterRadiusPixels ?? DEFAULT_SETTINGS.maxClusterRadiusPixels + }); this.display.map.addLayer(this.display.clusterGroup); const suggestor = new LocationSuggest(this.app, this.settings); const searchControl = GeoSearchControl({ @@ -305,6 +404,8 @@ export class MapView extends ItemView { // this.display.clusterGroup.on('clustermouseout', cluster => { // // cluster.propagatedFrom.closePopup(); // }); + + // build the right click context menu this.display.map.on('contextmenu', async (event: leaflet.LeafletMouseEvent) => { let mapPopup = new Menu(this.app); mapPopup.setNoIcon(); @@ -353,12 +454,19 @@ export class MapView extends ItemView { }); } - // Updates the map to the given state and then sets the state accordingly, but only if the given state version - // is not lower than the current state version (so concurrent async updates always keep the latest one) - async updateMapToState(state: MapState, autoFit: boolean = false, force: boolean = false) { + /** + * Set the map state if the state version is not lower than the current state version + * (so concurrent async updates always keep the latest one) + * @param state The map state to set + * @param autoFit Should the view be fit to the markers + * @param force Force setting the state. Will ignore if the state is old + */ + async setMapState(state: MapState, autoFit: boolean = false, force: boolean = false) { if (this.settings.debug) - console.time('updateMapToState'); + console.time('setMapState'); + // get a list of all files matching the tags const files = this.getFileListByQuery(state.tags); + // build the tags for all files matching the tag let newMarkers = await buildMarkers(files, this.settings, this.app); if (state.version < this.state.version && !force) { // If the state we were asked to update is old (e.g. because while we were building markers a newer instance @@ -369,7 +477,7 @@ export class MapView extends ItemView { // Saying it again: do not use 'await' below this line! this.state = state; this.plugin.highestVersionSeen = Math.max(this.plugin.highestVersionSeen, this.state.version); - this.updateMapMarkers(newMarkers); + this.setMapMarkers(newMarkers); this.state.tags = this.state.tags || []; this.display.tagsBox.setValue(this.state.tags.filter(tag => tag.length > 0).join(',')); if (this.state.mapCenter && this.state.mapZoom) @@ -377,9 +485,13 @@ export class MapView extends ItemView { if (autoFit) this.autoFitMapToMarkers(); if (this.settings.debug) - console.timeEnd('updateMapToState'); + console.timeEnd('setMapState'); } + /** + * Get a list of files containing at least one of the tags + * @param tags A list of string tags to match + */ getFileListByQuery(tags: string[]): TFile[] { let results: TFile[] = []; const allFiles = this.app.vault.getFiles(); @@ -401,11 +513,15 @@ export class MapView extends ItemView { return results; } - updateMapMarkers(newMarkers: FileMarker[]) { + /** + * Set the markers on the map. + * @param markers The new array of FileMarkers + */ + setMapMarkers(markers: BaseGeoLayer[]) { let newMarkersMap: MarkersMap = new Map(); - let markersToAdd: leaflet.Marker[] = []; - let markersToRemove: leaflet.Marker[] = []; - for (let marker of newMarkers) { + let markersToAdd: leaflet.Layer[] = []; + let markersToRemove: leaflet.Layer[] = []; + for (let marker of markers) { const existingMarker = this.display.markers.has(marker.id) ? this.display.markers.get(marker.id) : null; if (existingMarker && existingMarker.isSame(marker)) { @@ -414,65 +530,40 @@ export class MapView extends ItemView { this.display.markers.delete(marker.id); } else { // New marker - create it - marker.mapMarker = this.newLeafletMarker(marker); - markersToAdd.push(marker.mapMarker); - newMarkersMap.set(marker.id, marker); + try { + marker.initGeoLayer(this) + markersToAdd.push(marker.geoLayer); + newMarkersMap.set(marker.id, marker); + } catch (e) {} } } for (let [key, value] of this.display.markers) { - markersToRemove.push(value.mapMarker); + markersToRemove.push(value.geoLayer); } this.display.clusterGroup.addLayers(markersToAdd); this.display.clusterGroup.removeLayers(markersToRemove); - this.display.markers = newMarkersMap; - } - private newLeafletMarker(marker: FileMarker) : leaflet.Marker { - let newMarker = leaflet.marker(marker.location, { icon: marker.icon || new leaflet.Icon.Default() }); - newMarker.on('click', (event: leaflet.LeafletMouseEvent) => { - this.goToMarker(marker, event.originalEvent.ctrlKey, true); - }); - newMarker.on('mouseover', (event: leaflet.LeafletMouseEvent) => { - let content = `

${marker.file.name}

`; - if (marker.extraName) - content += `

${marker.extraName}

`; - if (marker.snippet) - content += `

${marker.snippet}

`; - newMarker.bindPopup(content, {closeButton: true, autoPan: false}).openPopup(); - }); - newMarker.on('mouseout', (event: leaflet.LeafletMouseEvent) => { - newMarker.closePopup(); - }); - newMarker.on('add', (event: leaflet.LeafletEvent) => { - newMarker.getElement().addEventListener('contextmenu', (ev: MouseEvent) => { - let mapPopup = new Menu(this.app); - mapPopup.setNoIcon(); - mapPopup.addItem((item: MenuItem) => { - item.setTitle('Open note'); - item.onClick(async ev => { this.goToMarker(marker, ev.ctrlKey, true); }); - }); - mapPopup.addItem((item: MenuItem) => { - item.setTitle('Open geolocation in default app'); - item.onClick(ev => { - open(`geo:${marker.location.lat},${marker.location.lng}`); - }); - }); - utils.populateOpenInItems(mapPopup, marker.location, this.settings); - mapPopup.showAtPosition(ev); - ev.stopPropagation(); - }) - }); - return newMarker; + this.display.markers = newMarkersMap; } + /** Zoom the map to fit all markers on the screen */ async autoFitMapToMarkers() { if (this.display.markers.size > 0) { - const locations: leaflet.LatLng[] = Array.from(this.display.markers.values()).map(fileMarker => fileMarker.location); + let locations: leaflet.LatLng[] = [] + for (let layer of this.display.markers.values()) { + locations.push(...layer.getBounds()); + } console.log(`Auto fit by state:`, this.state); this.display.map.fitBounds(leaflet.latLngBounds(locations)); } } + /** + * Open a file in an editor window + * @param file The file descriptor to open + * @param useCtrlKeyBehavior If true will open the file in a different editor instance + * @param editorAction Optional callback to run when the file is opened + */ async goToFile(file: TFile, useCtrlKeyBehavior: boolean, editorAction?: (editor: Editor) => Promise) { let leafToUse = this.app.workspace.activeLeaf; const defaultDifferentPane = this.settings.markerClickBehavior != 'samePane'; @@ -510,11 +601,21 @@ export class MapView extends ItemView { await editorAction(editor); } - async goToMarker(marker: FileMarker, useCtrlKeyBehavior: boolean, highlight: boolean) { - return this.goToFile(marker.file, useCtrlKeyBehavior, - async (editor) => { await utils.goToEditorLocation(editor, marker.fileLocation, highlight); }); + /** + * Open the marker in an editor instance + * @param marker The file marker to open + * @param useCtrlKeyBehavior If true the file will be opened in a new instance + * @param highlight If true will highlight the line + */ + async goToMarker(marker: BaseGeoLayer, useCtrlKeyBehavior: boolean, highlight: boolean) { + return this.goToFile( + marker.file, + useCtrlKeyBehavior, + async (editor) => { await utils.goToEditorLocation(editor, marker.fileLocation, highlight); } + ); } + /** Get a list of all tags in the archive */ getAllTagNames() : string[] { let tags: string[] = []; const allFiles = this.app.vault.getFiles(); @@ -529,18 +630,27 @@ export class MapView extends ItemView { return tags; } - private async updateMarkersWithRelationToFile(fileRemoved: string, fileAddedOrChanged: TAbstractFile, skipMetadata: boolean) { - if (!this.display.map || !this.isOpen) + /** + * Run when a file is deleted, renamed or changed + * @param fileRemoved The old file path + * @param fileAddedOrChanged The new file data + * @param skipMetadata currently unused TODO: what is this for? + * @private + */ + private async onFileChange(fileRemoved: string, fileAddedOrChanged: TAbstractFile, skipMetadata: boolean): Promise { + if (!this.display.map || !this.isOpen) { + // If the map has not been set up yet then do nothing return; - let newMarkers: FileMarker[] = []; - for (let [markerId, fileMarker] of this.display.markers) { - if (fileMarker.file.path !== fileRemoved) - newMarkers.push(fileMarker); + } + let newGeoLayers: BaseGeoLayer[] = []; + // create an array of all file markers not in the removed file + for (let [markerId, geoLayer] of this.display.markers) { + if (geoLayer.file.path !== fileRemoved) + newGeoLayers.push(geoLayer); } if (fileAddedOrChanged && fileAddedOrChanged instanceof TFile) - await buildAndAppendFileMarkers(newMarkers, fileAddedOrChanged, this.settings, this.app) - this.updateMapMarkers(newMarkers); + // add file markers from the added file + await buildAndAppendGeoLayers(newGeoLayers, fileAddedOrChanged, this.settings, this.app) + this.setMapMarkers(newGeoLayers); } - } - diff --git a/src/markers.ts b/src/markers.ts index d5097ca..b1ca62e 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -1,4 +1,4 @@ -import { App, TFile, getAllTags } from 'obsidian'; +import {App, TFile, getAllTags, Menu, MenuItem} from 'obsidian'; import wildcard from 'wildcard'; import * as leaflet from 'leaflet'; import 'leaflet-extra-markers'; @@ -9,28 +9,175 @@ let localL = L; import { PluginSettings, MarkerIconRule } from 'src/settings'; import * as consts from 'src/consts'; +import * as utils from "src/utils"; +import type {MapView} from "src/mapView" +import {GeoJsonObject} from 'geojson'; type MarkerId = string; -export class FileMarker { + +if (!(leaflet.Polygon.prototype as any).getLatLng) { + // extend the Polygon behaviour to support clustering + leaflet.Polygon.addInitHook(function() { + this._latlng = this.getBounds().getCenter(); + }); + + leaflet.Polygon.include({ + getLatLng: function() { + return this._latlng; + }, + setLatLng: function() {} // Dummy method. + }); +} + + + +export abstract class BaseGeoLayer { + /** The file descriptor */ file: TFile; - fileLocation?: number; - location: leaflet.LatLng; - icon?: leaflet.Icon; - mapMarker?: leaflet.Marker; + /** The unique identifier for this geographic layer */ id: MarkerId; + /** The character position in the file the coordinate comes from */ + fileLocation?: number; + /** The leaflet layer on the map */ + geoLayer?: leaflet.Layer; + /** Snippet of the file to show in the hover bubble */ snippet?: string; + /** Optional extra name. Used by geo urls with a prefixed name */ extraName?: string; + /** Any tags specified with the geographic layer */ tags: string[] = []; - constructor(file: TFile, location: leaflet.LatLng) { + /** + * Construct a new map pin object + * @param file + */ + protected constructor(file: TFile) { this.file = file; + } + + /** + * Init the leaflet geographic layer from the data + * @param map + */ + abstract initGeoLayer(map: MapView): void; + + /** Generate a unique identifier for this layer */ + abstract generateId(): MarkerId; + + /** + * Is this geographic layer identical to the other object. + * Used to compare to existing data to minimise creation. + * @param other The other object to compare to + */ + abstract isSame(other: BaseGeoLayer): boolean; + + /** Get the bounds of the data */ + abstract getBounds(): leaflet.LatLng[]; +} + + +export class GeoJSONLayer extends BaseGeoLayer { + /** The raw geoJSON data */ + geoJSON: GeoJsonObject; + + /** The image icon to display for pins */ + icon?: leaflet.Icon; + /** The GeoJSON leaflet object */ + geoLayer?: leaflet.GeoJSON + + constructor(file: TFile, geoJSON: GeoJsonObject) { + super(file); + this.geoJSON = geoJSON + this.id = this.generateId(); + } + + initGeoLayer(map: MapView) { + const geoJSONLayer = this; + try { + this.geoLayer = new leaflet.GeoJSON( + this.geoJSON, + { + onEachFeature(_, layer: leaflet.Layer) { + if (layer instanceof leaflet.Marker){ + if (!geoJSONLayer.icon){ + geoJSONLayer.icon = getIconForMarker(geoJSONLayer, map.settings, map.app); + } + layer.setIcon(geoJSONLayer.icon) + } + + // when clicked, open the marker in an editor + layer.on('click', (event: leaflet.LeafletMouseEvent) => { + map.goToMarker(geoJSONLayer, event.originalEvent.ctrlKey, true); + }); + // when hovered + layer.on('mouseover', (event: leaflet.LeafletMouseEvent) => { + let content = `

${geoJSONLayer.file.name}

`; + if (geoJSONLayer.extraName) + content += `

${geoJSONLayer.extraName}

`; + if (geoJSONLayer.snippet) + content += `

${geoJSONLayer.snippet}

`; + layer.bindPopup(content, {closeButton: true, autoPan: false}).openPopup(); + }); + // when stop hovering + layer.on( + 'mouseout', + (event: leaflet.LeafletMouseEvent) => { + layer.closePopup(); + } + ); + } + } + ); + } catch (e) { + console.log("geoJSON is not valid geoJSON", this.geoJSON) + throw e; + } + } + + generateId(): MarkerId { + return this.file.name + JSON.stringify(this.geoJSON); + } + + isSame(other: BaseGeoLayer): boolean { + return other instanceof GeoJSONLayer && + other.geoJSON == this.geoJSON; + } + + getBounds(): leaflet.LatLng[] { + let bounds = this.geoLayer.getBounds() + return [bounds.getNorthEast(), bounds.getSouthWest()]; + } +} + + +/** A class to hold all the data for a map pin */ +export class FileMarker extends BaseGeoLayer { + geoLayer?: leaflet.Marker; + + /** The coordinate for the geographic layer */ + location: leaflet.LatLng; + /** The image icon to display */ + icon?: leaflet.Icon; + + /** + * Construct a new map pin object + * @param file + * @param location + */ + constructor(file: TFile, location: leaflet.LatLng) { + super(file) this.location = location; this.id = this.generateId(); } - isSame(other: FileMarker) { - return this.file.name === other.file.name && + /** + * Is the other data equal to this + * @param other A BaseGeoLayer to compare to + */ + isSame(other: BaseGeoLayer): boolean { + return other instanceof FileMarker && + this.file.name === other.file.name && this.location.toString() === other.location.toString() && this.fileLocation === other.fileLocation && this.extraName === other.extraName && @@ -48,36 +195,113 @@ export class FileMarker { generateId() : MarkerId { return this.file.name + this.location.lat.toString() + this.location.lng.toString(); } + + /** Create a leaflet layer and add it to the map */ + initGeoLayer(map: MapView): void { + this.icon = getIconForMarker(this, map.settings, map.app); + // create the leaflet marker instance + this.geoLayer = leaflet.marker(this.location, { icon: this.icon || new leaflet.Icon.Default() }); + // when clicked, open the marker in an editor + this.geoLayer.on('click', (event: leaflet.LeafletMouseEvent) => { + map.goToMarker(this, event.originalEvent.ctrlKey, true); + }); + // when hovered + this.geoLayer.on('mouseover', (event: leaflet.LeafletMouseEvent) => { + let content = `

${this.file.name}

`; + if (this.extraName) + content += `

${this.extraName}

`; + if (this.snippet) + content += `

${this.snippet}

`; + this.geoLayer.bindPopup(content, {closeButton: true, autoPan: false}).openPopup(); + }); + // when stop hovering + this.geoLayer.on( + 'mouseout', + (event: leaflet.LeafletMouseEvent) => { + this.geoLayer.closePopup(); + } + ); + // run when the layer is added to the map + this.geoLayer.on( + 'add', + (event: leaflet.LeafletEvent) => { + this.geoLayer.getElement().addEventListener( + 'contextmenu', + (ev: MouseEvent) => { + // on context menu creation + let mapPopup = new Menu(map.app); + mapPopup.setNoIcon(); + mapPopup.addItem((item: MenuItem) => { + item.setTitle('Open note'); + item.onClick(async ev => { map.goToMarker(this, ev.ctrlKey, true); }); + }); + mapPopup.addItem((item: MenuItem) => { + item.setTitle('Open geolocation in default app'); + item.onClick(ev => { + open(`geo:${this.location.lat},${this.location.lng}`); + }); + }); + utils.populateOpenInItems(mapPopup, this.location, map.settings); + mapPopup.showAtPosition(ev); + ev.stopPropagation(); + } + ) + } + ); + } + + getBounds(): leaflet.LatLng[] { + return [this.location]; + } } -export type MarkersMap = Map; +export type MarkersMap = Map; -export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], file: TFile, settings: PluginSettings, app: App, skipMetadata?: boolean) { +/** + * Create file markers for every coordinate in the front matter and file body + * @param mapToAppendTo The list of file markers to append to + * @param file The file descriptor to parse + * @param settings The plugin settings + * @param app The obsidian app instance + * @param skipMetadata If true will not find markers in the front matter + */ +export async function buildAndAppendGeoLayers(mapToAppendTo: BaseGeoLayer[], file: TFile, settings: PluginSettings, app: App, skipMetadata?: boolean) { const fileCache = app.metadataCache.getFileCache(file); const frontMatter = fileCache?.frontmatter; if (frontMatter) { if (!skipMetadata) { - const location = getFrontMatterLocation(file, app); + const location = getFrontMatterCoordinate(file, app); if (location) { verifyLocation(location); let leafletMarker = new FileMarker(file, location); - leafletMarker.icon = getIconForMarker(leafletMarker, settings, app); mapToAppendTo.push(leafletMarker); } + let geoJSON = getFrontMatterGeoJSON(file, app); + if (geoJSON) { + mapToAppendTo.push(new GeoJSONLayer(file, geoJSON)); + } } if ('locations' in frontMatter) { - const markersFromFile = await getMarkersFromFileContent(file, settings, app); - mapToAppendTo.push(...markersFromFile); + const inlineCoordiantes = await getInlineFileMarkers(file, settings, app); + mapToAppendTo.push(...inlineCoordiantes); + const inlineGeoJSON = await getInlineGeoJSON(file, settings, app); + mapToAppendTo.push(...inlineGeoJSON); } } } -export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise { +/** + * Create file marker instances for all the files in the vault + * @param files + * @param settings + * @param app + */ +export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise { if (settings.debug) console.time('buildMarkers'); - let markers: FileMarker[] = []; + let markers: BaseGeoLayer[] = []; for (const file of files) { - await buildAndAppendFileMarkers(markers, file, settings, app); + await buildAndAppendGeoLayers(markers, file, settings, app); } if (settings.debug) console.timeEnd('buildMarkers'); @@ -89,7 +313,13 @@ function checkTagPatternMatch(tagPattern: string, tags: string[]) { return match && match.length > 0; } -function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App) : leaflet.Icon { +/** + * Create a leaflet icon for the marker + * @param marker The file marker to create the icon for + * @param settings The plugin settings + * @param app The obsidian app instance + */ +function getIconForMarker(marker: BaseGeoLayer, settings: PluginSettings, app: App) : leaflet.Icon { const fileCache = app.metadataCache.getFileCache(marker.file); // Combine the file tags with the marker-specific tags const tags = getAllTags(fileCache).concat(marker.tags); @@ -122,6 +352,11 @@ export function getIconFromOptions(iconSpec: leaflet.BaseIconOptions) : leaflet. } } +/** + * Make sure that the coordinates are valid world coordinates + * -90 <= longitude <= 90 and -180 <= latitude <= 180 + * @param location + */ export function verifyLocation(location: leaflet.LatLng) { if (location.lng < consts.LNG_LIMITS[0] || location.lng > consts.LNG_LIMITS[1]) throw Error(`Lng ${location.lng} is outside the allowed limits`); @@ -129,42 +364,94 @@ export function verifyLocation(location: leaflet.LatLng) { throw Error(`Lat ${location.lat} is outside the allowed limits`); } -export function matchInlineLocation(content: string): RegExpMatchArray[] { +/** + * Find all inline coordinates in a string + * @param content The file contents to find the coordinates in + */ +export function matchInlineCoordinates(content: string): RegExpMatchArray[] { // Old syntax of ` `location: ... ` `. This syntax doesn't support a name so we leave an empty capture group - const locationRegex1 = /\`()location:\s*\[?([0-9.\-]+)\s*,\s*([0-9.\-]+)\]?\`/g; + const legacyLocationRegex = /`location:\s*\[?(?[+-]?([0-9]*[.])?[0-9]+)\s*,\s*(?[+-]?([0-9]*[.])?[0-9]+)]?`/g // New syntax of `[name](geo:...)` and an optional tags as `tag:tagName` separated by whitespaces - const locationRegex2 = /\[(.*?)\]\(geo:([0-9.\-]+),([0-9.\-]+)\)[ \t]*((?:tag:[\w\/\-]+[\s\.]+)*)/g; - const matches1 = content.matchAll(locationRegex1); - const matches2 = content.matchAll(locationRegex2); - return Array.from(matches1).concat(Array.from(matches2)); + const geoURLlocationRegex = /\[(?.*?)]\(geo:(?[+-]?([0-9]*[.])?[0-9]+),(?[+-]?([0-9]*[.])?[0-9]+)\)[ \t]*(?(tag:[\w\/\-]+[\s.]+)*)/g; + const legacyMatches = content.matchAll(legacyLocationRegex); + const geoURLMatches = content.matchAll(geoURLlocationRegex); + return Array.from(legacyMatches).concat(Array.from(geoURLMatches)); } -async function getMarkersFromFileContent(file: TFile, settings: PluginSettings, app: App): Promise { +/** + * Find all inline geoJSON in a string + * @param content The file contents to find the coordinates in + */ +export function matchInlineGeoJSON(content: string): RegExpMatchArray[] { + // New syntax of `[name](geo:...)` and an optional tags as `tag:tagName` separated by whitespaces + const geoJSONlocationRegex = /```geoJSON\n(?.*?)```/gs; + const geoJSONMatches = content.matchAll(geoJSONlocationRegex); + return Array.from(geoJSONMatches); +} + +/** + * Get markers from within the file body + * @param file The file descriptor to load + * @param settings The plugin settings + * @param app The obsidian app instance + */ +async function getInlineFileMarkers(file: TFile, settings: PluginSettings, app: App): Promise { let markers: FileMarker[] = []; const content = await app.vault.read(file); - const matches = matchInlineLocation(content); + const matches = matchInlineCoordinates(content); for (const match of matches) { try { - const location = new leaflet.LatLng(parseFloat(match[2]), parseFloat(match[3])); + const location = new leaflet.LatLng(parseFloat(match.groups.lat), parseFloat(match.groups.long)); verifyLocation(location); const marker = new FileMarker(file, location); - if (match[1] && match[1].length > 0) - marker.extraName = match[1]; - if (match[4]) { + if (match.groups.name && match.groups.name.length > 0) + marker.extraName = match.groups.name; + if (match.groups.tags) { // Parse the list of tags - const tagRegex = /tag:([\w\/\-]+)/g; - const tags = match[4].matchAll(tagRegex); + const tagRegex = /tag:(?[\w\/\-]+)/g; + const tags = match.groups.tags.matchAll(tagRegex); for (const tag of tags) - if (tag[1]) - marker.tags.push('#' + tag[1]); + if (tag.groups.tag) + marker.tags.push('#' + tag.groups.tag); } marker.fileLocation = match.index; - marker.icon = getIconForMarker(marker, settings, app); marker.snippet = await makeTextSnippet(file, content, marker.fileLocation, settings); markers.push(marker); } catch (e) { - console.log(`Error converting location in file ${file.name}: could not parse ${match[1]} or ${match[2]}`, e); + console.log(`Error converting location in file ${file.name}: could not parse ${match[0]}`, e); + } + } + return markers; +} + +/** + * Get geoJSON from within the file body + * @param file The file descriptor to load + * @param settings The plugin settings + * @param app The obsidian app instance + */ +async function getInlineGeoJSON(file: TFile, settings: PluginSettings, app: App): Promise { + let markers: GeoJSONLayer[] = []; + const content = await app.vault.read(file); + const matches = matchInlineGeoJSON(content); + for (const match of matches) { + try { + const geoJSONData = JSON.parse(match.groups.geoJSON); + const geoJSON = new GeoJSONLayer(file, geoJSONData); + if (geoJSONData instanceof Object && geoJSONData.tags && geoJSONData.tags instanceof Array) { + for (const tag of geoJSONData.tags) { + if (tag instanceof String) { + geoJSON.tags.push('#' + tag); + } + } + } + geoJSON.fileLocation = match.index; + geoJSON.snippet = await makeTextSnippet(file, content, geoJSON.fileLocation, settings); + markers.push(geoJSON); + } + catch (e) { + console.log(`Error converting inline geoJSON from ${file.name}: could not parse ${match.groups.geoJSON}`, e); } } return markers; @@ -182,7 +469,7 @@ async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: n const prevLine = fileContent.lastIndexOf('\n', snippetStart - 1); const line = fileContent.substring(snippetStart, prevLine); // If the new line above contains another location, don't include it and stop - if (matchInlineLocation(line).length > 0) + if (matchInlineCoordinates(line).length > 0) break; snippetStart = prevLine; linesAbove -= 1; @@ -196,7 +483,7 @@ async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: n const nextLine = fileContent.indexOf('\n', snippetEnd + 1); const line = fileContent.substring(snippetEnd, nextLine > -1 ? nextLine : fileContent.length); // If the new line below contains another location, don't include it and stop - if (matchInlineLocation(line).length > 0) + if (matchInlineCoordinates(line).length > 0) break; snippetEnd = nextLine; linesBelow -= 1; @@ -210,7 +497,12 @@ async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: n return snippet; } -export function getFrontMatterLocation(file: TFile, app: App) : leaflet.LatLng { +/** + * Get the coordinates stored in the front matter of a file + * @param file The file to load the front matter from + * @param app The app to load the file from + */ +export function getFrontMatterCoordinate(file: TFile, app: App) : leaflet.LatLng { const fileCache = app.metadataCache.getFileCache(file); const frontMatter = fileCache?.frontmatter; if (frontMatter && frontMatter?.location) { @@ -231,3 +523,17 @@ export function getFrontMatterLocation(file: TFile, app: App) : leaflet.LatLng { } return null; } + +/** + * Get the geoJSON stored in the front matter of a file + * @param file The file to load the front matter from + * @param app The app to load the file from + */ +export function getFrontMatterGeoJSON(file: TFile, app: App) : GeoJsonObject { + const fileCache = app.metadataCache.getFileCache(file); + const frontMatter = fileCache?.frontmatter; + if (frontMatter && frontMatter?.geoJSON) { + return frontMatter.geoJSON; + } + return null; +} \ No newline at end of file diff --git a/src/newNoteDialog.ts b/src/newNoteDialog.ts index eeb14e8..b69a80f 100644 --- a/src/newNoteDialog.ts +++ b/src/newNoteDialog.ts @@ -3,7 +3,7 @@ import * as leaflet from 'leaflet'; import { PluginSettings } from 'src/settings'; import { LocationSuggest } from 'src/geosearch'; -import { UrlConvertor } from 'src/urlConvertor'; +import { CoordinateParser } from 'src/coordinateParser'; import { MapView } from 'src/mapView'; import * as utils from 'src/utils'; import * as consts from 'src/consts'; @@ -17,7 +17,7 @@ class SuggestInfo { export class NewNoteDialog extends SuggestModal { private settings: PluginSettings; private suggestor: LocationSuggest; - private urlConvertor: UrlConvertor; + private coordinateParser: CoordinateParser; private lastSearchTime = 0; private delayInMs = 250; private lastSearch = ''; @@ -30,7 +30,7 @@ export class NewNoteDialog extends SuggestModal { super(app); this.settings = settings; this.suggestor = new LocationSuggest(this.app, this.settings); - this.urlConvertor = new UrlConvertor(this.app, this.settings); + this.coordinateParser = new CoordinateParser(this.app, this.settings); this.dialogAction = dialogAction; this.editor = editor; @@ -81,9 +81,9 @@ export class NewNoteDialog extends SuggestModal { } } - async addToNote(location: leaflet.LatLng, ev: MouseEvent | KeyboardEvent, query: string) { - const locationString = `[${location.lat},${location.lng}]`; - utils.verifyOrAddFrontMatter(this.editor, 'location', locationString); + async addToNote(coordinate: leaflet.LatLng, ev: MouseEvent | KeyboardEvent, query: string) { + const coordianteArray = [coordinate.lat,coordinate.lng]; + utils.frontMatterSetDefault(this.editor, 'location', coordianteArray); } async getSearchResultsWithDelay(query: string) { @@ -112,7 +112,7 @@ export class NewNoteDialog extends SuggestModal { } parseLocationAsUrl(query: string): SuggestInfo { - const result = this.urlConvertor.parseLocationFromUrl(query); + const result = this.coordinateParser.parseString(query); if (result) return { name: `Parsed from ${result.ruleName}: ${result.location.lat}, ${result.location.lng}`, diff --git a/src/urlConvertor.ts b/src/urlConvertor.ts deleted file mode 100644 index a2f975f..0000000 --- a/src/urlConvertor.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { App, Editor, EditorPosition } from 'obsidian'; - -import * as leaflet from 'leaflet'; -import { PluginSettings } from 'src/settings'; -import * as utils from 'src/utils'; - -export class UrlConvertor { - private settings: PluginSettings; - - constructor(app: App, settings: PluginSettings) { - this.settings = settings; - } - - findMatchInLine(editor: Editor) { - const cursor = editor.getCursor(); - const result = this.parseLocationFromUrl(editor.getLine(cursor.line)); - return result?.location; - } - - parseLocationFromUrl(line: string) { - for (const rule of this.settings.urlParsingRules) { - const regexp = RegExp(rule.regExp, 'g'); - const results = line.matchAll(regexp); - for (let result of results) { - try { - return { - location: new leaflet.LatLng(parseFloat(result[1]), parseFloat(result[2])), - index: result.index, - matchLength: result[0].length, - ruleName: rule.name - }; - } - catch (e) { } - } - } - return null; - } - - insertLocationToEditor(location: leaflet.LatLng, editor: Editor, replaceStart?: EditorPosition, replaceLength?: number) { - const locationString = `[](geo:${location.lat},${location.lng})`; - const cursor = editor.getCursor(); - if (replaceStart && replaceLength) { - editor.replaceRange(locationString, replaceStart, {line: replaceStart.line, ch: replaceStart.ch + replaceLength}); - } - else - editor.replaceSelection(locationString); - // We want to put the cursor right after the beginning of the newly-inserted link - const newCursorPos = replaceStart ? replaceStart.ch + 1 : cursor.ch + 1; - editor.setCursor({line: cursor.line, ch: newCursorPos}); - utils.verifyOrAddFrontMatter(editor, 'locations', ''); - } - - convertUrlAtCursorToGeolocation(editor: Editor) { - const cursor = editor.getCursor(); - const result = this.parseLocationFromUrl(editor.getLine(cursor.line)); - if (result) - this.insertLocationToEditor(result.location, editor, {line: cursor.line, ch: result.index}, result.matchLength); - } -} diff --git a/src/utils.ts b/src/utils.ts index 8c84d2c..e37b2b6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { WorkspaceLeaf, MarkdownView, Editor, App, TFile, Menu, MenuItem } from 'obsidian'; +import {WorkspaceLeaf, MarkdownView, Editor, App, TFile, Menu, MenuItem, stringifyYaml, parseYaml, Notice} from 'obsidian'; import * as moment_ from 'moment'; import * as leaflet from 'leaflet'; @@ -22,30 +22,50 @@ type NewNoteType = 'singleLocation' | 'multiLocation'; const CURSOR = '$CURSOR$'; -export async function newNote(app: App, newNoteType: NewNoteType, directory: string, fileName: string, - location: string, templatePath?: string): Promise +/** + * Create a new markdown note and populate with the location + * @param app The obsidian app instance + * @param newNoteType The location format to encode as + * @param directory The directory path to put the file in + * @param fileName The name of the file + * @param coordinate The coordinate + * @param templatePath Optional path to a file to populate the file with + */ +export async function newNote( + app: App, + newNoteType: NewNoteType, + directory: string, + fileName: string, + coordinate: string, + templatePath?: string +): Promise { // `$CURSOR$` is used to set the cursor let content = newNoteType === 'singleLocation' ? - `---\nlocation: [${location}]\n---\n\n${CURSOR}` : - `---\nlocations:\n---\n\n\[${CURSOR}](geo:${location})\n`; - let templateContent = ''; + `---\nlocation: [${coordinate}]\n---\n\n${CURSOR}` : + `---\nlocations:\n---\n\n\[${CURSOR}](geo:${coordinate})\n`; if (templatePath) - templateContent = await app.vault.adapter.read(templatePath); + content = content + await app.vault.adapter.read(templatePath); let fullName = path.join(directory || '', fileName); if (await app.vault.adapter.exists(fullName + '.md')) fullName += Math.random() * 1000; try { - return app.vault.create(fullName + '.md', content + templateContent); + return app.vault.create(fullName + '.md', content); } catch (e) { throw Error(`Cannot create file named ${fullName}: ${e}`); } } -export async function goToEditorLocation(editor: Editor, fileLocation: number, highlight: boolean) { - if (fileLocation) { - let pos = editor.offsetToPos(fileLocation); +/** + * Go to a character index in the note + * @param editor The obsidian Editor instance + * @param characterIndex The character index in the file to go to + * @param highlight If true will select the whole line + */ +export async function goToEditorLocation(editor: Editor, characterIndex: number, highlight: boolean) { + if (characterIndex) { + let pos = editor.offsetToPos(characterIndex); if (highlight) { const lineContent = editor.getLine(pos.line); editor.setSelection({ch: 0, line: pos.line}, {ch: lineContent.length, line: pos.line}); @@ -66,36 +86,97 @@ export async function handleNewNoteCursorMarker(editor: Editor) { } } -// Creates or modifies a front matter that has the field `fieldName: fieldValue`. -// Returns true if a change to the note was made. -export function verifyOrAddFrontMatter(editor: Editor, fieldName: string, fieldValue: string): boolean { +/** + * A utility for reading, modifying and writing back front matter. + * Load front matter from the current file and pass it to edit_callback. + * The returned value from the callback replaces the original front matter + * Return true if the yaml is changed + * @param editor The obsidian editor instance + * @param edit_callback The callback to modify the parsed object. Must return the modified object or null if no changes were made. + */ +export function updateFrontMatter( + editor: Editor, + edit_callback: (frontMatter: any) => any | null +){ const content = editor.getValue(); - const frontMatterRegex = /^---(.*)^---/ms; - const frontMatter = content.match(frontMatterRegex); - const existingFieldRegex = new RegExp(`^---.*${fieldName}:.*^---`, 'ms'); - const existingField = content.match(existingFieldRegex); - const cursorLocation = editor.getCursor(); - // That's not the best usage of the API, and rather be converted to editor transactions or something else - // that can preserve the cursor position better - if (frontMatter && !existingField) { - const replaced = `---${frontMatter[1]}${fieldName}: ${fieldValue}\n---`; - editor.setValue(content.replace(frontMatterRegex, replaced)); - editor.setCursor({line: cursorLocation.line + 1, ch: cursorLocation.ch}); - return true; - } else if (!frontMatter) { - const newFrontMatter = `---\n${fieldName}: ${fieldValue}\n---\n\n`; - editor.setValue(newFrontMatter + content); - editor.setCursor({line: cursorLocation.line + newFrontMatter.split('\n').length - 1, ch: cursorLocation.ch}); - return true; + const frontMatterRegex = /^---(.*?)\n---/s; + const frontMatterMatch = content.match(frontMatterRegex); + let frontMatter: any + if (frontMatterMatch){ + // found valid front matter + const frontMatterYaml = frontMatterMatch[1]; + try { + // parse the front matter into an object for easier handling + frontMatter = parseYaml(frontMatterYaml); + } catch (error) { + new Notice("Could not parse front matter yaml.\n" + error); + return false + } + if (typeof frontMatter === "object") { + frontMatter = edit_callback(frontMatter); + if (frontMatter) { + const newFrontMatter = `---\n${stringifyYaml(frontMatter)}\n---`; + const cursorLocation = editor.getCursor(); + editor.setValue(content.replace(frontMatterRegex, newFrontMatter)); + editor.setCursor({line: cursorLocation.line + 1, ch: cursorLocation.ch}); + return true; + } + } else { + new Notice("Expected yaml front matter root to be an object/dictionary."); + } + } else { + // did not find valid front matter + frontMatter = {}; + frontMatter = edit_callback(frontMatter); + if (frontMatter) { + const newFrontMatter = `---\n${stringifyYaml(frontMatter)}\n---\n\n`; + const cursorLocation = editor.getCursor(); + editor.setValue(newFrontMatter + content); + editor.setCursor({line: cursorLocation.line + newFrontMatter.split('\n').length - 1, ch: cursorLocation.ch}); + return true; + } } return false; } -export function populateOpenInItems(menu: Menu, location: leaflet.LatLng, settings: settings.PluginSettings) { +/** + * Create or modify a front matter that has the field `fieldName: fieldValue`. + * Returns true if a change to the note was made. + * @param editor The obsidian Editor instance + * @param key_name The key to set in the yaml front matter + * @param default_value The default value to populate with if not defined. Defaults to null. + */ +export function frontMatterSetDefault(editor: Editor, key_name: string, default_value?: any): boolean { + return updateFrontMatter( + editor, + (frontMatter) => { + if (frontMatter.hasOwnProperty(key_name)) { + // if the front matter already has this key + if (frontMatter[key_name] === null && default_value !== null) { + // if the key exists but the value is null + frontMatter[key_name] = default_value + return frontMatter + } + return null; + } else { + frontMatter[key_name] = default_value + return frontMatter + } + } + ) +} + +/** + * Populate a context menu from the user configurable urls + * @param menu The menu to attach + * @param coordinate The coordinate to use in the menu item + * @param settings Plugin settings + */ +export function populateOpenInItems(menu: Menu, coordinate: leaflet.LatLng, settings: settings.PluginSettings) { for (let setting of settings.openIn) { if (!setting.name || !setting.urlPattern) continue; - const fullUrl = setting.urlPattern.replace('{x}', location.lat.toString()).replace('{y}', location.lng.toString()); + const fullUrl = setting.urlPattern.replace('{x}', coordinate.lat.toString()).replace('{y}', coordinate.lng.toString()); menu.addItem((item: MenuItem) => { item.setTitle(`Open in ${setting.name}`); item.onClick(_ev => {