diff --git a/.gitignore b/.gitignore index 4151650..65a96dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,18 @@ -# Intellij -*.iml -.idea - -# npm -node_modules -package-lock.json -dist - -# build -main.js -*.js.map - -# obsidian -data.json +# Intellij +*.iml +.idea + +# npm +node_modules +package-lock.json +dist + +# build +main.js +*.js.map + +# obsidian +data.json + +.history/** +.vscode/** \ No newline at end of file diff --git a/README.md b/README.md index 144c17d..3e2b99c 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,215 @@ -# Obsidian.md Map View - -## Intro - -This plugin introduces an **interactive map view** for the [Obsidian.md](https://obsidian.md/) editor. -It searches your notes for encoded geolocations (see below) and places them as markers on a map. - -You can set different icons for different note types, filter the displayed notes and much more. - -![](sample.png) - -![](search.png) - -This plugin is in preliminary stages, but its 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... - -- If I'm visiting somewhere, what interesting places do I know in the area? -- If I'm planning a trip, what is the geographical relation between the points? - -And many more. - -Just like the Obsidian graph view lets you visualize associative relations between some of your notes, the map view lets you visualize geographic ones. - -## Disclaimer - -This plugin has a lot of potential for growth; it can have many more useful features (and hopefully eventually it will). -It can also be much more visually polished and much easier to use. - -However, it is the result of just a few restless evenings on which I wanted to quickly build a solution to a problem that I had. -I will not be able to give it the attention it deserves to fulfill its full potential, because it requires a lot more work. -I'm sure many will have great ideas for taking it to the next level, but unfortunately I don't expect to have the availability required for that, so at this point feature request will mostly have to go unattended. - -I believe that it can be useful enough for many users as-is, and I hope that as the user base grows, a few developers will pitch in to help continue the vision. - -## Limitations - -- Although both light & dark themes are supported, the map itself is currently only light. -- Was not yet tested & adapted to Obsidian Mobile. - -## User Guide - -### Parsing Location Data - -The plugin scans all your notes and parses two types of location data. - -First is a location tag in a note's [front matter](https://help.obsidian.md/Advanced+topics/YAML+front+matter): - -```yaml ---- -location: [40.6892494,-74.0466891] ---- -``` - -This is useful for notes that represent a single specific location. -It's also compatible with the way other useful plugins like [obsidian-leaflet](https://github.com/valentine195/obsidian-leaflet-plugin) read locations, and allows some interoperability. - -Another way that the plugin parses location data is through inline `` `location` `` markers within notes (note the backticks), which allow multiple markers in the same note. -To prevent the need to scan the full content of all your notes, it requires an empty `locations:` tag in the note front matter ('locations' and not 'location'). -Example: - -``` ---- -locations: ---- - -# Trip Plan - -Point 1: Hudson River -`location: 42.277578,-76.1598107` -... more note content ... - -Point 2: New Haven -`location: 41.2982672,-72.9991356` -``` - -Notes with multiple markers will contain multiple markers on the map with the same note name, and clicking on the marker will jump to the correct location within the note. - -Notice how locations in the front matter must contain brackets (`location: [lat, lng]`) and inline locations do not (`location: lat, lng`). -For inline locations both formats are supported, but for the front matter brackets are mandatory because it needs to compny with the YAML format. - -### Finding a Location - -If you want to log a location in a note, I recommend one of two ways. - -![](copy.png) - -1. Use one of the "copy location as..." options when you right-click the map. If you use "copy location as inline", just remember you need the note to start with a front matter that has an empty `locations:` line. - -2. Search from something in Google Maps then copy the latitude & longitude parts of the URL, e.g. if you search for "statue of liberty" you get to a link that looks like this: `https://www.google.com/maps/place/Statue+of+Liberty+National+Monument/@40.6892494,-74.0466891,17z/data=!3m1!4b1!4m5!3m4!1s0x89c25090129c363d:0x40c6a5770d25022b!8m2!3d40.6892494!4d-74.0445004`. From that you can take the location: `40.6892494,-74.0466891`. - - -### Filtering by Tags - -At the time of release, this plugin provides just one way to filter notes: an "OR" search by tags. - -Your notes are encouraged to contain Obsidian tags that represent their type (e.g. `#hike`, `#food`, `#journal-entry` or whatever you'll want to filter by). -In the search box you can type tags separated by commas and you'll get in your view just the notes that have one of these tags. - -Since this method has a single-note granularity, there is currently no way to see just a few locations inlined in the same note. -If a note's tag is included in the search, all the locations within this note will be displayed on the map. - -### Marker Icons - -Although there isn't yet a friendly way in the GUI to configure this, the plugin allows you to selectively apply icons to notes based on a powerful rules system. - -To understand how this works you'll first have to refer to the [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#icons) package and use icon names from [Font Awesome](https://fontawesome.com/). - -A single marker is defined in the following JSON structure: -`{"prefix": "fas", "icon": "fa-bus", "shape": "circle", "color": "red"}` - -To build this, I searched Font Awesome (in the link above) for 'bus' and chose [this icon](https://fontawesome.com/v5.15/icons/bus?style=solid). -A Font Awesome icon has a style prefix (in this case `fas`) and an icon name that always starts with `fa`, in this case `fa-bus`. -Shape and color are for your choosing. - -#### Tag Rules - -To apply an icon to a note with geolocation data, Map View scans a list of rules. -You can edit these rules through the plugin configuration, which currently includes a not-so-friendly JSON dictionary that you need to carefully edit. -Please don't do that if you're unfamiliar with the JSON syntax, if you wait a while I'm sure that a better GUI will be built :) - -Map View scans the rules and applies them one by one, always starting from `default` and then from first to last. A rule matches if the tag that it lists is included in the note, and then the rule's fields will overwrite the corresponding fields of the previous matching rules, until all rules were scanned. -This allows you to set rules that change just some properties of the icons, e.g. some rules change the shape according to some tags, some change the color etc. - -Here's the example I provide as a probably-not-useful default in the plugin: - -```json - { - "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, - "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, - "#trip-water": {"prefix": "fas", "markerColor": "blue"}, - "#dogs": {"prefix": "fas", "icon": "fa-paw"}, - } -``` - -This means that all notes will have a blue `fa-circle` icon by default. -However, a note with the `#trip` tag will have a green `fa-hiking` icon. -Then, a note that has both the `#trip` and `#trip-water` tags will have a `fa-hiking` marker (when the `#trip` rule is applied), but a **blue** marker, because the `#trip-water` overwrites the `markerColor` that the previous `#trip` rule has set. - -**Consider copying the configuration to an external editor and editing it there.** -The configuration dialog ignores an invalid JSON object, so if you close it in a state that has a syntax error, your changes will be lost. - -### Map Sources - -By default, Map View uses the [standard tile layer of OpenStreetMap](https://wiki.openstreetmap.org/wiki/Standard_tile_layer). -However, you can change the map source in the configuration to any service that has a tiles API using a standard URL syntax. - -There are many services of localized, specialized or just beautifully-rendered maps that you can use, sometimes following a free registration. -See a pretty comprehensive list [here](https://wiki.openstreetmap.org/wiki/Tiles). - -Although that's the case with this plugin in general, it's worth noting explicitly that using 3rd party map data properly, and making sure you are not violating any terms of use, is your own responsibility. - -Note that Google Maps is not in that list, because although it does provide the same standard form of static tiles in the same URL format, the Google Maps terms of service makes it difficult to legally bundle the maps in an application. - -## Relation to Other Obsidian Plugins - -When thinking about Obsidian and maps, the first plugin that comes to mind is [Obsidian Leaflet](https://github.com/valentine195/obsidian-leaflet-plugin). -That plugin is great at rendering maps based on data within a note, with great customization options. -It can also scan for data inside a directory which gives even more power. -In contrast, Obsidian Map View is focused on showing and interacting with your notes geographically. - -Another relevant plugin is [Obsidian Map](https://github.com/Darakah/obsidian-map) which seems to focus on powerful tools for map drawing. - -## Wishlist - -As noted in the disclaimer above, my wishlist for this plugin is huge and I'm unlikely to get to it all. -There are so many things that I want it to do, and so little time... - -- **Most importantly**: proper mobile support including device location if possible. That's literally on the top of my list. -- More powerful filtering. I'd love it to be based on the [existing Obsidian query format](https://github.com/obsidianmd/obsidian-api/issues/22). What I see in mind is a powerful text search with a results pane that's linked to the map. -- Better interoperability with Obsidian Leaflet: support for marker image files, locations as an array and `marker` tags. -- Better UI, especially for the core functionality like editing icons. -- Dark mode. -- A side bar with note summaries linked to the map view. - -## Changelog - -### 0.0.8 - -- Fixed [a bug](https://github.com/esm7/obsidian-map-view/issues/12) allowing to confusingly add markers out of earth's proper bounds. -- "New note here" right-click option with configuration options. -- Markers now updated dynamically when relevant notes are added/deleted/modified. -- Tweaks to opening notes in a 2nd pane (be able to use a 2nd pane if it already existed). -- When jumping to a location within a note, the corresponding note line is now highlighted. -- "Open in Google Maps" menu item within notes with locations (both note menu and right-click on a location). - -### 0.0.7 - -Tiny fix to an annoying bug of the default not being applied. - -### 0.0.6 - -Small fixes before the plugin formal release. - -### 0.0.5 - -- New "show on map" menu item in the editor. -- Fixed a nasty compatibility issue with obsidian-leaflet, see [here](https://github.com/esm7/obsidian-map-view/issues/6). - -### 0.0.4 - -- Added settings (and Ctrl key) to open a note in a separate pane (https://github.com/esm7/obsidian-map-view/issues/3). - -### 0.0.3 - -- Proper view and state management (hopefully). -- Fixed a bug in location parsing. - -### 0.0.2 - -Various cleanups, better copyright handling and generally more readiness for releasing the plugin. - -### 0.0.1 - -Initial alpha release. - +# Obsidian.md Map View + +## Intro + +This plugin introduces an **interactive map view** for the [Obsidian.md](https://obsidian.md/) editor. +It searches your notes for encoded geolocations (see below) and places them as markers on a map. + +You can set different icons for different note types, filter the displayed notes and much more. + +![](sample.png) + +![](search.png) + +This plugin is in preliminary stages, but its 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... + +- If I'm visiting somewhere, what interesting places do I know in the area? +- If I'm planning a trip, what is the geographical relation between the points? + +And many more. + +Just like the Obsidian graph view lets you visualize associative relations between some of your notes, the map view lets you visualize geographic ones. + +## Disclaimer + +This plugin has a lot of potential for growth; it can have many more useful features (and hopefully eventually it will). +It can also be much more visually polished and much easier to use. + +However, it is the result of just a few restless evenings on which I wanted to quickly build a solution to a problem that I had. +I will not be able to give it the attention it deserves to fulfill its full potential, because it requires a lot more work. +I'm sure many will have great ideas for taking it to the next level, but unfortunately I don't expect to have the availability required for that, so at this point feature request will mostly have to go unattended. + +I believe that it can be useful enough for many users as-is, and I hope that as the user base grows, a few developers will pitch in to help continue the vision. + +## Limitations + +- Although both light & dark themes are supported, the map itself is currently only light. +- Was not yet tested & adapted to Obsidian Mobile. + +## User Guide + +### Parsing Location Data + +The plugin scans all your notes and parses two types of location data. + +First is a location tag in a note's [front matter](https://help.obsidian.md/Advanced+topics/YAML+front+matter): + +```yaml +--- +location: [40.6892494,-74.0466891] +--- +``` + +This is useful for notes that represent a single specific location. +It's also compatible with the way other useful plugins like [obsidian-leaflet](https://github.com/valentine195/obsidian-leaflet-plugin) read locations, and allows some interoperability. + +Another way that the plugin parses location data is through inline `` `location` `` markers within notes (note the backticks), which allow multiple markers in the same note. +To prevent the need to scan the full content of all your notes, it requires an empty `locations:` tag in the note front matter ('locations' and not 'location'). +Example: + +``` +--- +locations: +--- + +# Trip Plan + +Point 1: Hudson River +`location: 42.277578,-76.1598107` +... more note content ... + +Point 2: New Haven +`location: 41.2982672,-72.9991356` +``` + +Notes with multiple markers will contain multiple markers on the map with the same note name, and clicking on the marker will jump to the correct location within the note. + +Notice how locations in the front matter must contain brackets (`location: [lat, lng]`) and inline locations do not (`location: lat, lng`). +For inline locations both formats are supported, but for the front matter brackets are mandatory because it needs to compny with the YAML format. + +### Finding a Location + +If you want to log a location in a note, I recommend one of two ways. + +![](copy.png) + +1. Use one of the "copy location as..." options when you right-click the map. If you use "copy location as inline", just remember you need the note to start with a front matter that has an empty `locations:` line. + +2. Search from something in Google Maps then copy the latitude & longitude parts of the URL, e.g. if you search for "statue of liberty" you get to a link that looks like this: `https://www.google.com/maps/place/Statue+of+Liberty+National+Monument/@40.6892494,-74.0466891,17z/data=!3m1!4b1!4m5!3m4!1s0x89c25090129c363d:0x40c6a5770d25022b!8m2!3d40.6892494!4d-74.0445004`. From that you can take the location: `40.6892494,-74.0466891`. + + +### Filtering by Tags + +At the time of release, this plugin provides just one way to filter notes: an "OR" search by tags. + +Your notes are encouraged to contain Obsidian tags that represent their type (e.g. `#hike`, `#food`, `#journal-entry` or whatever you'll want to filter by). +In the search box you can type tags separated by commas and you'll get in your view just the notes that have one of these tags. + +Since this method has a single-note granularity, there is currently no way to see just a few locations inlined in the same note. +If a note's tag is included in the search, all the locations within this note will be displayed on the map. + +### Marker Icons + +Although there isn't yet a friendly way in the GUI to configure this, the plugin allows you to selectively apply icons to notes based on a powerful rules system. + +To understand how this works you'll first have to refer to the [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#icons) package and use icon names from [Font Awesome](https://fontawesome.com/). + +A single marker is defined in the following JSON structure: +`{"prefix": "fas", "icon": "fa-bus", "shape": "circle", "color": "red"}` + +To build this, I searched Font Awesome (in the link above) for 'bus' and chose [this icon](https://fontawesome.com/v5.15/icons/bus?style=solid). +A Font Awesome icon has a style prefix (in this case `fas`) and an icon name that always starts with `fa`, in this case `fa-bus`. +Shape and color are for your choosing. + +#### Tag Rules + +To apply an icon to a note with geolocation data, Map View scans a list of rules. +You can edit these rules through the plugin configuration, which currently includes a not-so-friendly JSON dictionary that you need to carefully edit. +Please don't do that if you're unfamiliar with the JSON syntax, if you wait a while I'm sure that a better GUI will be built :) + +Map View scans the rules and applies them one by one, always starting from `default` and then from first to last. A rule matches if the tag that it lists is included in the note, and then the rule's fields will overwrite the corresponding fields of the previous matching rules, until all rules were scanned. +This allows you to set rules that change just some properties of the icons, e.g. some rules change the shape according to some tags, some change the color etc. + +Here's the example I provide as a probably-not-useful default in the plugin: + +```json + { + "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, + "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, + "#trip-water": {"prefix": "fas", "markerColor": "blue"}, + "#dogs": {"prefix": "fas", "icon": "fa-paw"}, + } +``` + +This means that all notes will have a blue `fa-circle` icon by default. +However, a note with the `#trip` tag will have a green `fa-hiking` icon. +Then, a note that has both the `#trip` and `#trip-water` tags will have a `fa-hiking` marker (when the `#trip` rule is applied), but a **blue** marker, because the `#trip-water` overwrites the `markerColor` that the previous `#trip` rule has set. + +**Consider copying the configuration to an external editor and editing it there.** +The configuration dialog ignores an invalid JSON object, so if you close it in a state that has a syntax error, your changes will be lost. + +### Map Sources + +By default, Map View uses the [standard tile layer of OpenStreetMap](https://wiki.openstreetmap.org/wiki/Standard_tile_layer). +However, you can change the map source in the configuration to any service that has a tiles API using a standard URL syntax. + +There are many services of localized, specialized or just beautifully-rendered maps that you can use, sometimes following a free registration. +See a pretty comprehensive list [here](https://wiki.openstreetmap.org/wiki/Tiles). + +Although that's the case with this plugin in general, it's worth noting explicitly that using 3rd party map data properly, and making sure you are not violating any terms of use, is your own responsibility. + +Note that Google Maps is not in that list, because although it does provide the same standard form of static tiles in the same URL format, the Google Maps terms of service makes it difficult to legally bundle the maps in an application. + +## Relation to Other Obsidian Plugins + +When thinking about Obsidian and maps, the first plugin that comes to mind is [Obsidian Leaflet](https://github.com/valentine195/obsidian-leaflet-plugin). +That plugin is great at rendering maps based on data within a note, with great customization options. +It can also scan for data inside a directory which gives even more power. +In contrast, Obsidian Map View is focused on showing and interacting with your notes geographically. + +Another relevant plugin is [Obsidian Map](https://github.com/Darakah/obsidian-map) which seems to focus on powerful tools for map drawing. + +## Wishlist + +As noted in the disclaimer above, my wishlist for this plugin is huge and I'm unlikely to get to it all. +There are so many things that I want it to do, and so little time... + +- **Most importantly**: proper mobile support including device location if possible. That's literally on the top of my list. +- More powerful filtering. I'd love it to be based on the [existing Obsidian query format](https://github.com/obsidianmd/obsidian-api/issues/22). What I see in mind is a powerful text search with a results pane that's linked to the map. +- Better interoperability with Obsidian Leaflet: support for marker image files, locations as an array and `marker` tags. +- Better UI, especially for the core functionality like editing icons. +- Dark mode. +- A side bar with note summaries linked to the map view. + +## Changelog + +### 0.0.8 + +- Fixed [a bug](https://github.com/esm7/obsidian-map-view/issues/12) allowing to confusingly add markers out of earth's proper bounds. +- "New note here" right-click option with configuration options. +- Markers now updated dynamically when relevant notes are added/deleted/modified. +- Tweaks to opening notes in a 2nd pane (be able to use a 2nd pane if it already existed). +- When jumping to a location within a note, the corresponding note line is now highlighted. +- "Open in Google Maps" menu item within notes with locations (both note menu and right-click on a location). + +### 0.0.7 + +Tiny fix to an annoying bug of the default not being applied. + +### 0.0.6 + +Small fixes before the plugin formal release. + +### 0.0.5 + +- New "show on map" menu item in the editor. +- Fixed a nasty compatibility issue with obsidian-leaflet, see [here](https://github.com/esm7/obsidian-map-view/issues/6). + +### 0.0.4 + +- Added settings (and Ctrl key) to open a note in a separate pane (https://github.com/esm7/obsidian-map-view/issues/3). + +### 0.0.3 + +- Proper view and state management (hopefully). +- Fixed a bug in location parsing. + +### 0.0.2 + +Various cleanups, better copyright handling and generally more readiness for releasing the plugin. + +### 0.0.1 + +Initial alpha release. + diff --git a/manifest.json b/manifest.json index 6301714..81537ba 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ -{ - "id": "obsidian-map-view", - "name": "Map View", - "version": "0.0.8", - "minAppVersion": "0.12.10", - "description": "An interactive map view.", - "isDesktopOnly": false -} +{ + "id": "obsidian-map-view", + "name": "Map View", + "version": "0.0.8", + "minAppVersion": "0.12.10", + "description": "An interactive map view.", + "isDesktopOnly": false +} diff --git a/package.json b/package.json index 2a4eee5..5255027 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,40 @@ { - "name": "obsidian-map-view", - "version": "0.0.8", - "description": "An interactive map view for Obsidian.md", - "main": "main.js", - "scripts": { - "dev": "rollup --config rollup.config.js -w", - "build": "rollup --config rollup.config.js --environment BUILD:production" - }, - "keywords": [], - "author": "", - "license": "MIT", - "devDependencies": { - "@rollup/plugin-commonjs": "^18.0.0", - "@rollup/plugin-image": "^2.0.6", - "@rollup/plugin-node-resolve": "^11.2.1", - "@rollup/plugin-typescript": "^8.2.1", - "@types/geojson": "^7946.0.7", - "@types/leaflet": "^1.7.2", - "@types/node": "^14.14.37", - "obsidian": "^0.12.5", - "postcss-less": "^4.0.1", - "postcss-url": "^10.1.3", - "rollup": "^2.32.1", - "rollup-plugin-copy": "^3.4.0", - "rollup-plugin-postcss": "^4.0.0", - "tslib": "^2.2.0", - "typescript": "^4.2.4" - }, - "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.3", - "leaflet": "^1.7.1", - "leaflet-extra-markers": "github:coryasilva/Leaflet.ExtraMarkers", - "leaflet-fullscreen": "^1.0.2", - "leaflet-geosearch": "^3.3.2", - "moment": "^2.29.1", - "open": "^8.2.1" - } -} + "name": "obsidian-map-view", + "version": "0.0.8", + "description": "An interactive map view for Obsidian.md", + "main": "main.js", + "scripts": { + "dev": "rollup --config rollup.config.js -w", + "build": "rollup --config rollup.config.js --environment BUILD:production" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-commonjs": "^18.0.0", + "@rollup/plugin-image": "^2.0.6", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-typescript": "^8.2.1", + "@types/geojson": "^7946.0.7", + "@types/leaflet": "^1.7.2", + "@types/node": "^14.14.37", + "obsidian": "^0.12.5", + "postcss-less": "^4.0.1", + "postcss-url": "^10.1.3", + "rollup": "^2.32.1", + "rollup-plugin-copy": "^3.4.0", + "rollup-plugin-postcss": "^4.0.0", + "tslib": "^2.2.0", + "typescript": "^4.2.4" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^5.15.3", + "exifr": "^7.1.2", + "leaflet": "^1.7.1", + "leaflet-extra-markers": "github:coryasilva/Leaflet.ExtraMarkers", + "leaflet-fullscreen": "^1.0.2", + "leaflet-geosearch": "^3.3.2", + "moment": "^2.29.1", + "open": "^8.2.1" + } +} \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js index 9b55098..525a66c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,39 +1,39 @@ -import typescript from '@rollup/plugin-typescript'; -import {nodeResolve} from '@rollup/plugin-node-resolve'; -import commonjs from '@rollup/plugin-commonjs'; -import postcss from 'rollup-plugin-postcss'; -import postcss_url from 'postcss-url'; -import copy from 'rollup-plugin-copy'; - -const isProd = (process.env.BUILD === 'production'); - -const banner = -`/* -THIS IS A GENERATED/BUNDLED FILE BY ROLLUP -if you want to view the source visit the plugins github repository -*/ -`; - -export default { - input: 'src/main.ts', - output: { - dir: './dist', - sourcemap: isProd ? false : 'inline', - sourcemapExcludeSources: isProd, - format: 'cjs', - exports: 'default', - banner, - }, - external: ['obsidian'], - plugins: [ - typescript(), - nodeResolve({browser: true}), - commonjs(), - postcss({ extensions: ['.css'], plugins: [postcss_url({url: 'inline'})] }), - copy({ - targets: [ - { src: './manifest.json', dest: 'dist' } - ] - }) - ] -}; +import typescript from '@rollup/plugin-typescript'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import postcss from 'rollup-plugin-postcss'; +import postcss_url from 'postcss-url'; +import copy from 'rollup-plugin-copy'; + +const isProd = (process.env.BUILD === 'production'); + +const banner = + `/* +THIS IS A GENERATED/BUNDLED FILE BY ROLLUP +if you want to view the source visit the plugins github repository +*/ +`; + +export default { + input: 'src/main.ts', + output: { + dir: './dist', + sourcemap: isProd ? false : 'inline', + sourcemapExcludeSources: isProd, + format: 'cjs', + exports: 'default', + banner, + }, + external: ['obsidian'], + plugins: [ + typescript(), + nodeResolve({ browser: true }), + commonjs(), + postcss({ extensions: ['.css'], plugins: [postcss_url({ url: 'inline' })] }), + copy({ + targets: [ + { src: './manifest.json', dest: 'dist' } + ] + }) + ] +}; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 09c9dd6..9177a3f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,240 +1,321 @@ -import { addIcon, App, Editor, FileView, MarkdownView, MenuItem, Menu, TFile, Plugin, WorkspaceLeaf, PluginSettingTab, Setting, TAbstractFile } from 'obsidian'; -import * as consts from 'src/consts'; -import * as leaflet from 'leaflet'; - -import { MapView } from 'src/mapView'; -import { PluginSettings, DEFAULT_SETTINGS } from 'src/settings'; -import { getFrontMatterLocation, matchInlineLocation, verifyLocation } from 'src/markers'; - -export default class MapViewPlugin extends Plugin { - settings: PluginSettings; - - async onload() { - addIcon('globe', consts.RIBBON_ICON); - - await this.loadSettings(); - - this.addRibbonIcon('globe', 'Open map view', () => { - this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); - }); - - this.registerView(consts.MAP_VIEW_NAME, (leaf: WorkspaceLeaf) => { - return new MapView(leaf, this.settings, this); - }); - - this.addCommand({ - id: 'open-map-view', - name: 'Open Map View', - callback: () => { - this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); - }, - }); - - this.addSettingTab(new SettingsTab(this.app, this)); - - 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) { - menu.addItem((item: MenuItem) => { - item.setTitle('Show on map'); - item.setIcon('globe'); - item.onClick(async () => await this.openMapWithLocation(location)); - }); - menu.addItem((item: MenuItem) => { - item.setTitle('Open in Google Maps'); - item.onClick(_ev => { - open(`https://maps.google.com/?q=${location.lat},${location.lng}`); - }); - }); - } - } - }); - - // TODO function signature is a guess, revise when API is released - // @ts-ignore - this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor, view: FileView) => { - if (view instanceof FileView) { - const location = this.getLocationOnEditorLine(editor, view); - if (location) { - menu.addItem((item: MenuItem) => { - item.setTitle('Show on map'); - item.setIcon('globe'); - item.onClick(async () => await this.openMapWithLocation(location)); - }); - menu.addItem((item: MenuItem) => { - item.setTitle('Open in Google Maps'); - item.onClick(_ev => { - open(`https://maps.google.com/?q=${location.lat},${location.lng}`); - }); - }); - } - } - }); - - } - - private async openMapWithLocation(location: leaflet.LatLng) { - await this.app.workspace.getLeaf().setViewState({ - type: consts.MAP_VIEW_NAME, - state: { - mapCenter: location, - mapZoom: this.settings.zoomOnGoFromNote - } as any}); - } - - private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { - const line = editor.getLine(editor.getCursor().line); - const match = matchInlineLocation(line)?.next()?.value; - let selectedLocation = null; - if (match) - selectedLocation = new leaflet.LatLng(parseFloat(match[1]), parseFloat(match[2])); - else - { - const fmLocation = getFrontMatterLocation(view.file, this.app); - if (line.indexOf('location') > -1 && fmLocation) - selectedLocation = fmLocation; - } - if (selectedLocation) { - verifyLocation(selectedLocation); - return selectedLocation; - } - return null; - } - - onunload() { - } - - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - async saveSettings() { - await this.saveData(this.settings); - } -} - - -class SettingsTab extends PluginSettingTab { - plugin: MapViewPlugin; - - constructor(app: App, plugin: MapViewPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - let { containerEl } = this; - - containerEl.empty(); - - containerEl.createEl('h2', {text: 'Settings for the map view plugin.'}); - - new Setting(containerEl) - .setName('Map follows search results') - .setDesc('Auto focus the map to fit search results.') - .addToggle(component => {component - .setValue(this.plugin.settings.autoZoom) - .onChange(async (value) => { - this.plugin.settings.autoZoom = value; - await this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Default action for map marker click') - .setDesc('How should the corresponding note be opened when clicking a map marker? Either way, CTRL reverses the behavior.') - .addDropdown(component => { component - .addOption('samePane', 'Open in same pane (replace map view)') - .addOption('secondPane', 'Open in a 2nd pane and keep reusing it') - .addOption('alwaysNew', 'Always open a new pane') - .setValue(this.plugin.settings.markerClickBehavior || 'samePane') - .onChange(async (value: any) => { - this.plugin.settings.markerClickBehavior = value; - this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('New pane split direction') - .setDesc('Which way should the pane be split when opening in a new pane.') - .addDropdown(component => { component - .addOption('horizontal', 'Horizontal') - .addOption('vertical', 'Vertical') - .setValue(this.plugin.settings.newPaneSplitDirection || 'horizontal') - .onChange(async (value: any) => { - this.plugin.settings.newPaneSplitDirection = value; - this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('New note name format') - .setDesc('Date/times in the format can be wrapped in {{date:...}}, e.g. "note-{{date:YYYY-MM-DD}}".') - .addText(component => { component - .setValue(this.plugin.settings.newNoteNameFormat || DEFAULT_SETTINGS.newNoteNameFormat) - .onChange(async (value: string) => { - this.plugin.settings.newNoteNameFormat = value; - this.plugin.saveSettings(); - }) - }); - new Setting(containerEl) - .setName('New note location') - .setDesc('Location for notes created from the map.') - .addText(component => { component - .setValue(this.plugin.settings.newNotePath || '') - .onChange(async (value: string) => { - this.plugin.settings.newNotePath = value; - this.plugin.saveSettings(); - }) - }); - new Setting(containerEl) - .setName('Template file location') - .setDesc('Choose the file to use as a template, e.g. "templates/map-log.md".') - .addText(component => { component - .setValue(this.plugin.settings.newNoteTemplate || '') - .onChange(async (value: string) => { - this.plugin.settings.newNoteTemplate = value; - this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Default zoom for "show on map" action') - .setDesc('When jumping to the map from a note, what should be the display zoom?') - .addSlider(component => {component - .setLimits(1, 18, 1) - .setValue(this.plugin.settings.zoomOnGoFromNote) - .onChange(async (value) => { - this.plugin.settings.zoomOnGoFromNote = value; - await this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Map source (advanced)') - .setDesc('Source for the map tiles, see the documentation for more details. Requires to close & reopen the map.') - .addText(component => {component - .setValue(this.plugin.settings.tilesUrl) - .onChange(async (value) => { - this.plugin.settings.tilesUrl = value; - await this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Edit the marker icons (advanced)') - .setDesc("Refer to the plugin documentation for more details.") - .addTextArea(component => component - .setValue(JSON.stringify(this.plugin.settings.markerIcons, null, 2)) - .onChange(async value => { - try { - const newMarkerIcons = JSON.parse(value); - this.plugin.settings.markerIcons = newMarkerIcons; - await this.plugin.saveSettings(); - } catch (e) { - } - })); - - } -} +import { addIcon, App, Editor, FileView, MarkdownView, MenuItem, Menu, TFile, Plugin, WorkspaceLeaf, PluginSettingTab, Setting, TAbstractFile } from 'obsidian'; +import * as consts from 'src/consts'; +import * as leaflet from 'leaflet'; + +import { MapView } from 'src/mapView'; +import { PluginSettings, DEFAULT_SETTINGS, isImage } from 'src/settings'; +import { FileMarker, markersFromMd, getFrontMatterLocation, getIconFromOptions, matchInlineLocation, verifyLocation } from 'src/markers'; +import exifr from "exifr"; + +type Extractor = (f: TFile) => Promise; +type Predicate = (f: TFile) => boolean; +type Rule = {pred:Predicate, extract:Extractor}; + + +export default class MapViewPlugin extends Plugin { + settings: PluginSettings; + + CACHE_RULES: Rule[] = [ + { pred: (f: TFile) => this.settings.detectImageLocations && isImage(f), extract: (f: TFile) => this.getImageCoords(f) }, + { pred: (f: TFile) => f.extension == 'md', extract: (f: TFile) => markersFromMd(f, this.settings, this.app) }, + ]; + private _fileCache: Map; + private *cacheGen () { for (let val of this._fileCache.values()) yield Promise.resolve(val[2]); } + // BUG: sometimes this gets called twice before the cache has been populated, which parses all the files multiple times (only affects performance) + public get fileCache() { + if (!this._fileCache) return this.cacheAdd(); + else return this.cacheGen(); + } + + async onload() { + addIcon('globe', consts.RIBBON_ICON); + + await this.loadSettings(); + + this.addRibbonIcon('globe', 'Open map view', () => { + this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); + }); + + this.registerView(consts.MAP_VIEW_NAME, (leaf: WorkspaceLeaf) => { + return new MapView(leaf, this.settings, this); + }); + + this.addCommand({ + id: 'open-map-view', + name: 'Open Map View', + callback: () => { + this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); + }, + }); + + this.addSettingTab(new SettingsTab(this.app, this)); + + 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) { + menu.addItem((item: MenuItem) => { + item.setTitle('Show on map'); + item.setIcon('globe'); + item.onClick(() => this.openMapWithLocation(location)); + }); + menu.addItem((item: MenuItem) => { + item.setTitle('Open in Google Maps'); + item.onClick(_ev => { + open(`https://maps.google.com/?q=${location.lat},${location.lng}`); + }); + }); + } + } + }); + + // TODO function signature is a guess, revise when API is released + // @ts-ignore + this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor, view: FileView) => { + if (view instanceof FileView) { + const location = this.getLocationOnEditorLine(editor, view); + if (location) { + menu.addItem((item: MenuItem) => { + item.setTitle('Show on map'); + item.setIcon('globe'); + item.onClick(() => this.openMapWithLocation(location)); + }); + menu.addItem((item: MenuItem) => { + item.setTitle('Open in Google Maps'); + item.onClick(_ev => { + open(`https://maps.google.com/?q=${location.lat},${location.lng}`); + }); + }); + } + } + }); + this.app.vault.on('delete',file => this.cacheRemove(file)); + } + + private async openMapWithLocation(location: leaflet.LatLng) { + await this.app.workspace.getLeaf().setViewState({ + type: consts.MAP_VIEW_NAME, + state: { + mapCenter: location, + mapZoom: this.settings.zoomOnGoFromNote + } as any + }); + } + + private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { + const line = editor.getLine(editor.getCursor().line); + const match = matchInlineLocation(line)?.next()?.value; + let selectedLocation = null; + if (match) + selectedLocation = new leaflet.LatLng(parseFloat(match[1]), parseFloat(match[2])); + else { + const fmLocation = getFrontMatterLocation(view.file, this.app); + if (line.indexOf('location') > -1 && fmLocation) + selectedLocation = fmLocation; + } + if (selectedLocation) { + verifyLocation(selectedLocation); + return selectedLocation; + } + return null; + } + + onunload() { + } + + async loadSettings() { + //TODO: loading and saving the cache from/to a json file would make the map ready faster for large vaults + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + async getImageCoords(file: TFile) { + try{ + let { latitude, longitude } = await exifr.parse(await this.app.vault.adapter.readBinary(file.path)); + if(!latitude || !longitude) throw 0; + let leafletMarker = new FileMarker(file, new leaflet.LatLng(latitude, longitude)); + leafletMarker.icon = this.getImageMarker(this.settings); + return leafletMarker; + } catch{/* just ignore file parsing errors or empty location data for now*/} + return null; + } + + //TODO: as more filetypes are supported, icons should be configurable for each + getImageMarker(settings: PluginSettings) { + return getIconFromOptions(Object.assign({}, settings.markerIcons.default, { "prefix": "fas", "icon": "fa-camera" })); + } + + // TODO: should probably make the cache it's own class + public cacheGet(file: TFile) { return this._fileCache.get(file.path)[2]; } + public cacheRemove(...files: TAbstractFile[]) { if (files?.length) for (let file of files) this._fileCache.delete(file.path); } + async cacheReset() { + this._fileCache = null; + for (let f of this.cacheAdd()); + } + + *cacheAdd(...files: TFile[]) { + let add = async (file: TFile) => { + let existing = this._fileCache.get(file.path); + if (existing) return existing[2]; + for (const rule of this.CACHE_RULES) + if (rule.pred(file)) { + let marker = await rule.extract(file); + if (marker) { + this._fileCache.set(file.path, [file, rule, marker]); + return marker; + } + } + return null; + }; + if (!this._fileCache) this._fileCache = new Map(); + if (files?.length) for (let file of files) yield add(file); + else { + console.log('Loading cache...'); + // md files will be faster to extract from so we do them first + let markdownFilesFirst = this.app.vault.getFiles().sort((a, b) => + a.extension == b.extension ? 0 : a.extension == 'md' ? 1 : -1 + ); + for (let file of markdownFilesFirst) yield add(file); + } + } +} + +class SettingsTab extends PluginSettingTab { + plugin: MapViewPlugin; + + constructor(app: App, plugin: MapViewPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + let { containerEl } = this; + + containerEl.empty(); + + containerEl.createEl('h2', { text: 'Settings for the map view plugin.' }); + + new Setting(containerEl) + .setName('Automatically add markers for images') + .setDesc('Search the vault for image files and add markers on the map if they have location data') + .addToggle(component => { + component + .setValue(this.plugin.settings.detectImageLocations) + .onChange(async (value) => { + this.plugin.settings.detectImageLocations = value; + this.plugin.cacheReset(); + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName('Map follows search results') + .setDesc('Auto focus the map to fit search results.') + .addToggle(component => {component + .setValue(this.plugin.settings.autoZoom) + .onChange(async (value) => { + this.plugin.settings.autoZoom = value; + await this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Default action for map marker click') + .setDesc('How should the corresponding note be opened when clicking a map marker? Either way, CTRL reverses the behavior.') + .addDropdown(component => { component + .addOption('samePane', 'Open in same pane (replace map view)') + .addOption('secondPane', 'Open in a 2nd pane and keep reusing it') + .addOption('alwaysNew', 'Always open a new pane') + .setValue(this.plugin.settings.markerClickBehavior || 'samePane') + .onChange(async (value: any) => { + this.plugin.settings.markerClickBehavior = value; + this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('New pane split direction') + .setDesc('Which way should the pane be split when opening in a new pane.') + .addDropdown(component => { component + .addOption('horizontal', 'Horizontal') + .addOption('vertical', 'Vertical') + .setValue(this.plugin.settings.newPaneSplitDirection || 'horizontal') + .onChange(async (value: any) => { + this.plugin.settings.newPaneSplitDirection = value; + this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('New note name format') + .setDesc('Date/times in the format can be wrapped in {{date:...}}, e.g. "note-{{date:YYYY-MM-DD}}".') + .addText(component => { component + .setValue(this.plugin.settings.newNoteNameFormat || DEFAULT_SETTINGS.newNoteNameFormat) + .onChange(async (value: string) => { + this.plugin.settings.newNoteNameFormat = value; + this.plugin.saveSettings(); + }) + }); + new Setting(containerEl) + .setName('New note location') + .setDesc('Location for notes created from the map.') + .addText(component => { component + .setValue(this.plugin.settings.newNotePath || '') + .onChange(async (value: string) => { + this.plugin.settings.newNotePath = value; + this.plugin.saveSettings(); + }) + }); + new Setting(containerEl) + .setName('Template file location') + .setDesc('Choose the file to use as a template, e.g. "templates/map-log.md".') + .addText(component => { component + .setValue(this.plugin.settings.newNoteTemplate || '') + .onChange(async (value: string) => { + this.plugin.settings.newNoteTemplate = value; + this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Default zoom for "show on map" action') + .setDesc('When jumping to the map from a note, what should be the display zoom?') + .addSlider(component => {component + .setLimits(1, 18, 1) + .setValue(this.plugin.settings.zoomOnGoFromNote) + .onChange(async (value) => { + this.plugin.settings.zoomOnGoFromNote = value; + await this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Map source (advanced)') + .setDesc('Source for the map tiles, see the documentation for more details. Requires to close & reopen the map.') + .addText(component => {component + .setValue(this.plugin.settings.tilesUrl) + .onChange(async (value) => { + this.plugin.settings.tilesUrl = value; + await this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Edit the marker icons (advanced)') + .setDesc("Refer to the plugin documentation for more details.") + .addTextArea(component => component + .setValue(JSON.stringify(this.plugin.settings.markerIcons, null, 2)) + .onChange(async value => { + try { + const newMarkerIcons = JSON.parse(value); + this.plugin.settings.markerIcons = newMarkerIcons; + await this.plugin.saveSettings(); + } catch (e) { + } + })); + + } +} diff --git a/src/mapView.ts b/src/mapView.ts index b4ab915..0c23e48 100644 --- a/src/mapView.ts +++ b/src/mapView.ts @@ -1,4 +1,4 @@ -import { App, TAbstractFile, Editor, ButtonComponent, MarkdownView, getAllTags, ItemView, MenuItem, Menu, TFile, TextComponent, DropdownComponent, WorkspaceLeaf } from 'obsidian'; +import { TAbstractFile, Editor, ButtonComponent, MarkdownView, getAllTags, ItemView, MenuItem, Menu, TFile, TextComponent, DropdownComponent, WorkspaceLeaf } 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 @@ -10,7 +10,7 @@ import 'leaflet-geosearch/dist/geosearch.css'; import * as consts from 'src/consts'; import { PluginSettings, DEFAULT_SETTINGS } from 'src/settings'; -import { MarkersMap, FileMarker, buildMarkers, getIconFromOptions, buildAndAppendFileMarkers } from 'src/markers'; +import { MarkersMap, FileMarker, getIconFromOptions } from 'src/markers'; import MapViewPlugin from 'src/main'; import * as utils from 'src/utils'; @@ -19,7 +19,7 @@ type MapState = { mapCenter: leaflet.LatLng; tags: string[]; version: number; -} +}; export class MapView extends ItemView { private settings: PluginSettings; @@ -27,7 +27,7 @@ export class MapView extends ItemView { private state: MapState; private display = new class { map: leaflet.Map; - markers: MarkersMap = new Map(); + markers: MarkersMap; mapDiv: HTMLDivElement; tagsBox: TextComponent; }; @@ -35,6 +35,9 @@ export class MapView extends ItemView { private defaultState: MapState; private newPaneLeaf: WorkspaceLeaf; private isOpen: boolean = false; + private createTimer:any; + private refreshTimer:any; + private boxTimer:any; public onAfterOpen: (map: leaflet.Map, markers: MarkersMap) => any = null; @@ -50,21 +53,42 @@ export class MapView extends ItemView { tags: this.settings.defaultTags || consts.DEFAULT_TAGS, version: 0 }; - this.setState = async (state: MapState, result) => { + + this.getState = () => this.state; + this.setState = async (state: MapState, result: any = null) => { if (state) { console.log(`Received setState:`, state); // We give the given state priority by setting a high version state.version = 100; - await this.updateMapToState(state); + this.updateMapToState(state); } - } - this.getState = (): MapState => { - return this.state; - } + }; + + this.app.metadataCache.on('resolved', () => { + this.app.workspace.onLayoutReady(() => { + this.display.map.whenReady(() => { + this.updateMapToState(this.defaultState).then(() => { + this.refreshView(); + if (this.onAfterOpen != null) + this.onAfterOpen(this.display.map, this.display.markers); + }); + }); - 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)); + this.app.vault.on('rename', (file, oldPath) => this.handleRenameMarker(file, oldPath)); + this.app.metadataCache.on('changed', file => this.handleUpdateMarker(file)); + this.app.vault.on('delete', file => this.handleRemoveMarker(file)); + + // unfortunately create recieves an abstract file so whenever we detect created files we have to refresh the cache, + // in case of rapid creation we do this on a timeout in order to refresh only once + this.app.vault.on('create', () => { + if (this.createTimer) { window.clearTimeout(this.createTimer); this.createTimer = null; } + this.createTimer = window.setTimeout(() => { + this.createTimer = null; + this.plugin.cacheReset().then(() => this.updateMapToState(this.state,true)); + }, 2000); + }); + }); + }); } getViewType() { return 'map'; } @@ -84,8 +108,12 @@ export class MapView extends ItemView { this.display.tagsBox = new TextComponent(controlsDiv); 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); + if (this.boxTimer) { window.clearTimeout(this.boxTimer); this.boxTimer = null; } + this.boxTimer = window.setTimeout(() => { + this.boxTimer = null; + that.state.tags = tagsBox?.length ? tagsBox.split(',').filter(t => t.length > 0) : null; + this.updateMapToState(this.state); + }, 600); //enough time to not trigger while typing without feeling slow to respond }); let tagSuggestions = new DropdownComponent(controlsDiv); tagSuggestions.setValue('Quick add tag'); @@ -105,14 +133,14 @@ export class MapView extends ItemView { goDefault .setButtonText('Reset') .setTooltip('Reset the view to the defined default.') - .onClick(async () => { + .onClick(() => { let newState = { mapZoom: this.settings.defaultZoom || consts.DEFAULT_ZOOM, mapCenter: this.settings.defaultMapCenter || consts.DEFAULT_CENTER, tags: this.settings.defaultTags || consts.DEFAULT_TAGS, version: this.state.version + 1 }; - await this.updateMapToState(newState); + this.updateMapToState(newState); }); let fitButton = new ButtonComponent(controlsDiv); fitButton @@ -131,7 +159,7 @@ export class MapView extends ItemView { }); this.contentEl.style.padding = '0px 0px'; this.contentEl.append(controlsDiv); - this.display.mapDiv = createDiv({cls: 'map'}, (el: HTMLDivElement) => { + this.display.mapDiv = createDiv({ cls: 'map' }, (el: HTMLDivElement) => { el.style.zIndex = '1'; el.style.width = '100%'; el.style.height = '100%'; @@ -153,7 +181,6 @@ export class MapView extends ItemView { } async createMap() { - var that = this; // LeafletJS compatability: disable tree-shaking for the full-screen module var dummy = leafletFullscreen; this.display.map = new leaflet.Map(this.display.mapDiv, { @@ -161,7 +188,8 @@ export class MapView extends ItemView { zoom: 13, zoomControl: false, worldCopyJump: true, - maxBoundsViscosity: 1.0}); + maxBoundsViscosity: 1.0 + }); leaflet.control.zoom({ position: 'topright' }).addTo(this.display.map); @@ -169,7 +197,7 @@ export class MapView extends ItemView { '© OpenStreetMap contributors' : ''; this.display.map.addLayer(new leaflet.TileLayer(this.settings.tilesUrl, { maxZoom: 20, - subdomains:['mt0','mt1','mt2','mt3'], + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], attribution: attribution, className: this.settings.darkMode ? "dark-mode" : "" })); @@ -179,7 +207,8 @@ export class MapView extends ItemView { marker: { icon: getIconFromOptions(consts.SEARCH_RESULT_MARKER as leaflet.BaseIconOptions) }, - style: 'button'}); + style: 'button' + }); this.display.map.addControl(searchControl); this.display.map.on('zoomend', (event: leaflet.LeafletEvent) => { this.state.mapZoom = this.display.map.getZoom(); @@ -199,7 +228,7 @@ export class MapView extends ItemView { newFileName, location, this.settings.newNoteTemplate); this.goToFile(file, ev.ctrlKey); }); - }) + }); mapPopup.addItem((item: MenuItem) => { const location = `${event.latlng.lat},${event.latlng.lng}`; item.setTitle('New multi-location note'); @@ -209,7 +238,7 @@ export class MapView extends ItemView { newFileName, location, this.settings.newNoteTemplate); this.goToFile(file, ev.ctrlKey); }); - }) + }); mapPopup.addItem((item: MenuItem) => { const location = `${event.latlng.lat},${event.latlng.lng}`; item.setTitle(`Copy location as inline`); @@ -239,101 +268,141 @@ export class MapView extends ItemView { }); mapPopup.showAtPosition(event.originalEvent); }); - this.display.map.whenReady(async () => { - await that.updateMapToState(this.defaultState, !this.settings.defaultZoom); - if (this.onAfterOpen != null) - this.onAfterOpen(this.display.map, this.display.markers); - }) - } // 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) { - const files = this.getFileListByQuery(state.tags); - let newMarkers = await buildMarkers(files, this.settings, this.app); - if (state.version < this.state.version) { - // If the state we were asked to update is old (e.g. because while we were building markers a newer instance - // of the method was called), cancel the update - return; - } - this.state = state; - this.updateMapMarkers(newMarkers); + async updateMapToState(state: MapState, updateFromCache = false) { + // If the state we we're asked to update is old (e.g. because while we were building markers a newer instance + // of the method was called), cancel the update + if (state.version < this.state.version) return; + else this.state = state; 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) - this.display.map.setView(this.state.mapCenter, this.state.mapZoom); - if (autoFit) - this.autoFitMapToMarkers(); + + this.fetchAndUpdateMapMarkers(updateFromCache); + this.cleanMarkers(); + for(let marker of this.markerQuery(state.tags)) this.showOrHideMarker(marker); } - getFileListByQuery(tags: string[]): TFile[] { - let results: TFile[] = []; - const allFiles = this.app.vault.getFiles(); - for (const file of allFiles) { - var match = true; - if (tags && tags.length > 0) { - // A tags query exist, file defaults to non-matching and we'll add it if it has one of the tags - match = false; - const fileCache = this.app.metadataCache.getFileCache(file); - if (fileCache && fileCache.tags) { - const tagsMatch = fileCache.tags.some(tagInFile => tags.indexOf(tagInFile.tag) > -1); - if (tagsMatch) - match = true; - } + private fetchAndUpdateMapMarkers(updateFromCache = false) { + if (!this.display.markers || updateFromCache) { + this.display.markers ??= new Map(); + for (const promise of this.plugin.fileCache) { + promise.then(async marker => { + if (!marker) return; + this.updateMarker(marker); + }); } - if (match) - results.push(file); - } - return results; + } else for (const marker of this.display.markers.values()) + this.updateMarker(marker); } - updateMapMarkers(newMarkers: FileMarker[]) { - let newMarkersMap: MarkersMap = new Map(); - for (let marker of newMarkers) { - const existingMarker = this.display.markers.has(marker.id) ? - this.display.markers.get(marker.id) : null; - if (existingMarker && existingMarker.isSame(marker)) { - // This marker exists, so just keep it - newMarkersMap.set(marker.id, this.display.markers.get(marker.id)); + private async handleRemoveMarker(removed: TAbstractFile) { + if (!this.display.map || !this.isOpen) return; + this.display.markers.forEach(marker =>{ + if(marker.file.path == removed.path) { this.display.markers.delete(marker.id); - } else { - // New marker - create it - marker.mapMarker = leaflet.marker(marker.location, { icon: marker.icon || new leaflet.Icon.Default() }) - .addTo(this.display.map) - .bindTooltip(marker.file.name); - marker.mapMarker.on('click', (event: leaflet.LeafletMouseEvent) => { - this.goToMarker(marker, event.originalEvent.ctrlKey, true); + marker.mapMarker.removeFrom(this.display.map); + } + }); + } + private handleRenameMarker(renamed: TAbstractFile, oldPath: string) { + if (!this.display.map || !this.isOpen) return; + this.display.markers.forEach(marker => {if(marker.file.path == oldPath) Object.assign(marker.file,renamed)}); + } + /* + this currently recalculates all markers for a file that is changed while the map is open, + which could potentially be a bottleneck on a huge file with tons of markers, + but it's doubtful this would ever be an issue and finding exactly which marker changed adds unnecessary complexity + */ + private async handleUpdateMarker(changed: TFile) { + if (!this.display.map || !this.isOpen) return; + this.handleRemoveMarker(changed); + this.plugin.cacheRemove(changed); + for(let promise of this.plugin.cacheAdd(changed)) + promise.then(marker => this.updateMarker(marker)); + } + + updateMarker(marker: FileMarker|FileMarker[]) { + if(!marker) return; + + if (marker instanceof Array) { + marker.forEach(m => this.updateMarker(m)); + return; + } + + let existingMarker = this.display.markers.get(marker.id); + if (!existingMarker?.isSame(marker)) { + // New marker - create it + marker.mapMarker = leaflet.marker(marker.location, { icon: marker.icon || new leaflet.Icon.Default() }) + .addTo(this.display.map) + .bindTooltip(marker.file.name); + marker.mapMarker.on('click', (event: leaflet.LeafletMouseEvent) => { + this.goToMarker(marker, event.originalEvent.ctrlKey, true); + }); + marker.mapMarker.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); }); }); - marker.mapMarker.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 in Google Maps'); - item.onClick(ev => { - open(`https://maps.google.com/?q=${marker.location.lat},${marker.location.lng}`); - }); + mapPopup.addItem((item: MenuItem) => { + item.setTitle('Open in Google Maps'); + item.onClick(ev => { + open(`https://maps.google.com/?q=${marker.location.lat},${marker.location.lng}`); }); - mapPopup.showAtPosition(ev); - ev.stopPropagation(); - }) - newMarkersMap.set(marker.id, marker); - } + }); + mapPopup.showAtPosition(ev); + ev.stopPropagation(); + }); + this.display.markers.set(marker.id, marker); + this.refreshView(); } - for (let [key, value] of this.display.markers) { - value.mapMarker.removeFrom(this.display.map); + } + + /** has "side effect" of marking unmatched markers dirty so they are removed */ + *markerQuery(tags: string[]) { + //NOTE: this way of doing things means any tag filtering automatically excludes non-markdown files + //TODO: make it possible to insert multiple inclusion strategies, e.g. filter by file type + for (const [markerId,marker] of this.display.markers) { + if (tags?.length && !this.app.metadataCache.getFileCache(marker.file)?.tags?.some(t => tags.includes(t.tag))) + marker.dirty = true; + yield marker; } - this.display.markers = newMarkersMap; + } + cleanMarkers(){ + this.display.markers.forEach(marker => marker.dirty = false); + } + showOrHideMarker(marker: FileMarker) { + if (marker?.dirty) marker.mapMarker.removeFrom(this.display.map); + else marker.mapMarker.addTo(this.display.map); + this.refreshView(); + } + + // each refresh request resets the timer unless it's been more than a second since the first request + // this causes periodic refreshes every second when getting many sequential requests + refreshStartTime:number; + refreshView() { + this.refreshStartTime ??= Date.now(); + if (this.refreshTimer){ + if(Date.now() - this.refreshStartTime < 1000) window.clearTimeout(this.refreshTimer); + else return; + } + this.refreshTimer = window.setTimeout(async () => { + this.refreshTimer = null; + this.refreshStartTime = null; + if (this.state.mapCenter && this.state.mapZoom) + this.display.map.setView(this.state.mapCenter, this.state.mapZoom); + if(this.settings.autoZoom) this.autoFitMapToMarkers(); + }, 100); + } async autoFitMapToMarkers() { if (this.display.markers.size > 0) { const locations: leaflet.LatLng[] = Array.from(this.display.markers.values()).map(fileMarker => fileMarker.location); - console.log(`Auto fit by state:`, this.state); this.display.map.fitBounds(leaflet.latLngBounds(locations)); } } @@ -375,7 +444,7 @@ export class MapView extends ItemView { if (fileLocation) { let pos = editor.offsetToPos(fileLocation); if (highlight) { - editor.setSelection({ch: 0, line: pos.line}, {ch: 1000, line: pos.line}); + editor.setSelection({ ch: 0, line: pos.line }, { ch: 1000, line: pos.line }); } else { editor.setCursor(pos); editor.refresh(); @@ -390,7 +459,7 @@ export class MapView extends ItemView { return this.goToFile(marker.file, useCtrlKeyBehavior, marker.fileLocation, highlight); } - getAllTagNames() : string[] { + getAllTagNames(): string[] { let tags: string[] = []; const allFiles = this.app.vault.getFiles(); for (const file of allFiles) { @@ -404,25 +473,13 @@ export class MapView extends ItemView { return tags; } - getEditor() : Editor { + getEditor(): Editor { let view = this.app.workspace.getActiveViewOfType(MarkdownView); if (view) return view.editor; return null; } - private async updateMarkersWithRelationToFile(fileRemoved: string, fileAddedOrChanged: TAbstractFile, skipMetadata: boolean) { - if (!this.display.map || !this.isOpen) - return; - let newMarkers: FileMarker[] = []; - for (let [markerId, fileMarker] of this.display.markers) { - if (fileMarker.file.path !== fileRemoved) - newMarkers.push(fileMarker); - } - if (fileAddedOrChanged && fileAddedOrChanged instanceof TFile) - await buildAndAppendFileMarkers(newMarkers, fileAddedOrChanged, this.settings, this.app) - this.updateMapMarkers(newMarkers); - } - } + diff --git a/src/markers.ts b/src/markers.ts index d6779fc..a867a4f 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -1,13 +1,15 @@ -import { App, TFile } from 'obsidian'; -import * as leaflet from 'leaflet'; import 'leaflet-extra-markers'; import 'leaflet-extra-markers/dist/css/leaflet.extra-markers.min.css'; + +import * as consts from 'src/consts'; +import * as leaflet from 'leaflet'; + +import { App, TFile } from 'obsidian'; +import { PluginSettings } from 'src/settings'; // Ugly hack for obsidian-leaflet compatability, see https://github.com/esm7/obsidian-map-view/issues/6 // @ts-ignore let localL = L; -import { PluginSettings } from 'src/settings'; -import * as consts from 'src/consts'; type MarkerId = string; @@ -18,6 +20,7 @@ export class FileMarker { icon?: leaflet.Icon; mapMarker?: leaflet.Marker; id: MarkerId; + dirty:boolean = false; constructor(file: TFile, location: leaflet.LatLng) { this.file = file; @@ -40,6 +43,7 @@ export class FileMarker { this.icon?.options?.shape === other.icon?.options?.shape; } + //NOTE: given two of the same locations in the same file this results in only one marker eventually being created (which I assume is always ok) generateId() : MarkerId { return this.file.name + this.location.lat.toString() + this.location.lng.toString(); } @@ -47,34 +51,26 @@ export class FileMarker { export type MarkersMap = Map; -export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], file: TFile, settings: PluginSettings, app: App, skipMetadata?: boolean) { +export async function markersFromMd(file: TFile, settings: PluginSettings, app: App) { const fileCache = app.metadataCache.getFileCache(file); const frontMatter = fileCache?.frontmatter; if (frontMatter) { - if (!skipMetadata) { + if ('locations' in frontMatter) { + const markersFromFile = await getMarkersFromFileContent(file, settings, app); + return markersFromFile; + } + else { const location = getFrontMatterLocation(file, app); if (location) { verifyLocation(location); let leafletMarker = new FileMarker(file, location); leafletMarker.icon = getIconForMarker(leafletMarker, settings, app); - mapToAppendTo.push(leafletMarker); + return leafletMarker; } } - if ('locations' in frontMatter) { - const markersFromFile = await getMarkersFromFileContent(file, settings, app); - mapToAppendTo.push(...markersFromFile); - } } } -export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise { - let markers: FileMarker[] = []; - for (const file of files) { - await buildAndAppendFileMarkers(markers, file, settings, app); - } - return markers; -} - function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App) : leaflet.Icon { let result = settings.markerIcons.default; const fileCache = app.metadataCache.getFileCache(marker.file); @@ -145,7 +141,7 @@ export function getFrontMatterLocation(file: TFile, app: App) : leaflet.LatLng { try { const location = frontMatter.location; // We have a single location at hand - if (location.length == 2 && typeof(location[0]) === 'number' && typeof(location[1]) === 'number') { + if (location.length == 2 && typeof (location[0]) === 'number' && typeof(location[1]) === 'number') { const location = new leaflet.LatLng(frontMatter.location[0], frontMatter.location[1]); verifyLocation(location); return location; diff --git a/src/settings.ts b/src/settings.ts index f8e7ba2..805cc1f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,6 +1,6 @@ import * as consts from 'src/consts'; import { LatLng } from 'leaflet'; -import { SplitDirection } from 'obsidian'; +import { SplitDirection, TAbstractFile, TFile } from 'obsidian'; export type PluginSettings = { darkMode: boolean; @@ -16,19 +16,27 @@ export type PluginSettings = { newNoteNameFormat?: string; newNotePath?: string; newNoteTemplate?: string; -} + detectImageLocations: boolean; + imageMatcher: RegExp; +}; export const DEFAULT_SETTINGS: PluginSettings = { darkMode: false, markerIcons: { - "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, - "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, - "#trip-water": {"prefix": "fas", "markerColor": "blue"}, - "#dogs": {"prefix": "fas", "icon": "fa-paw"}, + "default": { "prefix": "fas", "icon": "fa-circle", "markerColor": "blue" }, + "#trip": { "prefix": "fas", "icon": "fa-hiking", "markerColor": "green" }, + "#trip-water": { "prefix": "fas", "markerColor": "blue" }, + "#dogs": { "prefix": "fas", "icon": "fa-paw" }, }, zoomOnGoFromNote: 15, tilesUrl: consts.TILES_URL_OPENSTREETMAP, autoZoom: true, markerClickBehavior: 'samePane', - newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}' + newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}', + detectImageLocations: true, + imageMatcher: /(?:png|jpe?g|tiff)/i, }; + +export function isImage(file: TAbstractFile) { + return !!file.path.match(DEFAULT_SETTINGS.imageMatcher); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c1266ea..c4f6ec1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,21 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "es6", - "allowJs": true, - "noImplicitAny": true, - "moduleResolution": "node", - "importHelpers": true, - "lib": [ - "dom", - "es2020", - "scripthost" - ] - }, - "include": [ - "**/*.ts" - ] -} +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "es6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "lib": [ + "dom", + "es2020", + "scripthost" + ] + }, + "include": [ + "**/*.ts" + ] +}