├── .editorconfig ├── .github └── workflows │ ├── nodejs.yml │ ├── publish.yml │ └── static.yml ├── .gitignore ├── .npmrc ├── README.md ├── docs ├── app.js ├── bundle.js ├── bundle.js.map ├── index.html └── style.css ├── index.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── bookmarks.js ├── formpopup.js ├── leaflet.bookmarks.less ├── leaflet.delegate.js ├── storage.js ├── storage │ ├── global.js │ ├── localstorage.js │ └── xhr.js └── string.js └── test └── bookmarks.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | 10 | [*{.scss,.less}] 11 | indent_style = tab 12 | 13 | [Makefile] 14 | indent_style = tab 15 | indent_size = 4 16 | 17 | [*.json] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm ci 27 | - run: npm run build --if-present 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*.*.*" 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 18 15 | - run: npm ci 16 | - run: npm run build 17 | - uses: JS-DevTools/npm-publish@v1 18 | with: 19 | token: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["master"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v3 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v1 38 | with: 39 | # Upload entire repository 40 | path: "./docs" 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v1 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .externalToolBuilders/* 3 | .project 4 | *.properties 5 | .idea/* 6 | .cache 7 | .settings/* 8 | buildlogs 9 | node_modules 10 | bower_components/* 11 | 12 | build.properties 13 | .mocha-puppeteer 14 | 15 | test/lib/ 16 | dist 17 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Leaflet.Bookmarks 2 | ================= 3 | [![npm version](https://badge.fury.io/js/leaflet-bookmarks.svg)](http://badge.fury.io/js/leaflet-bookmarks) 4 | [![Bower version](https://badge.fury.io/bo/leaflet-bookmarks.svg)](http://badge.fury.io/bo/leaflet-bookmarks) [![CircleCI](https://circleci.com/gh/w8r/Leaflet.Bookmarks.svg?style=shield)](https://circleci.com/gh/w8r/Leaflet.Bookmarks) 5 | 6 | Highly customizable Leaflet plugin for user-generated bookmarks, stored locally or on the server. 7 | 8 | See [demo and documentation](http://w8r.github.io/Leaflet.Bookmarks/) 9 | 10 | ## Description 11 | 12 | This is a highly customizable plugin for leaflet to allow users to drop bookmarks on your map and to store them locally or on server. It uses localstorage by default, but allows you to implement your own storage solution. You can also redefine the addition of bookmarks, their looks and fields. 13 | 14 | Right-click on the map, to add a new bookmark 15 | 16 | ## Usage 17 | 18 | Includes 19 | 20 | ```html 21 | 22 | 23 | 24 | ``` 25 | ## Put control on the map 26 | 27 | ```js 28 | var map = new L.Map(...); 29 | var control = new L.Control.Bookmarks().addTo(map); 30 | ``` 31 | 32 | ## Adding a bookmark 33 | 34 | How you trigger a bookmark addition is your own choice. For the demo I used the beautiful Leaflet.contextmenu plugin by @aratcliffe, but you are free to take any approach you want - it's based on the event bookmark:new dispatched by the map instance: 35 | 36 | ```js 37 | map.fire('bookmark:new', { 38 | latlng: new L.LatLng(..., ...) 39 | }); 40 | ``` 41 | 42 | If you want you can omit the naming step and add a bookmark straight to the list using a bookmark:add event. 43 | 44 | ```js 45 | map.fire('bookmark:add', { 46 | data: { 47 | id: 'XXXX' // make sure it's unique, 48 | name: 'Bookmark name', 49 | latlng: [lat, lng] // important, we're dealing with JSON here, 50 | your_key: 'your value' 51 | } 52 | }); 53 | ``` 54 | 55 | ## Events 56 | 57 | Triggered on **map**: 58 | * `bookmark:removed` - Bookmark has been removed from storage and interface 59 | * `bookmark:show` - bookmark selected from list or just created 60 | 61 | ## `GeoJSON` support 62 | 63 | There are GeoJSON import/export methods provided for convinence and use within the storage methods of your choice 64 | 65 | * `.bookmarkToFeature(bookmark)` 66 | Use it on a single bookmark if you want to convert it into geoJSON `Feature` before send 67 | * `.toGeoJSON()` 68 | Exports the whole list into GeoJSON, uses `.bookmarkToFeature` 69 | * `.fromGeoJSON(geojson)` 70 | Uses properties as the bookmark contents, geometry as the location. GeoJSON `Point` expected, you can change it for a different type of geometry, if you want, then you'll have to take care of the centroid routines. 71 | 72 | ## Customizing 73 | 74 | ### localStorage or variable storage 75 | 76 | The control uses localStorage by default. Your bookmarks will be stored in prefixed key-value pairs. You can customize the prefix if you want to 77 | 78 | ```js 79 | var control = new L.Control.Bookmarks({ 80 | name: 'your-storage-prefix', // defaults to 'leaflet-bookmarks' 81 | localStorage: false // if you want to use local variable for storage 82 | }); 83 | ``` 84 | 85 | P.S. You can access the storage directly through the control: 86 | 87 | ```js 88 | control._storage.getItem(key, callback); 89 | control._storage.setItem(key, value, callback); 90 | control._storage.removeItem(key, callback); 91 | control._storage.getAllItems(callback); 92 | ``` 93 | 94 | ### Custom storage(e.g AJAX) 95 | 96 | I intentionally didn't add an engine for anything else than localStorage and local variable storage, so you could use your own xhr functions. To do that, you have to pass the interface to your storage to the control like this: 97 | 98 | ```js 99 | var control = new L.Control.Bookmarks({ 100 | storage: { 101 | getItem: function(id, callback){ 102 | $.ajax({ 103 | url: '/bookmarks/' + id, 104 | type: 'GET', 105 | dataType: 'json', 106 | success: callback 107 | }); 108 | }, 109 | setItem: function(id, value, callback){ 110 | $.ajax({ 111 | url: '/bookmarks/' + id, 112 | type: 'PUT', 113 | data: value, 114 | dataType: 'json', 115 | success: callback 116 | }); 117 | }, 118 | removeItem: function(id, callback){ 119 | $.ajax({ 120 | url: '/bookmarks/' + id, 121 | type: 'DELETE', 122 | dataType: 'json', 123 | success: callback 124 | }); 125 | }, 126 | getAllItems: function(callback){ 127 | $.ajax({ 128 | url: '/bookmarks/', 129 | type: 'GET', 130 | dataType: 'json', 131 | success: callback 132 | }); 133 | } 134 | } 135 | }).addTo(map); 136 | ``` 137 | 138 | ### Custom templates 139 | 140 | Pass those into the options if you want to customize the popup or list templates. Proceed with caution 141 | 142 | ```js 143 | { 144 | // list item MUST contain `data-id` attribute, 145 | // or provide your own `options.getBookmarkFromListItem(listItem)` method 146 | bookmarkTemplate: '
  • ' + 147 | '×' + 148 | '{{ data.name }}' + 149 | '{{ data.coords }}' + 150 | '
  • ', 151 | 152 | // format list item name 153 | formatName: function(name){ ... }, 154 | 155 | // format coords 156 | // again, you have access to the control here, so you can 157 | // output projected coords for example 158 | formatCoords: function(laltlng){ 159 | var projected = this._map.project(L.latLng(latlng[0], latlng[1])); 160 | return 'X: ' + projected.x + 'm, Y: ' + projected.y + 'm'; 161 | }, 162 | 163 | // no bookmarks yet 164 | emptyTemplate: '
  • ' + 165 | '{{ data.emptyMessage }}
  • ', 166 | 167 | // no bookmarks text 168 | emptyMessage: "Hell no, I forgot where I've been!", 169 | 170 | // you can change them, but then provide your own styling 171 | bookmarkTemplateOptions: { 172 | itemClass: 'bookmark-item', 173 | nameClass: 'bookmark-name', 174 | coordsClass: 'bookmark-coords', 175 | removeClass: 'bookmark-remove', 176 | emptyClass: 'bookmarks-empty' 177 | }, 178 | 179 | // change that if you have custom fields in your bookmarks 180 | popupTemplate: '

    {{ name }}

    {{ latlng }}

    ', 181 | 182 | // here you extract them for the template. 183 | // note - you have access to controls methods and fields here 184 | getPopupContent: function(bookmark) { 185 | return L.Util.template(this.options.popupTemplate, { 186 | latlng: this.formatCoords(bookmark.latlng), 187 | name: bookmark.name 188 | }); 189 | }, 190 | 191 | // here you can filter bookmarks that you get from 192 | // the storage or a user, make sure it returns an Array 193 | filterBookmarks: function(bookmarks){ ... }, 194 | } 195 | ``` 196 | 197 | You can customize the bookmark add form too, pass that to the control options: 198 | 199 | ```js 200 | formPopup: { 201 | className: 'leaflet-bookmarks-form-popup', 202 | templateOptions: { 203 | formClass: 'leaflet-bookmarks-form', 204 | inputClass: 'leaflet-bookmarks-form-input', 205 | coordsClass: 'leaflet-bookmarks-form-coords', 206 | submitClass: 'leaflet-bookmarks-form-submit', 207 | inputPlaceholder: 'Bookmark name', 208 | submitText: '+' 209 | }, 210 | getBookmarkData: function(){ 211 | var input = this._contentNode.querySelector('.' + 212 | this.options.templateOptions.inputClass); 213 | ... 214 | return { 215 | id: 'YOUR_UNIQUE_ID', 216 | name: 'Bookmark name', 217 | your_custom_field: ... // get it from the form inputs 218 | latlng: this._source.getLatLng() // get it from the marker 219 | }; 220 | }, 221 | onRemove: function(bookmark, callback){ 222 | /* use that to add confirmation menus 223 | when removing a bookmark */ 224 | }, 225 | generateNames: true, // generate unique name if it's not provided by the user 226 | generateNamesPrefix: 'Bookmark ', 227 | template: '
    ' + 228 | '' + 230 | '' + 232 | '
    {{ coords }}
    ' + 233 | '
    ', 234 | } 235 | ``` 236 | 237 | ### Editing 238 | 239 | You can enable bookmarks editing/removal by putting a flag in the bookmark object 240 | 241 | ```js 242 | { 243 | name: '', 244 | id: 'XXX', 245 | latlng: [lat, lng], 246 | editable: true, 247 | removable: true 248 | } 249 | ``` 250 | 251 | This will enable a menu on popup to remove or edit the bookmark. 252 | Presence of menu items will is defined by those params also 253 | 254 | ![screenshot 2015-05-27 21 32 51](https://cloud.githubusercontent.com/assets/26884/7845663/987abcfa-04b8-11e5-867d-f4ea025b416e.png) 255 | 256 | ### Removal 257 | 258 | You can pass a custom confirm function to the control, so you could handle confirmation menus 259 | 260 | ``` 261 | onRemove: function(bookmark, callback){ 262 | if(confirm('Are you really sure?')){ 263 | if(bookmark.name === 'Bamby') { 264 | alert('Keep your hands away!'); 265 | callback(false); // won't be removed 266 | } else { 267 | callback(true); // will be removed 268 | } 269 | } else { 270 | callback(false); 271 | } 272 | } 273 | ``` 274 | 275 | ### `L.Util._template` 276 | 277 | Small template function used by this project. It's a simple implementation of @lodash templates, using mustache interpolation syntax. You get it as a souvenir. 278 | 279 | ```js 280 | L.Util._template('Whereof one cannot {{ data.action }}, thereof one must keep {{ data.instead }}', { data: { action: 'speak', instead: 'silent' }}); 281 | // -> "Whereof one cannot speak, thereof one must keep silent" 282 | ``` 283 | ## Authors and Contributors 284 | 285 | Alexander Milevski 286 | 287 | ## License 288 | 289 | MIT 290 | 291 | ## Changelog 292 | 293 | * **0.4.0** 294 | * Leaflet 1.6 support 295 | * **0.3.0** 296 | * Fixed some bugs 297 | * Migrated to rollup build system and ESM modules 298 | * **0.2.0** 299 | * Editing/removal funtionality 300 | * "Add new" button 301 | * Tests added 302 | * **0.1.5** 303 | * GeoJSON support 304 | * **0.1.3** 305 | * Different layout when in `topleft` position 306 | * Scroll to bookmark on addition 307 | * **0.1.2** 308 | * Remove marker when bookmark is removed 309 | * **0.1.0** 310 | * npm & bower packages published 311 | * **0.0.2** 312 | * Zoom level ztored and used by default 313 | * Remove button flickering fixed 314 | * Add bookmark UX: now you can show the newly created bookmark right away 315 | * **0.0.1** 316 | * Initial release 317 | 318 | -------------------------------------------------------------------------------- /docs/app.js: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | import '../index'; 3 | import 'leaflet-contextmenu'; 4 | import 'leaflet-modal'; 5 | 6 | const map = window.map = new L.Map('map', { 7 | contextmenu: true, 8 | contextmenuItems: [{ 9 | text: 'Bookmark this position', 10 | callback: function(evt) { 11 | this.fire('bookmark:new', { latlng: evt.latlng }); 12 | } 13 | }] 14 | }).setView([22.2670, 114.188], 13); 15 | 16 | L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', { 17 | attribution: '© ' + 18 | 'OpenStreetMap contributors' 19 | }).addTo(map); 20 | 21 | // var bookmarksControl = global.bookmarksControlRight = new L.Control.Bookmarks({ 22 | // position: 'topright' 23 | // }); 24 | // map.addControl(bookmarksControl); 25 | 26 | const bookmarksControl = new L.Control.Bookmarks({ 27 | position: 'topleft', 28 | onRemove: function(bookmark, callback) { 29 | map.fire('modal', { 30 | title: 'Are you sure?', 31 | content: '

    Do you wnat to remove bookmark ' + bookmark.name + '?

    ', 32 | template: ['', 33 | '
    ', 34 | '', 35 | '' 39 | ].join(''), 40 | 41 | okText: 'Ok', 42 | cancelText: 'Cancel', 43 | OK_CLS: 'modal-ok', 44 | CANCEL_CLS: 'modal-cancel', 45 | 46 | width: 300, 47 | 48 | onShow: function({ modal }) { 49 | L.DomEvent 50 | .on(modal._container.querySelector('.modal-ok'), 'click', function() { 51 | modal.hide(); 52 | callback(true); 53 | }) 54 | .on(modal._container.querySelector('.modal-cancel'), 'click', function() { 55 | modal.hide(); 56 | callback(false) 57 | }); 58 | } 59 | }); 60 | }, 61 | }); 62 | 63 | map.addControl(bookmarksControl); 64 | 65 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Leaflet.Bookmarks 4 | 5 | 9 | 13 | 14 | 18 | 22 | 23 | 24 |
    25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | #map { 7 | width: 100%; 8 | height: 100%; 9 | display: block; 10 | } 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | import Bookmarks from "./src/bookmarks"; 3 | 4 | L.Control.Bookmarks = Bookmarks; 5 | 6 | export default Bookmarks; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leaflet-bookmarks", 3 | "version": "0.5.1", 4 | "description": "Leaflet plugin for user-generated bookmarks", 5 | "main": "dist/index.min.js", 6 | "module": "dist/index.mjs", 7 | "unpkg": "dist/index.min.js", 8 | "jsdelivr": "dist/index.min.js", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "test": "mocha-puppeteer test/*.test.js", 14 | "start": "npm run watch & serve -p 3001", 15 | "watch": "rollup -cw", 16 | "build-less": "lessc src/leaflet.bookmarks.less > dist/leaflet.bookmarks.css", 17 | "compress-less": "lessc -x src/leaflet.bookmarks.less > dist/leaflet.bookmarks.min.css", 18 | "build-css": "npm run build-less && npm run compress-less", 19 | "build-js": "rollup -c", 20 | "build": "npm run build-js && npm run build-css" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/w8r/Leaflet.Bookmarks" 25 | }, 26 | "keywords": [ 27 | "leaflet", 28 | "bookmarks", 29 | "plugin" 30 | ], 31 | "author": "Alexander Milevski", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/w8r/Leaflet.Bookmarks/issues" 35 | }, 36 | "homepage": "https://github.com/w8r/Leaflet.Bookmarks", 37 | "devDependencies": { 38 | "@rollup/plugin-buble": "^0.21.1", 39 | "@rollup/plugin-commonjs": "^11.0.2", 40 | "@rollup/plugin-node-resolve": "^7.1.1", 41 | "chai": "^4.2.0", 42 | "leaflet-contextmenu": "^1.4.0", 43 | "leaflet-modal": "^0.2.0", 44 | "less": "^2.1.1", 45 | "lessc": "^1.0.2", 46 | "mocha": "^10.2.0", 47 | "mocha-puppeteer": "^0.14.0", 48 | "reify": "^0.20.12", 49 | "rollup": "^2.0.2", 50 | "rollup-plugin-embed-css": "^1.0.16", 51 | "rollup-plugin-terser": "^5.2.0", 52 | "serve": "^11.3.0", 53 | "tape": "^4.0.0", 54 | "typescript": "^4.9.5" 55 | }, 56 | "dependencies": { 57 | "leaflet": "^1.9.3" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import buble from "@rollup/plugin-buble"; 4 | import { terser } from "rollup-plugin-terser"; 5 | 6 | import { version, author, license, description } from "./package.json"; 7 | 8 | const moduleName = "L.Control.Bookmarks"; 9 | 10 | const banner = `\ 11 | /** 12 | * ${moduleName} v${version} 13 | * ${description} 14 | * 15 | * @author ${author} 16 | * @license ${license} 17 | * @preserve 18 | */ 19 | `; 20 | 21 | export default [ 22 | { 23 | external: ["leaflet"], 24 | input: "./index.js", 25 | output: { 26 | file: `dist/index.js`, 27 | name: moduleName, 28 | sourcemap: true, 29 | format: "umd", 30 | banner, 31 | globals: { leaflet: "L" }, 32 | }, 33 | plugins: [resolve(), commonjs(), buble()], 34 | }, 35 | { 36 | external: ["leaflet"], 37 | input: "./index.js", 38 | output: { 39 | file: `dist/index.min.js`, 40 | name: moduleName, 41 | sourcemap: true, 42 | format: "umd", 43 | banner, 44 | globals: { leaflet: "L" }, 45 | }, 46 | plugins: [resolve(), commonjs(), buble(), terser()], 47 | }, 48 | { 49 | external: ["leaflet"], 50 | input: "./index.js", 51 | output: { 52 | file: `dist/index.mjs`, 53 | name: moduleName, 54 | sourcemap: true, 55 | format: "esm", 56 | banner, 57 | globals: { leaflet: "L" }, 58 | }, 59 | plugins: [resolve(), commonjs(), buble()], 60 | }, 61 | { 62 | input: "./docs/app.js", 63 | output: { 64 | file: `docs/bundle.js`, 65 | name: moduleName, 66 | sourcemap: true, 67 | format: "iife", 68 | banner, 69 | }, 70 | plugins: [resolve(), commonjs()], 71 | }, 72 | ]; 73 | -------------------------------------------------------------------------------- /src/bookmarks.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | import Storage, { EngineType } from "./storage"; 3 | import FormPopup from "./formpopup"; 4 | import { substitute } from "./string"; 5 | import "./leaflet.delegate"; 6 | 7 | // expose 8 | L.Util._template = L.Util._template || substitute; 9 | 10 | /** 11 | * Bookmarks control 12 | * @class L.Control.Bookmarks 13 | * @extends {L.Control} 14 | */ 15 | export default L.Control.extend( 16 | /** @lends Bookmarks.prototype */ { 17 | statics: { 18 | Storage, 19 | FormPopup, 20 | }, 21 | 22 | /** 23 | * @type {Object} 24 | */ 25 | options: { 26 | localStorage: true, 27 | 28 | /* you can provide access to your own storage, 29 | * xhr for example, but make sure it has all 30 | * required endpoints: 31 | * 32 | * .getItem(id, callback) 33 | * .setItem(id, callback) 34 | * .getAllItems(callback) 35 | * .removeItem(id, callback) 36 | */ 37 | storage: null, 38 | name: "leaflet-bookmarks", 39 | position: "topright", // chose your own if you want 40 | 41 | containerClass: "leaflet-bar leaflet-bookmarks-control", 42 | expandedClass: "expanded", 43 | headerClass: "bookmarks-header", 44 | listClass: "bookmarks-list", 45 | iconClass: "bookmarks-icon", 46 | iconWrapperClass: "bookmarks-icon-wrapper", 47 | listWrapperClass: "bookmarks-list-wrapper", 48 | listWrapperClassAdd: "list-with-button", 49 | wrapperClass: "bookmarks-container", 50 | addBookmarkButtonCss: "add-bookmark-button", 51 | 52 | animateClass: "bookmark-added-anim", 53 | animateDuration: 150, 54 | 55 | formPopup: { 56 | popupClass: "bookmarks-popup", 57 | }, 58 | 59 | bookmarkTemplate: 60 | '
  • ' + 61 | '×' + 62 | '{{ data.name }}' + 63 | '{{ data.coords }}' + 64 | "
  • ", 65 | 66 | emptyTemplate: 67 | '
  • ' + 68 | "{{ data.emptyMessage }}
  • ", 69 | 70 | dividerTemplate: '
  • ', 71 | 72 | bookmarkTemplateOptions: { 73 | itemClass: "bookmark-item", 74 | nameClass: "bookmark-name", 75 | coordsClass: "bookmark-coords", 76 | removeClass: "bookmark-remove", 77 | emptyClass: "bookmarks-empty", 78 | }, 79 | 80 | defaultBookmarkOptions: { 81 | editable: true, 82 | removable: true, 83 | }, 84 | 85 | title: "Bookmarks", 86 | emptyMessage: "No bookmarks yet", 87 | addBookmarkMessage: "Add new bookmark", 88 | collapseOnClick: true, 89 | scrollOnAdd: true, 90 | scrollDuration: 1000, 91 | popupOnShow: true, 92 | addNewOption: true, 93 | 94 | /** 95 | * This you can change easily to output 96 | * whatever you have stored in bookmark 97 | * 98 | * @type {String} 99 | */ 100 | popupTemplate: 101 | "

    {{ name }}

    {{ latlng }}, {{ zoom }}

    ", 102 | 103 | /** 104 | * Prepare your bookmark data for template. 105 | * If you don't change it, the context of this 106 | * function will be bookmarks control, so you can 107 | * access the map or other things from here 108 | * 109 | * @param {Object} bookmark 110 | * @return {Object} 111 | */ 112 | getPopupContent: function (bookmark) { 113 | return substitute(this.options.popupTemplate, { 114 | latlng: this.formatCoords(bookmark.latlng), 115 | name: bookmark.name, 116 | zoom: this._map.getZoom(), 117 | }); 118 | }, 119 | }, 120 | 121 | /** 122 | * @param {Object} options 123 | * @constructor 124 | * @constructs Bookmarks 125 | * @extends {L.Control} 126 | */ 127 | initialize: function (options) { 128 | options = options || {}; 129 | 130 | /** 131 | * Bookmarks array 132 | * @type {Array} 133 | */ 134 | this._data = []; 135 | 136 | /** 137 | * @type {Element} 138 | */ 139 | this._list = null; 140 | 141 | /** 142 | * @type {L.Marker} 143 | */ 144 | this._marker = null; 145 | 146 | /** 147 | * @type {HTMLElement} 148 | */ 149 | this._addButton = null; 150 | 151 | /** 152 | * @type {Element} 153 | */ 154 | this._icon = null; 155 | 156 | /** 157 | * @type {Boolean} 158 | */ 159 | this._isCollapsed = true; 160 | 161 | L.Util.setOptions(this, options); 162 | 163 | /** 164 | * @type {Storage} 165 | */ 166 | this._storage = 167 | options.storage || 168 | (this.options.localStorage 169 | ? new Storage(this.options.name, EngineType.LOCALSTORAGE) 170 | : new Storage(this.options.name, EngineType.GLOBALSTORAGE)); 171 | 172 | L.Control.prototype.initialize.call(this, this.options); 173 | }, 174 | 175 | /** 176 | * @param {L.Map} map 177 | */ 178 | onAdd: function (map) { 179 | const container = (this._container = L.DomUtil.create( 180 | "div", 181 | this.options.containerClass 182 | )); 183 | 184 | L.DomEvent.disableClickPropagation(container).disableScrollPropagation( 185 | container 186 | ); 187 | container.innerHTML = 188 | '
    ' + 193 | ''; 196 | 197 | this._icon = container.querySelector("." + this.options.iconClass); 198 | this._icon.title = this.options.title; 199 | 200 | this._createList(this.options.bookmarks); 201 | 202 | const wrapper = L.DomUtil.create( 203 | "div", 204 | this.options.wrapperClass, 205 | this._container 206 | ); 207 | wrapper.appendChild(this._listwrapper); 208 | 209 | this._initLayout(); 210 | 211 | L.DomEvent.on(container, "click", this._onClick, this).on( 212 | container, 213 | "contextmenu", 214 | L.DomEvent.stopPropagation 215 | ); 216 | 217 | map 218 | .on("bookmark:new", this._onBookmarkAddStart, this) 219 | .on("bookmark:add", this._onBookmarkAdd, this) 220 | .on("bookmark:edited", this._onBookmarkEdited, this) 221 | .on("bookmark:show", this._onBookmarkShow, this) 222 | .on("bookmark:edit", this._onBookmarkEdit, this) 223 | .on("bookmark:options", this._onBookmarkOptions, this) 224 | .on("bookmark:remove", this._onBookmarkRemove, this) 225 | .on("resize", this._initLayout, this); 226 | 227 | return container; 228 | }, 229 | 230 | /** 231 | * @param {L.Map} map 232 | */ 233 | onRemove: function (map) { 234 | map 235 | .off("bookmark:new", this._onBookmarkAddStart, this) 236 | .off("bookmark:add", this._onBookmarkAdd, this) 237 | .off("bookmark:edited", this._onBookmarkEdited, this) 238 | .off("bookmark:show", this._onBookmarkShow, this) 239 | .off("bookmark:edit", this._onBookmarkEdit, this) 240 | .off("bookmark:options", this._onBookmarkOptions, this) 241 | .off("bookmark:remove", this._onBookmarkRemove, this) 242 | .off("resize", this._initLayout, this); 243 | 244 | if (this._marker) this._marker._popup_.close(); 245 | 246 | if (this.options.addNewOption) { 247 | L.DomEvent.off( 248 | this._container.querySelector( 249 | "." + this.options.addBookmarkButtonCss 250 | ), 251 | "click", 252 | this._onAddButtonPressed, 253 | this 254 | ); 255 | } 256 | 257 | this._marker = null; 258 | this._popup = null; 259 | this._container = null; 260 | }, 261 | 262 | /** 263 | * @return {Array.} 264 | */ 265 | getData: function () { 266 | return this._filterBookmarksOutput(this._data); 267 | }, 268 | 269 | /** 270 | * @param {Array.|Function|null} bookmarks 271 | */ 272 | _createList: function (bookmarks) { 273 | this._listwrapper = L.DomUtil.create( 274 | "div", 275 | this.options.listWrapperClass, 276 | this._container 277 | ); 278 | this._list = L.DomUtil.create( 279 | "ul", 280 | this.options.listClass, 281 | this._listwrapper 282 | ); 283 | 284 | // select bookmark 285 | L.DomEvent.delegate( 286 | this._list, 287 | "." + this.options.bookmarkTemplateOptions.itemClass, 288 | "click", 289 | this._onBookmarkClick, 290 | this 291 | ); 292 | 293 | this._setEmptyListContent(); 294 | 295 | if (L.Util.isArray(bookmarks)) { 296 | this._appendItems(bookmarks); 297 | } else if (typeof bookmarks === "function") { 298 | this._appendItems(bookmarks()); 299 | } else { 300 | this._storage.getAllItems((bookmarks) => this._appendItems(bookmarks)); 301 | } 302 | }, 303 | 304 | /** 305 | * Empty list 306 | */ 307 | _setEmptyListContent: function () { 308 | this._list.innerHTML = substitute( 309 | this.options.emptyTemplate, 310 | L.Util.extend(this.options.bookmarkTemplateOptions, { 311 | data: { 312 | emptyMessage: this.options.emptyMessage, 313 | }, 314 | }) 315 | ); 316 | }, 317 | 318 | /** 319 | * Sees that the list size is not too big 320 | */ 321 | _initLayout: function () { 322 | const size = this._map.getSize(); 323 | this._listwrapper.style.maxHeight = 324 | Math.min(size.y * 0.6, size.y - 100) + "px"; 325 | 326 | if (this.options.position === "topleft") { 327 | L.DomUtil.addClass(this._container, "leaflet-bookmarks-to-right"); 328 | } 329 | if (this.options.addNewOption) { 330 | const addButton = L.DomUtil.create( 331 | "div", 332 | this.options.addBookmarkButtonCss 333 | ); 334 | if (this._addButton === null) { 335 | this._listwrapper.parentNode.appendChild(addButton); 336 | this._addButton = addButton; 337 | this._listwrapper.parentNode.classList.add( 338 | this.options.listWrapperClassAdd 339 | ); 340 | addButton.innerHTML = 341 | '+' + 342 | '' + 343 | this.options.addBookmarkMessage + 344 | ""; 345 | L.DomEvent.on(addButton, "click", this._onAddButtonPressed, this); 346 | } 347 | } 348 | }, 349 | 350 | /** 351 | * @param {MouseEvent} evt 352 | */ 353 | _onAddButtonPressed: function (evt) { 354 | L.DomEvent.stop(evt); 355 | this.collapse(); 356 | this._map.fire("bookmark:new", { 357 | latlng: this._map.getCenter(), 358 | }); 359 | }, 360 | 361 | /** 362 | * I don't care if they're unique or not, 363 | * if you do - handle this 364 | * 365 | * @param {Array.} bookmarks 366 | * @return {Array.} 367 | */ 368 | _filterBookmarks: function (bookmarks) { 369 | if (this.options.filterBookmarks) { 370 | return this.options.filterBookmarks.call(this, bookmarks); 371 | } 372 | return bookmarks; 373 | }, 374 | 375 | /** 376 | * Filter bookmarks for output. This one allows you to save dividers as well 377 | * 378 | * @param {Array.} bookmarks 379 | * @return {Array.} 380 | */ 381 | _filterBookmarksOutput: function (bookmarks) { 382 | if (this.options.filterBookmarksOutput) { 383 | return this.options.filterBookmarksOutput.call(this, bookmarks); 384 | } 385 | return bookmarks; 386 | }, 387 | 388 | /** 389 | * Append list items(render) 390 | * @param {Array.} bookmarks 391 | */ 392 | _appendItems: function (bookmarks) { 393 | let html = ""; 394 | let wasEmpty = this._data.length === 0; 395 | let bookmark; 396 | 397 | // maybe you have something in mind? 398 | bookmarks = this._filterBookmarks(bookmarks); 399 | 400 | // store 401 | this._data = this._data.concat(bookmarks); 402 | 403 | for (let i = 0, len = bookmarks.length; i < len; i++) { 404 | html += this._renderBookmarkItem(bookmarks[i]); 405 | } 406 | 407 | if (html !== "") { 408 | // replace `empty` message if needed 409 | if (wasEmpty) { 410 | this._list.innerHTML = html; 411 | } else { 412 | this._list.innerHTML += html; 413 | } 414 | } 415 | 416 | if (this._isCollapsed) { 417 | const container = this._container; 418 | const className = this.options.animateClass; 419 | container.classList.add(className); 420 | window.setTimeout(function () { 421 | container.classList.remove(className); 422 | }, this.options.animateDuration); 423 | } else { 424 | this._scrollToLast(); 425 | } 426 | }, 427 | 428 | /** 429 | * Scrolls to last element of the list 430 | */ 431 | _scrollToLast: function () { 432 | const listwrapper = this._listwrapper; 433 | let pos = this._listwrapper.scrollTop; 434 | const targetVal = this._list.lastChild.offsetTop; 435 | let start = 0; 436 | 437 | const step = 438 | (targetVal - pos) / (this.options.scrollDuration / (1000 / 16)); 439 | 440 | function scroll(timestamp) { 441 | if (!start) start = timestamp; 442 | //var progress = timestamp - start; 443 | 444 | pos = Math.min(pos + step, targetVal); 445 | listwrapper.scrollTop = pos; 446 | if (pos !== targetVal) { 447 | L.Util.requestAnimFrame(scroll); 448 | } 449 | } 450 | L.Util.requestAnimFrame(scroll); 451 | }, 452 | 453 | /** 454 | * Render single bookmark item 455 | * @param {Object} bookmark 456 | * @return {String} 457 | */ 458 | _renderBookmarkItem: function (bookmark) { 459 | if (bookmark.divider) { 460 | return substitute(this.options.dividerTemplate, bookmark); 461 | } 462 | 463 | this.options.bookmarkTemplateOptions.data = 464 | this._getBookmarkDataForTemplate(bookmark); 465 | 466 | return substitute( 467 | this.options.bookmarkTemplate, 468 | this.options.bookmarkTemplateOptions 469 | ); 470 | }, 471 | 472 | /** 473 | * Extracts data and style expressions for item template 474 | * @param {Object} bookmark 475 | * @return {Object} 476 | */ 477 | _getBookmarkDataForTemplate: function (bookmark) { 478 | if (this.options.getBookmarkDataForTemplate) { 479 | return this.options.getBookmarkDataForTemplate.call(this, bookmark); 480 | } 481 | return { 482 | coords: this.formatCoords(bookmark.latlng), 483 | name: this.formatName(bookmark.name), 484 | zoom: bookmark.zoom, 485 | id: bookmark.id, 486 | }; 487 | }, 488 | 489 | /** 490 | * @param {L.LatLng} latlng 491 | * @return {String} 492 | */ 493 | formatCoords: function (latlng) { 494 | if (this.options.formatCoords) { 495 | return this.options.formatCoords.call(this, latlng); 496 | } 497 | return latlng[0].toFixed(4) + ", " + latlng[1].toFixed(4); 498 | }, 499 | 500 | /** 501 | * @param {String} name 502 | * @return {String} 503 | */ 504 | formatName: function (name) { 505 | if (this.options.formatName) { 506 | return this.options.formatName.call(this, name); 507 | } 508 | return name; 509 | }, 510 | 511 | /** 512 | * Shows bookmarks list 513 | */ 514 | expand: function () { 515 | L.DomUtil.addClass(this._container, this.options.expandedClass); 516 | this._isCollapsed = false; 517 | }, 518 | 519 | /** 520 | * Hides bookmarks list and the form 521 | */ 522 | collapse: function () { 523 | L.DomUtil.removeClass(this._container, this.options.expandedClass); 524 | this._isCollapsed = true; 525 | }, 526 | 527 | /** 528 | * @param {Event} evt 529 | */ 530 | _onClick: function (evt) { 531 | const expanded = L.DomUtil.hasClass( 532 | this._container, 533 | this.options.expandedClass 534 | ); 535 | let target = evt.target || evt.srcElement; 536 | 537 | if (expanded) { 538 | if (target === this._container) { 539 | return this.collapse(); 540 | } 541 | // check if it's inside the header 542 | while (target !== this._container) { 543 | if ( 544 | L.DomUtil.hasClass(target, this.options.headerClass) || 545 | L.DomUtil.hasClass(target, this.options.listWrapperClass) 546 | ) { 547 | this.collapse(); 548 | break; 549 | } 550 | target = target.parentNode; 551 | } 552 | } else this.expand(); 553 | }, 554 | 555 | /** 556 | * @param {Object} evt 557 | */ 558 | _onBookmarkAddStart: function (evt) { 559 | if (this._marker) this._popup.close(); 560 | 561 | this._marker = new L.Marker(evt.latlng, { 562 | icon: this.options.icon || new L.Icon.Default(), 563 | draggable: true, 564 | riseOnHover: true, 565 | }).addTo(this._map); 566 | this._marker.on("popupclose", this._onPopupClosed, this); 567 | 568 | // open form 569 | this._popup = new L.Control.Bookmarks.FormPopup( 570 | L.Util.extend(this.options.formPopup, { 571 | mode: L.Control.Bookmarks.FormPopup.modes.CREATE, 572 | }), 573 | this._marker, 574 | this, 575 | L.Util.extend({}, evt.data, this.options.defaultBookmarkOptions) 576 | ).addTo(this._map); 577 | }, 578 | 579 | /** 580 | * Bookmark added 581 | * @param {Object} bookmark 582 | */ 583 | _onBookmarkAdd: function (bookmark) { 584 | const map = this._map; 585 | bookmark = this._cleanBookmark(bookmark.data); 586 | this._storage.setItem(bookmark.id, bookmark, (item) => { 587 | map.fire("bookmark:saved", { 588 | data: item, 589 | }); 590 | this._appendItems([item]); 591 | }); 592 | this._showBookmark(bookmark); 593 | }, 594 | 595 | /** 596 | * Update done 597 | * @param {Event} evt 598 | */ 599 | _onBookmarkEdited: function (evt) { 600 | const map = this._map; 601 | const bookmark = this._cleanBookmark(evt.data); 602 | this._storage.setItem(bookmark.id, bookmark, (item) => { 603 | map.fire("bookmark:saved", { data: item }); 604 | const data = this._data; 605 | this._data = []; 606 | for (var i = 0, len = data.length; i < len; i++) { 607 | if (data[i].id === bookmark.id) { 608 | data.splice(i, 1, bookmark); 609 | } 610 | } 611 | this._appendItems(data); 612 | }); 613 | this._showBookmark(bookmark); 614 | }, 615 | 616 | /** 617 | * Cleans circular reference for JSON 618 | * @param {Object} bookmark 619 | * @return {Object} 620 | */ 621 | _cleanBookmark: function (bookmark) { 622 | if (!L.Util.isArray(bookmark.latlng)) { 623 | bookmark.latlng = [bookmark.latlng.lat, bookmark.latlng.lng]; 624 | } 625 | return bookmark; 626 | }, 627 | 628 | /** 629 | * Form closed 630 | * @param {Object} evt 631 | */ 632 | _onPopupClosed: function (evt) { 633 | this._map.removeLayer(this._marker); 634 | this._marker = null; 635 | this._popup = null; 636 | }, 637 | 638 | /** 639 | * @param {String} id 640 | * @return {Object|Null} 641 | */ 642 | _getBookmark: function (id) { 643 | for (let i = 0, len = this._data.length; i < len; i++) { 644 | if (this._data[i].id === id) return this._data[i]; 645 | } 646 | return null; 647 | }, 648 | 649 | /** 650 | * @param {Object} evt 651 | */ 652 | _onBookmarkShow: function (evt) { 653 | this._gotoBookmark(evt.data); 654 | }, 655 | 656 | /** 657 | * Event handler for edit 658 | * @param {Object} evt 659 | */ 660 | _onBookmarkEdit: function (evt) { 661 | this._editBookmark(evt.data); 662 | }, 663 | 664 | /** 665 | * Remove bookmark triggered 666 | * @param {Event} evt 667 | */ 668 | _onBookmarkRemove: function (evt) { 669 | this._removeBookmark(evt.data); 670 | }, 671 | 672 | /** 673 | * Bookmark options called 674 | * @param {Event} evt 675 | */ 676 | _onBookmarkOptions: function (evt) { 677 | this._bookmarkOptions(evt.data); 678 | }, 679 | 680 | /** 681 | * Show menu popup 682 | * @param {Object} bookmark 683 | */ 684 | _bookmarkOptions: function (bookmark) { 685 | const coords = L.latLng(bookmark.latlng); 686 | const marker = (this._marker = this._createMarker(coords, bookmark)); 687 | // open form 688 | this._popup = new L.Control.Bookmarks.FormPopup( 689 | L.Util.extend(this.options.formPopup, { 690 | mode: L.Control.Bookmarks.FormPopup.modes.OPTIONS, 691 | }), 692 | marker, 693 | this, 694 | bookmark 695 | ).addTo(this._map); 696 | }, 697 | 698 | /** 699 | * Call edit popup 700 | * @param {Object} bookmark 701 | */ 702 | _editBookmark: function (bookmark) { 703 | const coords = L.latLng(bookmark.latlng); 704 | const marker = (this._marker = this._createMarker(coords, bookmark)); 705 | marker.dragging.enable(); 706 | // open form 707 | this._popup = new L.Control.Bookmarks.FormPopup( 708 | L.Util.extend(this.options.formPopup, { 709 | mode: L.Control.Bookmarks.FormPopup.modes.UPDATE, 710 | }), 711 | marker, 712 | this, 713 | bookmark 714 | ).addTo(this._map); 715 | }, 716 | 717 | /** 718 | * Returns a handler that will remove the bookmark from map 719 | * in case it got removed from the list 720 | * @param {Object} bookmark 721 | * @param {L.Marker} marker 722 | * @return {Function} 723 | */ 724 | _getOnRemoveHandler: function (bookmark, marker) { 725 | return function (evt) { 726 | if (evt.data.id === bookmark.id) { 727 | marker.clearAllEventListeners(); 728 | if (marker._popup_) marker._popup_.close(); 729 | this.removeLayer(marker); 730 | } 731 | }; 732 | }, 733 | 734 | /** 735 | * Creates bookmark marker 736 | * @param {L.LatLng} coords 737 | * @param {Object} bookmark 738 | * @return {L.Marker} 739 | */ 740 | _createMarker: function (coords, bookmark) { 741 | const marker = new L.Marker(coords, { 742 | icon: this.options.icon || new L.Icon.Default(), 743 | riseOnHover: true, 744 | }).addTo(this._map); 745 | const removeIfRemoved = this._getOnRemoveHandler(bookmark, marker); 746 | this._map.on("bookmark:removed", removeIfRemoved, this._map); 747 | marker 748 | .on("popupclose", () => this._map.removeLayer(this)) 749 | .on("remove", () => this._map.off("bookmark:removed", removeIfRemoved)); 750 | return marker; 751 | }, 752 | 753 | /** 754 | * Shows bookmark, nothing else 755 | * @param {Object} bookmark 756 | */ 757 | _showBookmark: function (bookmark) { 758 | if (this._marker) this._marker._popup_.close(); 759 | const coords = L.latLng(bookmark.latlng); 760 | const marker = this._createMarker(coords, bookmark); 761 | const popup = new L.Control.Bookmarks.FormPopup( 762 | L.Util.extend(this.options.formPopup, { 763 | mode: L.Control.Bookmarks.FormPopup.modes.SHOW, 764 | }), 765 | marker, 766 | this, 767 | bookmark 768 | ); 769 | if (this.options.popupOnShow) popup.addTo(this._map); 770 | this._popup = popup; 771 | this._marker = marker; 772 | }, 773 | 774 | /** 775 | * @param {Object} bookmark 776 | */ 777 | _gotoBookmark: function (bookmark) { 778 | this._map.setView(bookmark.latlng, bookmark.zoom); 779 | this._showBookmark(bookmark); 780 | }, 781 | 782 | /** 783 | * @param {Object} bookmark 784 | */ 785 | _removeBookmark: function (bookmark) { 786 | const remove = (proceed) => { 787 | if (!proceed) return this._showBookmark(bookmark); 788 | 789 | this._data.splice(this._data.indexOf(bookmark), 1); 790 | this._storage.removeItem(bookmark.id, (bookmark) => { 791 | this._onBookmarkRemoved(bookmark); 792 | }); 793 | }; 794 | 795 | if (typeof this.options.onRemove === "function") { 796 | this.options.onRemove(bookmark, remove); 797 | } else { 798 | remove(true); 799 | } 800 | }, 801 | 802 | /** 803 | * @param {Object} bookmark 804 | */ 805 | _onBookmarkRemoved: function (bookmark) { 806 | const li = this._list.querySelector( 807 | "." + 808 | this.options.bookmarkTemplateOptions.itemClass + 809 | "[data-id='" + 810 | bookmark.id + 811 | "']" 812 | ); 813 | 814 | this._map.fire("bookmark:removed", { data: bookmark }); 815 | 816 | if (li) { 817 | L.DomUtil.setOpacity(li, 0); 818 | setTimeout(() => { 819 | if (li.parentNode) li.parentNode.removeChild(li); 820 | if (this._data.length === 0) this._setEmptyListContent(); 821 | }, 250); 822 | } 823 | }, 824 | 825 | /** 826 | * Gets popup content 827 | * @param {Object} bookmark 828 | * @return {String} 829 | */ 830 | _getPopupContent: function (bookmark) { 831 | if (this.options.getPopupContent) { 832 | return this.options.getPopupContent.call(this, bookmark); 833 | } 834 | return JSON.stringify(bookmark); 835 | }, 836 | 837 | /** 838 | * @param {Event} e 839 | */ 840 | _onBookmarkClick: function (evt) { 841 | const bookmark = this._getBookmarkFromListItem(evt.delegateTarget); 842 | if (!bookmark) return; 843 | L.DomEvent.stopPropagation(evt); 844 | 845 | // remove button hit 846 | if ( 847 | L.DomUtil.hasClass( 848 | evt.target || evt.srcElement, 849 | this.options.bookmarkTemplateOptions.removeClass 850 | ) 851 | ) { 852 | this._removeBookmark(bookmark); 853 | } else { 854 | this._map.fire("bookmark:show", { data: bookmark }); 855 | if (this.options.collapseOnClick) this.collapse(); 856 | } 857 | }, 858 | 859 | /** 860 | * In case you've decided to play with ids - we've got you covered 861 | * @param {Element} li 862 | * @return {Object|Null} 863 | */ 864 | _getBookmarkFromListItem: function (li) { 865 | if (this.options.getBookmarkFromListItem) { 866 | return this.options.getBookmarkFromListItem.call(this, li); 867 | } 868 | return this._getBookmark(li.getAttribute("data-id")); 869 | }, 870 | 871 | /** 872 | * GeoJSON feature out of a bookmark 873 | * @param {Object} bookmark 874 | * @return {Object} 875 | */ 876 | bookmarkToFeature: function (bookmark) { 877 | const coords = this._getBookmarkCoords(bookmark); 878 | bookmark = JSON.parse(JSON.stringify(bookmark)); // quick copy 879 | delete bookmark.latlng; 880 | 881 | return L.GeoJSON.getFeature( 882 | { 883 | feature: { 884 | type: "Feature", 885 | id: bookmark.id, 886 | properties: bookmark, 887 | }, 888 | }, 889 | { 890 | type: "Point", 891 | coordinates: coords, 892 | } 893 | ); 894 | }, 895 | 896 | /** 897 | * @param {Object} bookmark 898 | * @return {Array.} 899 | */ 900 | _getBookmarkCoords: function (bookmark) { 901 | if (bookmark.latlng instanceof L.LatLng) { 902 | return [bookmark.latlng.lat, bookmark.latlng.lng]; 903 | } 904 | return bookmark.latlng.reverse(); 905 | }, 906 | 907 | /** 908 | * Read bookmarks from GeoJSON FeatureCollectio 909 | * @param {Object} geojson 910 | * @return {Object} 911 | */ 912 | fromGeoJSON: function (geojson) { 913 | const bookmarks = []; 914 | for (let i = 0, len = geojson.features.length; i < len; i++) { 915 | const bookmark = geojson.features[i]; 916 | if (!bookmark.properties.divider) { 917 | bookmark.properties.latlng = bookmark.geometry.coordinates 918 | .concat() 919 | .reverse(); 920 | } 921 | bookmarks.push(bookmark.properties); 922 | } 923 | return bookmarks; 924 | }, 925 | 926 | /** 927 | * @return {Object} 928 | */ 929 | toGeoJSON: function () { 930 | return { 931 | type: "FeatureCollection", 932 | features: ((data) => { 933 | const result = []; 934 | for (let i = 0, len = data.length; i < len; i++) { 935 | if (!data[i].divider) { 936 | result.push(this.bookmarkToFeature(data[i])); 937 | } 938 | } 939 | return result; 940 | })(this._data), 941 | }; 942 | }, 943 | } 944 | ); 945 | -------------------------------------------------------------------------------- /src/formpopup.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | import { unique, substitute } from "./string"; 3 | 4 | const modes = { 5 | CREATE: 1, 6 | UPDATE: 2, 7 | SHOW: 3, 8 | OPTIONS: 4, 9 | }; 10 | 11 | /** 12 | * New bookmark form popup 13 | * 14 | * @class FormPopup 15 | * @extends {L.Popup} 16 | */ 17 | export default L.Popup.extend( 18 | /** @lends FormPopup.prototype */ { 19 | statics: { modes }, 20 | 21 | /** 22 | * @type {Object} 23 | */ 24 | options: { 25 | mode: modes.CREATE, 26 | className: "leaflet-bookmarks-form-popup", 27 | templateOptions: { 28 | formClass: "leaflet-bookmarks-form", 29 | inputClass: "leaflet-bookmarks-form-input", 30 | inputErrorClass: "has-error", 31 | idInputClass: "leaflet-bookmarks-form-id", 32 | coordsClass: "leaflet-bookmarks-form-coords", 33 | submitClass: "leaflet-bookmarks-form-submit", 34 | inputPlaceholder: "Bookmark name", 35 | removeClass: "leaflet-bookmarks-form-remove", 36 | editClass: "leaflet-bookmarks-form-edit", 37 | cancelClass: "leaflet-bookmarks-form-cancel", 38 | editableClass: "editable", 39 | removableClass: "removable", 40 | menuItemClass: "nav-item", 41 | editMenuText: "Edit", 42 | removeMenuText: "Remove", 43 | cancelMenuText: "Cancel", 44 | submitTextCreate: "+", 45 | submitTextEdit: '', 46 | }, 47 | generateNames: false, 48 | minWidth: 160, 49 | generateNamesPrefix: "Bookmark ", 50 | template: 51 | '
    ' + 52 | '
    ' + 54 | '' + 55 | '
    " + 57 | '
    {{ coords }}
    ' + 58 | "
    ", 59 | menuTemplate: 60 | '", 65 | }, 66 | 67 | /** 68 | * @param {Object} options 69 | * @param {L.Layer} source 70 | * @param {Object=} bookmark 71 | * 72 | * @constructor 73 | */ 74 | initialize: function (options, source, control, bookmark) { 75 | /** 76 | * @type {Object} 77 | */ 78 | this._bookmark = bookmark; 79 | 80 | /** 81 | * @type {L.Control.Bookmarks} 82 | */ 83 | this._control = control; 84 | 85 | /** 86 | * @type {L.LatLng} 87 | */ 88 | this._latlng = source.getLatLng(); 89 | 90 | /** 91 | * For dragging purposes we're not maintaining the usual 92 | * link between the marker and Popup, otherwise it will simply be destroyed 93 | */ 94 | source._popup_ = this; 95 | 96 | L.Popup.prototype.initialize.call(this, options, source); 97 | }, 98 | 99 | /** 100 | * Add menu button 101 | */ 102 | _initLayout: function () { 103 | L.Popup.prototype._initLayout.call(this); 104 | 105 | if ( 106 | this.options.mode === modes.SHOW && 107 | (this._bookmark.editable || this._bookmark.removable) 108 | ) { 109 | const menuButton = (this._menuButton = L.DomUtil.create( 110 | "a", 111 | "leaflet-popup-menu-button" 112 | )); 113 | this._container.insertBefore(menuButton, this._closeButton); 114 | menuButton.href = "#menu"; 115 | menuButton.innerHTML = ''; 116 | L.DomEvent.disableClickPropagation(menuButton); 117 | L.DomEvent.on(menuButton, "click", this._onMenuButtonClick, this); 118 | } 119 | }, 120 | 121 | /** 122 | * Show options menu 123 | */ 124 | _showMenu: function () { 125 | this._map.fire("bookmark:options", { data: this._bookmark }); 126 | }, 127 | 128 | /** 129 | * @param {MouseEvent} evt 130 | */ 131 | _onMenuButtonClick: function (evt) { 132 | L.DomEvent.preventDefault(evt); 133 | this._showMenu(); 134 | this.close(); 135 | }, 136 | 137 | /** 138 | * Renders template only 139 | * @override 140 | */ 141 | _updateContent: function () { 142 | let content; 143 | if (this.options.mode === modes.SHOW) { 144 | content = this._control._getPopupContent(this._bookmark); 145 | } else { 146 | let template = this.options.template; 147 | let submitText = this.options.templateOptions.submitTextCreate; 148 | if (this.options.mode === modes.OPTIONS) { 149 | template = this.options.menuTemplate; 150 | } 151 | if (this.options.mode === modes.UPDATE) { 152 | submitText = this.options.templateOptions.submitTextEdit; 153 | } 154 | const modeClass = []; 155 | if (this._bookmark.editable) { 156 | modeClass.push(this.options.templateOptions.editableClass); 157 | } 158 | if (this._bookmark.removable) { 159 | modeClass.push(this.options.templateOptions.removableClass); 160 | } 161 | content = substitute( 162 | template, 163 | L.Util.extend( 164 | {}, 165 | this._bookmark || {}, 166 | this.options.templateOptions, 167 | { 168 | submitText: submitText, 169 | coords: this.formatCoords( 170 | this._source.getLatLng(), 171 | this._map.getZoom() 172 | ), 173 | mode: modeClass.join(" "), 174 | } 175 | ) 176 | ); 177 | } 178 | this._content = content; 179 | L.Popup.prototype._updateContent.call(this); 180 | this._onRendered(); 181 | }, 182 | 183 | /** 184 | * Form rendered, set up create or edit 185 | */ 186 | _onRendered: function () { 187 | if ( 188 | this.options.mode === modes.CREATE || 189 | this.options.mode === modes.UPDATE 190 | ) { 191 | const form = this._contentNode.querySelector( 192 | "." + this.options.templateOptions.formClass 193 | ); 194 | const input = form.querySelector( 195 | "." + this.options.templateOptions.inputClass 196 | ); 197 | 198 | L.DomEvent.on(form, "submit", this._onSubmit, this); 199 | setTimeout(this._setFocus.bind(this), 250); 200 | } else if (this.options.mode === modes.OPTIONS) { 201 | L.DomEvent.delegate( 202 | this._container, 203 | "." + this.options.templateOptions.editClass, 204 | "click", 205 | this._onEditClick, 206 | this 207 | ); 208 | L.DomEvent.delegate( 209 | this._container, 210 | "." + this.options.templateOptions.removeClass, 211 | "click", 212 | this._onRemoveClick, 213 | this 214 | ); 215 | L.DomEvent.delegate( 216 | this._container, 217 | "." + this.options.templateOptions.cancelClass, 218 | "click", 219 | this._onCancelClick, 220 | this 221 | ); 222 | } 223 | }, 224 | 225 | /** 226 | * Set focus at the end of input 227 | */ 228 | _setFocus: function () { 229 | const input = this._contentNode.querySelector( 230 | "." + this.options.templateOptions.inputClass 231 | ); 232 | // Multiply by 2 to ensure the cursor always ends up at the end; 233 | // Opera sometimes sees a carriage return as 2 characters. 234 | const strLength = input.value.length * 2; 235 | input.focus(); 236 | input.setSelectionRange(strLength, strLength); 237 | }, 238 | 239 | /** 240 | * Edit button clicked 241 | * @param {Event} evt 242 | */ 243 | _onEditClick: function (evt) { 244 | L.DomEvent.preventDefault(evt); 245 | this._map.fire("bookmark:edit", { data: this._bookmark }); 246 | this.close(); 247 | }, 248 | 249 | /** 250 | * Remove button clicked 251 | * @param {Event} evt 252 | */ 253 | _onRemoveClick: function (evt) { 254 | L.DomEvent.preventDefault(evt); 255 | this._map.fire("bookmark:remove", { data: this._bookmark }); 256 | this.close(); 257 | }, 258 | 259 | /** 260 | * Back from options view 261 | * @param {Event} evt 262 | */ 263 | _onCancelClick: function (evt) { 264 | L.DomEvent.preventDefault(evt); 265 | this._map.fire("bookmark:show", { data: this._bookmark }); 266 | this.close(); 267 | }, 268 | 269 | /** 270 | * Creates bookmark object from form data 271 | * @return {Object} 272 | */ 273 | _getBookmarkData: function () { 274 | const options = this.options; 275 | if (options.getBookmarkData) { 276 | return options.getBookmarkData.call(this); 277 | } 278 | const input = this._contentNode.querySelector( 279 | "." + options.templateOptions.inputClass 280 | ); 281 | const idInput = this._contentNode.querySelector( 282 | "." + options.templateOptions.idInputClass 283 | ); 284 | return { 285 | latlng: this._source.getLatLng(), 286 | zoom: this._map.getZoom(), 287 | name: input.value, 288 | id: idInput.value || unique(), 289 | }; 290 | }, 291 | 292 | /** 293 | * Form submit, dispatch eventm close popup 294 | * @param {Event} evt 295 | */ 296 | _onSubmit: function (evt) { 297 | L.DomEvent.stop(evt); 298 | 299 | const input = this._contentNode.querySelector( 300 | "." + this.options.templateOptions.inputClass 301 | ); 302 | input.classList.remove(this.options.templateOptions.inputErrorClass); 303 | 304 | if (input.value === "" && this.options.generateNames) { 305 | input.value = unique(this.options.generateNamesPrefix); 306 | } 307 | 308 | const validate = this.options.validateInput || (() => true); 309 | 310 | if (input.value !== "" && validate.call(this, input.value)) { 311 | const bookmark = L.Util.extend( 312 | {}, 313 | this._bookmark, 314 | this._getBookmarkData() 315 | ); 316 | const map = this._map; 317 | 318 | this.close(); 319 | if (this.options.mode === modes.CREATE) { 320 | map.fire("bookmark:add", { data: bookmark }); 321 | } else { 322 | map.fire("bookmark:edited", { data: bookmark }); 323 | } 324 | } else { 325 | input.classList.add(this.options.templateOptions.inputErrorClass); 326 | } 327 | }, 328 | 329 | /** 330 | * @param {L.LatLng} coords 331 | * @param {Number=} zoom 332 | * @return {String} 333 | */ 334 | formatCoords: function (coords, zoom) { 335 | if (this.options.formatCoords) { 336 | return this.options.formatCoords.call(this, coords, zoom); 337 | } 338 | return [coords.lat.toFixed(4), coords.lng.toFixed(4), zoom].join( 339 | ", " 340 | ); 341 | }, 342 | 343 | /** 344 | * Hook to source movements 345 | * @param {L.Map} map 346 | * @return {Element} 347 | */ 348 | onAdd: function (map) { 349 | this._source.on("dragend", this._onSourceMoved, this); 350 | this._source.on("dragstart", this._onSourceMoveStart, this); 351 | return L.Popup.prototype.onAdd.call(this, map); 352 | }, 353 | 354 | /** 355 | * @param {L.Map} map 356 | */ 357 | onRemove: function (map) { 358 | this._source.off("dragend", this._onSourceMoved, this); 359 | L.Popup.prototype.onRemove.call(this, map); 360 | }, 361 | 362 | /** 363 | * Marker drag 364 | */ 365 | _onSourceMoveStart: function () { 366 | // store 367 | this._bookmark = L.Util.extend( 368 | this._bookmark || {}, 369 | this._getBookmarkData() 370 | ); 371 | this._container.style.display = "none"; 372 | }, 373 | 374 | /** 375 | * Marker moved 376 | * @param {Event} e 377 | */ 378 | _onSourceMoved: function (e) { 379 | this._latlng = this._source.getLatLng(); 380 | this._container.style.display = ""; 381 | this._source.openPopup(); 382 | this.update(); 383 | }, 384 | } 385 | ); 386 | -------------------------------------------------------------------------------- /src/leaflet.bookmarks.less: -------------------------------------------------------------------------------- 1 | @bookmarkIconColor: #777777; 2 | @bookmarkIconHoverColor: #333333; 3 | @bookmarkIconOutColor: #555555; 4 | @buttonColor: #dfdfdf; 5 | @white: #ffffff; 6 | @bookmarkIconBg: @white; 7 | @bookmarkHeaderBg: @white; 8 | @bookmarkItemBorderTop: @white; 9 | @bookmarkItemHoverBg: #eeeeee; 10 | @bookmarkFormBorders: #cccccc; 11 | @bookmarkMenuBorders: #dddddd; 12 | @bookmarkEmptyColor: #777777; 13 | @linkColor: #0078a8; 14 | @linkHoverColor: darken(@linkColor, 20%); 15 | @disabledColor: #efefef; 16 | @errorColor: #a94442; 17 | 18 | .leaflet-right .leaflet-bookmarks-control { 19 | margin-top: 18px; 20 | margin-right: 18px; 21 | 22 | // bookmark added animation 23 | &.bookmark-added-anim { 24 | margin-top: 14px; 25 | margin-right: 14px; 26 | padding: 12px; 27 | } 28 | } 29 | 30 | .leaflet-bookmarks-control { 31 | background: @bookmarkIconBg; 32 | padding: 8px; 33 | cursor: pointer; 34 | transition: margin 0.15s ease-out, padding 0.15s ease-out; 35 | -webkit-transition: margin 0.15s ease-out, padding 0.15s ease-out; 36 | 37 | .bookmarks-icon-wrapper { 38 | padding: 0 3px 0 3px; 39 | position: relative; 40 | } 41 | 42 | .bookmarks-icon { 43 | width: 1em; 44 | height: 0.8em; 45 | background: @bookmarkIconColor; 46 | 47 | &, 48 | &:before, 49 | &:after { 50 | display: inline-block; 51 | cursor: pointer; 52 | position: relative; 53 | content: ""; 54 | margin: 0; 55 | } 56 | 57 | &:before, 58 | &:after { 59 | margin-top: 0.8em; 60 | position: relative; 61 | width: 0; 62 | height: 0; 63 | border-top: 0.5em solid @bookmarkIconColor; 64 | } 65 | 66 | &:before { 67 | border-right: 0.5em solid transparent; 68 | } 69 | 70 | &:after { 71 | border-left: 0.5em solid transparent; 72 | } 73 | } 74 | 75 | &:hover, 76 | &:active { 77 | .bookmarks-icon { 78 | background: @bookmarkIconHoverColor; 79 | 80 | &:before, 81 | &:after { 82 | border-top-color: @bookmarkIconHoverColor; 83 | } 84 | } 85 | } 86 | 87 | .bookmarks-header { 88 | height: 1.25em; 89 | } 90 | 91 | .bookmarks-list-wrapper { 92 | overflow-y: auto; 93 | margin-top: -1.25em; 94 | padding-top: 1.25em; 95 | } 96 | 97 | .bookmarks-list { 98 | display: none; 99 | list-style: none; 100 | margin: 0; 101 | padding: 0; 102 | 103 | .divider { 104 | border-bottom: 1px solid #909090; 105 | border-top: 1px solid #ddd; 106 | margin-top: -1px; 107 | } 108 | 109 | .bookmark-item { 110 | cursor: pointer; 111 | transition: opacity 0.25s linear; 112 | -webkit-transition: opacity 0.25s linear; 113 | padding: 5px; 114 | border-bottom: 1px solid @bookmarkItemHoverBg; 115 | 116 | &:hover { 117 | background: @bookmarkItemHoverBg; 118 | border-bottom: 1px solid @bookmarkItemHoverBg; 119 | 120 | .bookmark-name { 121 | text-decoration: underline; 122 | } 123 | 124 | .bookmark-remove { 125 | opacity: 0.6; 126 | filter: alpha(opacity=60); 127 | } 128 | } 129 | 130 | &:last-child { 131 | &, 132 | &:hover { 133 | border-bottom: none; 134 | } 135 | } 136 | 137 | &.bookmarks-empty { 138 | font-style: italic; 139 | color: @bookmarkEmptyColor; 140 | 141 | &, 142 | &:hover { 143 | background: none; 144 | border: none; 145 | } 146 | } 147 | } 148 | 149 | .bookmark-remove { 150 | display: inline-block; 151 | position: relative; 152 | float: right; 153 | margin-left: 6px; 154 | font-size: 1.5em; 155 | color: @bookmarkIconColor; 156 | opacity: 0; 157 | z-index: 30; 158 | filter: alpha(opacity=0); 159 | transition: opacity 0.15s linear; 160 | -webkit-transition: opacity 0.15s linear; 161 | 162 | &:hover { 163 | color: @linkColor; 164 | opacity: 1; 165 | filter: alpha(opacity=100); 166 | } 167 | } 168 | 169 | .bookmark-name, 170 | .bookmark-coords { 171 | display: block; 172 | z-index: 20; 173 | } 174 | 175 | .bookmark-name { 176 | font-weight: bold; 177 | } 178 | } 179 | 180 | // list expanded 181 | &.expanded { 182 | min-width: 180px; 183 | 184 | .bookmarks-icon-wrapper { 185 | background: @bookmarkHeaderBg; 186 | padding: 4px 3px 0.25em 7px; 187 | border-radius: 0 0 0 4px; 188 | position: relative; 189 | } 190 | 191 | .bookmarks-header { 192 | text-align: right; 193 | } 194 | 195 | .bookmarks-list-wrapper { 196 | padding-top: 1.75em; 197 | } 198 | 199 | .bookmarks-list { 200 | display: block; 201 | } 202 | 203 | .add-bookmark-button { 204 | display: inline-block; 205 | width: 100%; 206 | line-height: 2; 207 | cursor: pointer; 208 | padding-left: 5px; 209 | 210 | .content { 211 | margin-right: 15px; 212 | padding-left: 5px; 213 | } 214 | } 215 | } 216 | 217 | .add-bookmark-button { 218 | display: none; 219 | position: absolute; 220 | font-weight: bold; 221 | bottom: 5px; 222 | 223 | .plus { 224 | background: @linkHoverColor; 225 | display: inline-block; 226 | width: 11px; 227 | height: 15px; 228 | border-radius: 50%; 229 | color: @white; 230 | padding: 0 0 0 4px; 231 | line-height: 14px; 232 | } 233 | 234 | &:hover { 235 | .content { 236 | text-decoration: underline; 237 | } 238 | 239 | .plus { 240 | background: @linkColor; 241 | } 242 | } 243 | } 244 | } 245 | 246 | .leaflet-bookmarks-control { 247 | &.expanded { 248 | .list-with-button { 249 | padding-bottom: 30px; 250 | } 251 | } 252 | } 253 | 254 | .leaflet-bookmarks-to-right { 255 | .bookmarks-header { 256 | padding: 0; 257 | text-align: center; 258 | font-size: 10px; 259 | } 260 | 261 | .bookmarks-icon-wrapper { 262 | padding: 0; 263 | } 264 | 265 | .bookmarks-container { 266 | position: absolute; 267 | top: -100%; 268 | left: 100%; 269 | z-index: 100; 270 | display: none; 271 | float: left; 272 | min-width: 160px; 273 | padding: 5px 0 5px 0; 274 | margin: 2px 0 0 6px; 275 | text-align: left; 276 | background-color: @bookmarkHeaderBg; 277 | border: 1px solid @bookmarkItemHoverBg; 278 | border: 1px solid rgba(0, 0, 0, 0.15); 279 | border-radius: 4px; 280 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 281 | background-clip: padding-box; 282 | } 283 | 284 | &, 285 | &.expanded { 286 | width: 10px; 287 | height: 10px; 288 | } 289 | 290 | &.expanded { 291 | min-width: 0; 292 | background-color: @bookmarkItemHoverBg; 293 | 294 | .bookmarks-list-wrapper { 295 | margin-top: 0; 296 | padding-top: 0; 297 | } 298 | 299 | .bookmarks-icon-wrapper { 300 | padding: 0; 301 | background: transparent; 302 | } 303 | 304 | .bookmarks-container { 305 | display: block; 306 | } 307 | } 308 | } 309 | 310 | .leaflet-bookmarks-form-popup { 311 | .leaflet-popup-menu-button { 312 | position: absolute; 313 | top: 7px; 314 | right: 26px; 315 | background: transparent; 316 | border-bottom: 6px double @bookmarkFormBorders; 317 | border-top: 2px solid @bookmarkFormBorders; 318 | content: ""; 319 | height: 2px; 320 | width: 12px; 321 | 322 | &:hover { 323 | border-bottom-color: @linkColor; 324 | border-top-color: @linkColor; 325 | } 326 | } 327 | 328 | .nav { 329 | list-style: none; 330 | padding: 4px 0; 331 | 332 | .nav-item { 333 | display: block; 334 | white-space: nowrap; 335 | padding-right: 14px; 336 | padding-left: 14px; 337 | line-height: 2em; 338 | text-decoration: none; 339 | border-bottom: 1px solid @bookmarkMenuBorders; 340 | color: @linkColor; 341 | 342 | &:hover { 343 | background: @buttonColor; 344 | color: @linkHoverColor; 345 | box-shadow: 1px 1px 1px @white; 346 | } 347 | } 348 | 349 | li:first-child .nav-item { 350 | border-top-left-radius: 4px; 351 | border-top-right-radius: 4px; 352 | } 353 | 354 | li:last-child .nav-item { 355 | border-bottom-left-radius: 4px; 356 | border-bottom-right-radius: 4px; 357 | border-bottom: 0; 358 | } 359 | 360 | .leaflet-bookmarks-form-remove, 361 | .leaflet-bookmarks-form-edit { 362 | display: none; 363 | } 364 | 365 | &.removable .leaflet-bookmarks-form-remove { 366 | display: block; 367 | } 368 | 369 | &.editable .leaflet-bookmarks-form-edit { 370 | display: block; 371 | } 372 | } 373 | 374 | .icon-checkmark { 375 | display: inline-block; 376 | width: 16px; 377 | height: 16px; 378 | border-radius: 50%; 379 | margin-top: -3px; 380 | -webkit-transform: rotate(45deg); /* Chrome, Safari, Opera */ 381 | -ms-transform: rotate(45deg); /* IE 9 */ 382 | transform: rotate(45deg); 383 | 384 | &:before { 385 | content: ""; 386 | position: absolute; 387 | width: 3px; 388 | height: 9px; 389 | background-color: @bookmarkIconOutColor; 390 | left: 8px; 391 | top: 4px; 392 | } 393 | 394 | &:after { 395 | content: ""; 396 | position: absolute; 397 | width: 3px; 398 | height: 3px; 399 | background-color: @bookmarkIconOutColor; 400 | left: 5px; 401 | top: 10px; 402 | } 403 | } 404 | 405 | button:hover .icon-checkmark { 406 | &:before, 407 | &:after { 408 | background-color: @bookmarkIconHoverColor; 409 | } 410 | } 411 | } 412 | 413 | .leaflet-bookmarks-form { 414 | padding-top: 10px; 415 | 416 | .leaflet-bookmarks-form-input, 417 | .leaflet-bookmarks-form-submit { 418 | display: table-cell; 419 | } 420 | 421 | .leaflet-bookmarks-form-input { 422 | &, 423 | &:focus { 424 | outline-color: transparent; 425 | outline-style: none; 426 | } 427 | 428 | font-size: 13px; 429 | padding-left: 5px; 430 | padding-right: 5px; 431 | line-height: 19px; 432 | border: 1px solid @bookmarkFormBorders; 433 | border-radius: 3px 0 0 3px; 434 | } 435 | 436 | .has-error { 437 | border-color: @errorColor; 438 | } 439 | 440 | .leaflet-bookmarks-form-submit { 441 | border: 0; 442 | font-size: 16px; 443 | font-weight: bold; 444 | margin: 0 0 -2px -2px; 445 | position: relative; 446 | top: 1px; 447 | border-radius: 0 3px 3px 0; 448 | cursor: pointer; 449 | height: 1.45em; 450 | 451 | &.disabled { 452 | background-color: @disabledColor; 453 | 454 | .icon-checkmark { 455 | opacity: 0.5; 456 | } 457 | } 458 | } 459 | 460 | .leaflet-bookmarks-form-coords { 461 | margin-top: 8px; 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /src/leaflet.delegate.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | 3 | /** 4 | * Courtesy of https://github.com/component/matches-selector 5 | */ 6 | const matchesSelector = ((ElementPrototype) => { 7 | const matches = 8 | ElementPrototype.matches || 9 | ElementPrototype.webkitMatchesSelector || 10 | ElementPrototype.mozMatchesSelector || 11 | ElementPrototype.msMatchesSelector || 12 | ElementPrototype.oMatchesSelector || 13 | // hello IE 14 | function (selector) { 15 | var node = this, 16 | parent = node.parentNode || node.document, 17 | nodes = parent.querySelectorAll(selector); 18 | 19 | for (var i = 0, len = nodes.length; i < len; ++i) { 20 | if (nodes[i] == node) return true; 21 | } 22 | return false; 23 | }; 24 | 25 | /** 26 | * @param {Element} element 27 | * @param {String} selector 28 | * @return {Boolean} 29 | */ 30 | return function (element, selector) { 31 | return matches.call(element, selector); 32 | }; 33 | })(Element.prototype); 34 | 35 | /** 36 | * Courtesy of https://github.com/component/closest 37 | * 38 | * @param {Element} element 39 | * @param {String} selector 40 | * @param {Boolean} checkSelf 41 | * @param {Element} root 42 | * 43 | * @return {Element|Null} 44 | */ 45 | function closest(element, selector, checkSelf, root) { 46 | element = checkSelf 47 | ? { 48 | parentNode: element, 49 | } 50 | : element; 51 | 52 | root = root || document; 53 | 54 | // Make sure `element !== document` and `element != null` 55 | // otherwise we get an illegal invocation 56 | while ((element = element.parentNode) && element !== document) { 57 | if (matchesSelector(element, selector)) return element; 58 | // After `matches` on the edge case that 59 | // the selector matches the root 60 | // (when the root is not the document) 61 | if (element === root) return null; 62 | } 63 | } 64 | 65 | /** 66 | * Based on https://github.com/component/delegate 67 | * 68 | * @param {Element} el 69 | * @param {String} selector 70 | * @param {String} type 71 | * @param {Function} fn 72 | * 73 | * @return {Function} 74 | */ 75 | L.DomEvent.delegate = function (el, selector, type, fn, bind) { 76 | return L.DomEvent.on(el, type, (evt) => { 77 | const target = evt.target || evt.srcElement; 78 | evt.delegateTarget = closest(target, selector, true, el); 79 | if (evt.delegateTarget && !evt.propagationStopped) { 80 | fn.call(bind || el, evt); 81 | } 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /src/storage.js: -------------------------------------------------------------------------------- 1 | import { unique } from "./string"; 2 | 3 | import XHR from "./storage/xhr"; 4 | import GlobalStorage from "./storage/global"; 5 | import LocalStorage from "./storage/localstorage"; 6 | 7 | /** 8 | * @const 9 | * @enum {Number} 10 | */ 11 | export const EngineType = { 12 | // XHR: 1, // we don't have it included, it's a stub 13 | GLOBALSTORAGE: 2, 14 | LOCALSTORAGE: 3, 15 | }; 16 | 17 | /** 18 | * Persistent storage, depends on engine choice: localStorage/ajax 19 | * @param {String} name 20 | */ 21 | export default class Storage { 22 | constructor(name, engineType) { 23 | if (typeof name !== "string") { 24 | engineType = name; 25 | name = unique(); 26 | } 27 | 28 | /** 29 | * @type {String} 30 | */ 31 | this._name = name; 32 | 33 | /** 34 | * @type {Storage.Engine} 35 | */ 36 | this._engine = Storage.createEngine( 37 | engineType, 38 | this._name, 39 | Array.prototype.slice.call(arguments, 2) 40 | ); 41 | } 42 | 43 | /** 44 | * Engine factory 45 | * @param {Number} type 46 | * @param {String} prefix 47 | * @return {Storage.Engine} 48 | */ 49 | static createEngine(type, prefix, args) { 50 | if (type === EngineType.GLOBALSTORAGE) { 51 | return new GlobalStorage(prefix); 52 | } 53 | if (type === EngineType.LOCALSTORAGE) { 54 | return new LocalStorage(prefix); 55 | } 56 | } 57 | 58 | /** 59 | * @param {String} key 60 | * @param {*} item 61 | * @param {Function} callback 62 | */ 63 | setItem(key, item, callback) { 64 | this._engine.setItem(key, item, callback); 65 | return this; 66 | } 67 | 68 | /** 69 | * @param {String} key 70 | * @param {Function} callback 71 | */ 72 | getItem(key, callback) { 73 | this._engine.getItem(key, callback); 74 | return this; 75 | } 76 | 77 | /** 78 | * @param {Function} callback 79 | */ 80 | getAllItems(callback) { 81 | this._engine.getAllItems(callback); 82 | } 83 | 84 | /** 85 | * @param {String} key 86 | * @param {Function} callback 87 | */ 88 | removeItem(key, callback) { 89 | this._engine.removeItem(key, callback); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/storage/global.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {Object} 3 | */ 4 | const data = {}; 5 | 6 | /** 7 | * Object based storage 8 | * @class Storage.Engine.Global 9 | * @constructor 10 | */ 11 | export default class GlobalStorage { 12 | constructor(prefix) { 13 | /** 14 | * @type {String} 15 | */ 16 | this._prefix = prefix; 17 | } 18 | 19 | /** 20 | * @param {String} key 21 | * @param {Function} callback 22 | */ 23 | getItem(key, callback) { 24 | callback(data[this._prefix + key]); 25 | } 26 | 27 | /** 28 | * @param {String} key 29 | * @param {*} item 30 | * @param {Function} callback 31 | */ 32 | setItem(key, item, callback) { 33 | data[this._prefix + key] = item; 34 | callback(item); 35 | } 36 | 37 | /** 38 | * @param {Function} callback 39 | */ 40 | getAllItems(callback) { 41 | const items = []; 42 | for (const key in data) { 43 | if (data.hasOwnProperty(key) && key.indexOf(this_prefix) === 0) { 44 | items.push(data[key]); 45 | } 46 | } 47 | callback(items); 48 | } 49 | 50 | /** 51 | * @param {String} key 52 | * @param {Function} callback 53 | */ 54 | removeItem(key, callback) { 55 | this.getItem(key, (item) => { 56 | if (item) { 57 | delete data[this._prefix + key]; 58 | } else { 59 | item = null; 60 | } 61 | if (callback) callback(item); 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/storage/localstorage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @const 3 | * @type {RegExp} 4 | */ 5 | const JSON_RE = /^[\{\[](.)*[\]\}]$/; 6 | 7 | /** 8 | * LocalStoarge based storage 9 | */ 10 | export default class LocalStorage { 11 | constructor(prefix) { 12 | /** 13 | * @type {String} 14 | */ 15 | this._prefix = prefix; 16 | 17 | /** 18 | * @type {LocalStorage} 19 | */ 20 | this._storage = window.localStorage; 21 | } 22 | 23 | /** 24 | * @param {String} key 25 | * @param {Function} callback 26 | */ 27 | getItem(key, callback) { 28 | let item = this._storage.getItem(this._prefix + key); 29 | if (item && JSON_RE.test(item)) { 30 | item = JSON.parse(item); 31 | } 32 | callback(item); 33 | } 34 | 35 | /** 36 | * @param {Function} callback 37 | */ 38 | getAllItems(callback) { 39 | const items = []; 40 | const prefixLength = this._prefix.length; 41 | for (const key in this._storage) { 42 | if ( 43 | this._storage.getItem(key) !== null && 44 | key.indexOf(this._prefix) === 0 45 | ) { 46 | this.getItem(key.substring(prefixLength), (item) => items.push(item)); 47 | } 48 | } 49 | callback(items); 50 | } 51 | 52 | /** 53 | * @param {String} key 54 | * @param {Function} callback 55 | */ 56 | removeItem(key, callback) { 57 | const self = this; 58 | this.getItem(key, (item) => { 59 | this._storage.removeItem(self._prefix + key); 60 | if (callback) callback(item); 61 | }); 62 | } 63 | 64 | /** 65 | * @param {String} key 66 | * @param {*} item 67 | * @param {Function} callback 68 | */ 69 | setItem(key, item, callback) { 70 | let itemStr = item.toString(); 71 | if (itemStr === "[object Object]") { 72 | itemStr = JSON.stringify(item); 73 | } 74 | this._storage.setItem(this._prefix + key, itemStr); 75 | callback(item); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/storage/xhr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * XHR storage 3 | * @param {Object} getUrl 4 | * 5 | * @constructor 6 | */ 7 | export default class XHR { 8 | constructor(options) { 9 | /** 10 | * @type {*} 11 | */ 12 | this._transport = this.createTransport(options); 13 | 14 | /** 15 | * @type {Object} 16 | */ 17 | this.options = options; 18 | } 19 | 20 | /** 21 | * Create transport 22 | * @return {*} 23 | */ 24 | createTransport() {} 25 | 26 | /** 27 | * Create request url 28 | * @param {String} requestType 29 | * @param {String} type 30 | * @param {String} key 31 | * @return {String} 32 | */ 33 | getUrl(requestType, type, key) {} 34 | 35 | /** 36 | * @param {String} key 37 | * @param {Function} callback 38 | */ 39 | getItem(key, callback) { 40 | this._transport.get( 41 | this._getUrl, 42 | { 43 | key: key, 44 | }, 45 | callback 46 | ); 47 | } 48 | 49 | /** 50 | * @param {String} key 51 | * @param {*} item 52 | * @param {Function} callback 53 | */ 54 | setItem(key, item, callback) { 55 | this._transport.post( 56 | this._postUrl, 57 | { 58 | key: item, 59 | }, 60 | callback 61 | ); 62 | } 63 | 64 | /** 65 | * @param {String} key 66 | * @param {Function} callback 67 | */ 68 | removeItem(key, callback) { 69 | this._transport.delete( 70 | this._deleteUrl, 71 | { 72 | key: key, 73 | }, 74 | callback 75 | ); 76 | } 77 | 78 | /** 79 | * @param {String=} prefix 80 | * @param {Function} callback 81 | */ 82 | getAllItems(callback) { 83 | this._transport.get(this._getUrl, null, callback); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Substitutes {{ obj.field }} in strings 3 | * 4 | * @param {String} str 5 | * @param {Object} object 6 | * @param {RegExp=} regexp 7 | * @return {String} 8 | */ 9 | export function substitute(str, object, regexp) { 10 | return str.replace(regexp || /{{([\s\S]+?)}}/g, function (match, name) { 11 | name = trim(name); 12 | 13 | if (name.indexOf(".") === -1) { 14 | if (match.charAt(0) == "\\") return match.slice(1); 15 | return object[name] != null ? object[name] : ""; 16 | } else { 17 | // nested 18 | let result = object; 19 | name = name.split("."); 20 | for (var i = 0, len = name.length; i < len; i++) { 21 | if (name[i] in result) result = result[name[i]]; 22 | else return ""; 23 | } 24 | return result; 25 | } 26 | }); 27 | } 28 | 29 | const alpha = "abcdefghijklmnopqrstuvwxyz"; 30 | /** 31 | * Unique string from date. Puts character at the beginning, 32 | * for the sake of good manners 33 | * 34 | * @return {String} 35 | */ 36 | export function unique(prefix) { 37 | return ( 38 | (prefix || alpha[Math.floor(Math.random() * alpha.length)]) + 39 | new Date().getTime().toString(16) 40 | ); 41 | } 42 | 43 | /** 44 | * Trim whitespace 45 | * @param {String} str 46 | * @return {String} 47 | */ 48 | export function trim(str) { 49 | return str.replace(/^\s+|\s+$/g, ""); 50 | } 51 | 52 | /** 53 | * Clean and trim 54 | * @param {String} str 55 | * @return {String} 56 | */ 57 | export function clean(str) { 58 | return trim(str.replace(/\s+/g, " ")); 59 | } 60 | -------------------------------------------------------------------------------- /test/bookmarks.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var L = require("leaflet"); 4 | var Bookmarks = require("../dist/index.js"); 5 | var { assert } = require("chai"); 6 | 7 | describe("L.Bookmarks.FormPopup", () => { 8 | const container = L.DomUtil.create("div", "map", document.body); 9 | L.Icon.Default.imagePath = "http://cdn.leafletjs.com/leaflet-0.7/images"; 10 | 11 | var map = new L.Map(container, {}).setView([22.267, 114.188], 13); 12 | L.tileLayer("http://{s}.tile.osm.org/{z}/{x}/{y}.png", { 13 | attribution: 14 | "© " + 15 | 'OpenStreetMap contributors', 16 | }).addTo(map); 17 | 18 | function getCoord() { 19 | var bounds = map.getBounds(); 20 | var sw = bounds._southWest; 21 | var ne = bounds._northEast; 22 | return new L.LatLng( 23 | sw.lat + (ne.lat - sw.lat) * Math.random(), 24 | sw.lng + (ne.lng - sw.lng) * Math.random() 25 | ); 26 | } 27 | 28 | function getBookmark() { 29 | var coord = getCoord(); 30 | var id = uid(); 31 | return { 32 | latlng: coord, 33 | id: id, 34 | name: "Bookmark " + id, 35 | custom_key: "custom value " + id, 36 | }; 37 | } 38 | 39 | function uid() { 40 | return Math.round(Math.random() * 100000).toString(36); 41 | } 42 | 43 | map.whenReady(function () { 44 | var bookmarksControl = new L.Control.Bookmarks({ 45 | position: "topright", 46 | }); 47 | map.addControl(bookmarksControl); 48 | 49 | it("constructor", () => { 50 | var coord = getCoord(); 51 | map.fire("bookmark:new", { latlng: coord }); 52 | 53 | assert.ok(bookmarksControl._marker, "marker is present"); 54 | assert.ok(bookmarksControl._popup, "popup is present"); 55 | assert.ok( 56 | coord.equals(bookmarksControl._marker.getLatLng()), 57 | "marker on right coordinate" 58 | ); 59 | assert.ok( 60 | coord.equals(bookmarksControl._popup.getLatLng()), 61 | "popup on right coordinate" 62 | ); 63 | bookmarksControl._popup.close(); 64 | }); 65 | 66 | it("add bookmark", () => { 67 | var bookmark = getBookmark(); 68 | var coord = bookmark.latlng; 69 | var id = bookmark.id; 70 | map.fire("bookmark:add", { data: bookmark }); 71 | 72 | assert.ok(bookmarksControl._popup, "showed popup"); 73 | assert.equal( 74 | bookmarksControl._popup._bookmark, 75 | bookmark, 76 | "popup linked to bookmark" 77 | ); 78 | assert.ok( 79 | bookmarksControl._list.querySelector("[data-id='" + id + "']"), 80 | "in list" 81 | ); 82 | assert.equal( 83 | bookmarksControl.getData().filter((b) => b.id === id).length, 84 | 1, 85 | "in data" 86 | ); 87 | bookmarksControl._storage.getItem(id, function (item) { 88 | assert.ok(item, "in storage"); 89 | assert.equal(item.name, "Bookmark " + id, "correct name"); 90 | assert.equal( 91 | item.custom_key, 92 | "custom value " + id, 93 | "custom value stored" 94 | ); 95 | }); 96 | }); 97 | 98 | it("remove bookmark", () => { 99 | var bookmark = getBookmark(); 100 | map.fire("bookmark:add", { data: bookmark }); 101 | assert.notEqual( 102 | bookmarksControl.getData().indexOf(bookmark), 103 | -1, 104 | "stored" 105 | ); 106 | assert.ok(bookmarksControl._popup, "popup on the map"); 107 | 108 | map.fire("bookmark:remove", { data: bookmark }); 109 | 110 | assert.notOk(map.hasLayer(bookmarksControl._marker), "marker hidden"); 111 | assert.notOk(map.hasLayer(bookmarksControl._popup), "popup hidden"); 112 | 113 | assert.equal( 114 | bookmarksControl.getData().indexOf(bookmark), 115 | -1, 116 | "removed from data" 117 | ); 118 | bookmarksControl._storage.getItem(bookmark.id, (item) => { 119 | assert.notOk(item, "removed from storage"); 120 | }); 121 | }); 122 | 123 | it("edit bookmark", () => { 124 | var bookmark = getBookmark(); 125 | var coords = new L.LatLng(bookmark.latlng.lat, bookmark.latlng.lng); 126 | var name = bookmark.name.toString(); 127 | var id = bookmark.id; 128 | map.fire("bookmark:add", { data: bookmark }); 129 | map.fire("bookmark:edit", { data: bookmark }); 130 | var input = 131 | bookmarksControl._popup._contentNode.querySelector("input[type=text]"); 132 | var suffix = " " + uid(); 133 | input.value = input.value + suffix; 134 | var newCoord = getCoord(); 135 | bookmarksControl._marker.setLatLng(newCoord); 136 | bookmarksControl._marker.fire("dragstart").fire("dragend"); 137 | 138 | var form = bookmarksControl._popup._contentNode.querySelector("form"); 139 | map.once("bookmark:saved", (evt) => { 140 | var item = evt.data; 141 | assert.notEqual(item.name, name, "name changed"); 142 | assert.equal(item.name, name + suffix, "name stored correctly"); 143 | assert.equal(id, item.id, "same id"); 144 | assert.ok(item.custom_key, "other keys not lost"); 145 | assert.ok( 146 | newCoord.lng === item.latlng[1] && newCoord.lat === item.latlng[0], 147 | "new coord saved" 148 | ); 149 | }); 150 | 151 | bookmarksControl._popup._onSubmit({}); 152 | }); 153 | 154 | if (localStorage) localStorage.clear(); 155 | }); 156 | }); 157 | --------------------------------------------------------------------------------