diff options
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | default.yml | 57 | ||||
-rw-r--r-- | eslint.config.js | 36 | ||||
-rw-r--r-- | index.html | 31 | ||||
-rw-r--r-- | package.json | 61 | ||||
-rw-r--r-- | rollup.config.js | 55 | ||||
l--------- | src/css/css | 1 | ||||
-rw-r--r-- | src/css/dumbymap.css | 206 | ||||
-rw-r--r-- | src/css/index.css | 80 | ||||
-rw-r--r-- | src/css/style.css | 114 | ||||
-rw-r--r-- | src/css/tiny-mde.min.css | 1 | ||||
-rw-r--r-- | src/dumbymap.mjs | 357 | ||||
-rw-r--r-- | src/editor.mjs | 400 |
13 files changed, 1403 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e70c35c --- /dev/null +++ b/.gitignore | |||
@@ -0,0 +1,4 @@ | |||
1 | dist/ | ||
2 | node_modules/ | ||
3 | package-lock.json | ||
4 | pnpm-lock.yaml | ||
diff --git a/default.yml b/default.yml new file mode 100644 index 0000000..8967dbc --- /dev/null +++ b/default.yml | |||
@@ -0,0 +1,57 @@ | |||
1 | aliases: | ||
2 | use: | ||
3 | leaflet: | ||
4 | value: ./renderers/leaflet.mjs, | ||
5 | description: Leaflet is the leading open-source JavaScript library for mobile-friendly interactive maps. It has all the mapping features most developers ever need. | ||
6 | maplibre: | ||
7 | value: ./renderers/maplibre.mjs, | ||
8 | description: MapLibre GL JS is a TypeScript library that uses WebGL to render interactive maps from vector tiles in a browser. The customization of the map comply with the MapLibre Style Spec. | ||
9 | openlayers: | ||
10 | value: ./renderers/openlayers.mjs, | ||
11 | description: OpenLayers makes it easy to put a dynamic map in any web page. It can display map tiles, vector data and markers loaded from any source. OpenLayers has been developed to further the use of geographic information of all kinds. | ||
12 | XYZ: | ||
13 | OSM Carto: http://b.tile.openstreetmap.org/{z}/{x}/{y}.png | ||
14 | OpenCycleMap: https://a.tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=a5dd6a2f1c934394bce6b0fb077203eb | ||
15 | Rudymap: https://tile.happyman.idv.tw/map/moi_osm/{z}/{x}/{y}.png | ||
16 | Happyman gpx: https://tile.happyman.idv.tw/map/gpxtrack/{z}/{x}/{y}.png | ||
17 | Happyman moi: https://tile.happyman.idv.tw/map/moi_osm/{z}/{x}/{y}.png | ||
18 | NLSC aeril: http://wmts.nlsc.gov.tw/wmts/PHOTO2/default/GoogleMapsCompatible/{z}/{y}/{x} | ||
19 | NLSC topo: http://wmts.nlsc.gov.tw/wmts/PHOTO_MIX/default/GoogleMapsCompatible/{z}/{y}/{x} | ||
20 | TAIWAN_ ERRAIN: https://osmhacktw.github.io/terrain-rgb/tiles/{z}/{x}/{y}.png | ||
21 | WMTS: | ||
22 | SINICA: https://gis.sinica.edu.tw/tileserver/wmts | ||
23 | NLSC: https://wmts.nlsc.gov.tw/wmts | ||
24 | WMTS TAIWAN_TERRAIN: https://osmhacktw.github.io/terrain-rgb/wmts.xml | ||
25 | center: | ||
26 | Tokyo: [ 139.6917, 35.6895 ] | ||
27 | Delhi: [ 77.1025, 28.7041 ] | ||
28 | Shanghai: [ 121.4737, 31.2304 ] | ||
29 | Sao Paulo: [ -46.6333, -23.5505 ] | ||
30 | Mumbai: [ 72.8777, 19.0760 ] | ||
31 | Mexico City: [ -99.1332, 19.4326 ] | ||
32 | Beijing: [ 116.4074, 39.9042 ] | ||
33 | Osaka: [ 135.5022, 34.6937 ] | ||
34 | Cairo: [ 31.2357, 30.0444 ] | ||
35 | New York City: [ -74.0059, 40.7128 ] | ||
36 | Dhaka: [ 90.4125, 23.8103 ] | ||
37 | Karachi: [ 67.0011, 24.8607 ] | ||
38 | Buenos Aires: [ -58.3816, -34.6037 ] | ||
39 | Istanbul: [ 28.9784, 41.0082 ] | ||
40 | Kolkata: [ 88.3639, 22.5726 ] | ||
41 | Manila: [ 120.9842, 14.5995 ] | ||
42 | Lagos: [ 3.3792, 6.5244 ] | ||
43 | Rio de Janeiro: [ -43.1729, -22.9068 ] | ||
44 | Tianjin: [ 117.1767, 39.0842 ] | ||
45 | zoom: | ||
46 | Whole world: 0 | ||
47 | Subcontinental area: 2 | ||
48 | Large African country: 5 | ||
49 | Large European country: 6 | ||
50 | Wide area, large metropolitan area: 9 | ||
51 | Metropolitan area: 10 | ||
52 | City: 11 | ||
53 | Town, or city district: 12 | ||
54 | Village, or suburb: 13 | ||
55 | Street: 16 | ||
56 | Block, park, addresses: 17 | ||
57 | A mid-sized building: 20 | ||
diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b2a1c54 --- /dev/null +++ b/eslint.config.js | |||
@@ -0,0 +1,36 @@ | |||
1 | import globals from "globals"; | ||
2 | import js from "@eslint/js"; | ||
3 | import importPlugin from "eslint-plugin-import"; | ||
4 | import promisePlugin from "eslint-plugin-promise"; | ||
5 | import nodePlugin from "eslint-plugin-node"; | ||
6 | |||
7 | export default [ | ||
8 | js.configs.recommended, | ||
9 | { | ||
10 | languageOptions: { | ||
11 | ecmaVersion: 2022, | ||
12 | sourceType: "module", | ||
13 | globals: { | ||
14 | ...globals.browser, | ||
15 | ...globals.node | ||
16 | } | ||
17 | }, | ||
18 | plugins: { | ||
19 | import: importPlugin, | ||
20 | promise: promisePlugin, | ||
21 | node: nodePlugin, | ||
22 | }, | ||
23 | rules: { | ||
24 | 'no-unused-vars': ['error', { 'varsIgnorePattern': '^_' }], | ||
25 | 'import/no-unresolved': 'error', | ||
26 | 'no-console': ["error", { allow: ["info", "warn", "error"] }], | ||
27 | 'eqeqeq': ['error', 'always'], | ||
28 | // 'curly': ['warn', 'multi'], | ||
29 | 'prefer-const': 'error', | ||
30 | 'no-var': 'error', | ||
31 | 'promise/catch-or-return': 'error', | ||
32 | 'array-callback-return': 'error', | ||
33 | 'no-unexpected-multiline': 'warn' | ||
34 | }, | ||
35 | } | ||
36 | ]; | ||
diff --git a/index.html b/index.html new file mode 100644 index 0000000..3440175 --- /dev/null +++ b/index.html | |||
@@ -0,0 +1,31 @@ | |||
1 | <!DOCTYPE html> | ||
2 | <html lang="en"> | ||
3 | |||
4 | <head> | ||
5 | <meta charset="utf-8"> | ||
6 | <title>Leader Line with Markdown</title> | ||
7 | |||
8 | <meta property="og:description" content="Add a default marker to the map."> | ||
9 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
10 | <link rel="shortcut icon" href="favicon.svg" type="image/svg+xml"> | ||
11 | |||
12 | <!-- FIXME --> | ||
13 | <link rel="stylesheet" href="./dist/css/style.css"> | ||
14 | <link rel="stylesheet" href="./dist/css/dumbymap.css"> | ||
15 | <link rel="stylesheet" type="text/css" href="./dist/css/tiny-mde.min.css" /> | ||
16 | <link rel="stylesheet" type="text/css" href="./dist/css/index.css" /> | ||
17 | |||
18 | <!-- FIXME --> | ||
19 | <script src="./dist/editor.mjs" type="module"></script> | ||
20 | |||
21 | </head> | ||
22 | |||
23 | <body> | ||
24 | <div class="result-html"></div> | ||
25 | <div class="editor"> | ||
26 | <div id="tinymde"></div> | ||
27 | <div id="tinymde_commandbar"></div> | ||
28 | </div> | ||
29 | </body> | ||
30 | |||
31 | </html> | ||
diff --git a/package.json b/package.json new file mode 100644 index 0000000..2c86d6d --- /dev/null +++ b/package.json | |||
@@ -0,0 +1,61 @@ | |||
1 | { | ||
2 | "name": "dumbymap", | ||
3 | "description": "Generate interactive maps from Semantic HTML", | ||
4 | "version": "0.1.0", | ||
5 | "license": "MIT", | ||
6 | "type": "module", | ||
7 | "main": "dist/dumbymap.mjs", | ||
8 | "style": "dist/css/dummby.css", | ||
9 | "files": [ | ||
10 | "dist/*", | ||
11 | "src/" | ||
12 | ], | ||
13 | "keywords": [ | ||
14 | "markdown", | ||
15 | "semantic-html", | ||
16 | "maplibre", | ||
17 | "openlayers", | ||
18 | "leaflet" | ||
19 | ], | ||
20 | "license": "MIT", | ||
21 | "scripts": { | ||
22 | "watch": "npx rollup -c -w", | ||
23 | "build": "npx rollup -c", | ||
24 | "build-css": "ln -sf `pwd`/src/css `pwd`/dist/css", | ||
25 | "build-renderers": "ln -sf `pwd`/node_modules/mapclay/dist/renderers `pwd`/dist/renderers", | ||
26 | "server": "live-server --port=8080 --ignore='**/src/**js' --wait=2000 --no-browser --cors", | ||
27 | "dev": "mkdir -p dist; npm run build-css; npm run build-renderers; npm run build; npm run server", | ||
28 | "lint": "npx eslint src" | ||
29 | }, | ||
30 | "devDependencies": { | ||
31 | "@eslint/compat": "^1.1.1", | ||
32 | "@eslint/js": "^9.10.0", | ||
33 | "@rollup/plugin-alias": "^5.1.0", | ||
34 | "@rollup/plugin-commonjs": "^26.0.1", | ||
35 | "@rollup/plugin-node-resolve": "^15.2.3", | ||
36 | "@rollup/plugin-terser": "^0.4.4", | ||
37 | "eslint": "^9.10.0", | ||
38 | "eslint-plugin-import": "^2.30.0", | ||
39 | "eslint-plugin-node": "^11.1.0", | ||
40 | "eslint-plugin-promise": "^7.1.0", | ||
41 | "globals": "^15.9.0", | ||
42 | "rollup": "^4.21.2" | ||
43 | }, | ||
44 | "dependencies": { | ||
45 | "leader-line": "^1.0.7", | ||
46 | "mapclay": "^0.5.4", | ||
47 | "markdown-it": "^14.1.0", | ||
48 | "markdown-it-anchor": "^9.1.0", | ||
49 | "markdown-it-footnote": "^4.0.0", | ||
50 | "markdown-it-front-matter": "^0.2.4", | ||
51 | "markdown-it-toc-done-right": "^4.2.0", | ||
52 | "plain-draggable": "^2.5.14", | ||
53 | "tiny-markdown-editor": "^0.1.23" | ||
54 | }, | ||
55 | "author": "Hsiehg Chin Fan <pham@topo.tw>", | ||
56 | "homepage": "https://outdoorsafetylab.org/", | ||
57 | "repository": { | ||
58 | "type": "git", | ||
59 | "url": "https://github.com/outdoorsafetylab/dumbymap" | ||
60 | } | ||
61 | } | ||
diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..1dc3a02 --- /dev/null +++ b/rollup.config.js | |||
@@ -0,0 +1,55 @@ | |||
1 | import node from '@rollup/plugin-node-resolve'; | ||
2 | import commonjs from '@rollup/plugin-commonjs'; | ||
3 | import terser from '@rollup/plugin-terser'; | ||
4 | import { existsSync } from 'fs'; | ||
5 | import { join } from 'path'; | ||
6 | |||
7 | const production = !process.env.ROLLUP_WATCH; | ||
8 | const general = { | ||
9 | output: [ | ||
10 | { | ||
11 | dir: './dist', | ||
12 | format: 'esm', | ||
13 | entryFileNames: '[name].mjs', | ||
14 | } | ||
15 | ], | ||
16 | watch: { | ||
17 | clearScreen: false, | ||
18 | include: ["src/**", "mapclay/dist/mapclay.mjs"] | ||
19 | }, | ||
20 | context: "window", | ||
21 | plugins: [ | ||
22 | { | ||
23 | name: 'leader-line', | ||
24 | transform(code, id) { | ||
25 | if (id.includes('node_modules/leader-line/')) { | ||
26 | return `${code}\nexport default LeaderLine;`; | ||
27 | } | ||
28 | return null; | ||
29 | }, | ||
30 | }, | ||
31 | { | ||
32 | name: 'mapclay', | ||
33 | resolveId(source) { | ||
34 | if (source === 'mapclay' && existsSync(join('.', 'mapclay'))) { | ||
35 | return './mapclay/dist/mapclay.mjs'; | ||
36 | } | ||
37 | return null; | ||
38 | } | ||
39 | }, | ||
40 | node(), | ||
41 | commonjs(), | ||
42 | production && terser(), | ||
43 | ], | ||
44 | } | ||
45 | |||
46 | export default [ | ||
47 | { | ||
48 | input: "src/editor.mjs", | ||
49 | }, | ||
50 | { | ||
51 | input: "src/dumbymap.mjs", | ||
52 | }, | ||
53 | ].map(config => { | ||
54 | return { ...general, ...config } | ||
55 | }) | ||
diff --git a/src/css/css b/src/css/css new file mode 120000 index 0000000..9913b8c --- /dev/null +++ b/src/css/css | |||
@@ -0,0 +1 @@ | |||
/home/pham/git/dumbymap/src/css \ No newline at end of file | |||
diff --git a/src/css/dumbymap.css b/src/css/dumbymap.css new file mode 100644 index 0000000..bfdcc2b --- /dev/null +++ b/src/css/dumbymap.css | |||
@@ -0,0 +1,206 @@ | |||
1 | [class^="leader-line"] { | ||
2 | z-index: 9999; | ||
3 | } | ||
4 | |||
5 | .with-leader-line:not(:has(> *)) { | ||
6 | display: inline-block; | ||
7 | left: 0px; | ||
8 | top: 0px; | ||
9 | |||
10 | padding-right: 15px; | ||
11 | padding-left: 6px; | ||
12 | |||
13 | background-image: url("data:image/svg+xml;charset=utf-8;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cG9seWdvbiBwb2ludHM9IjI0LDAgMCw4IDgsMTEgMCwxOSA1LDI0IDEzLDE2IDE2LDI0IiBmaWxsPSJjb3JhbCIvPjwvc3ZnPg=="); | ||
14 | background-size: 12px 12px; | ||
15 | background-repeat: no-repeat; | ||
16 | background-position: right 2px top 2px; | ||
17 | color: #555; | ||
18 | |||
19 | &.geolink { | ||
20 | background-color: rgb(248, 248, 129); | ||
21 | } | ||
22 | &.doclink { | ||
23 | background-color: #9ee7ea; | ||
24 | } | ||
25 | |||
26 | &:hover { | ||
27 | padding-right: 6px; | ||
28 | background-image: none; | ||
29 | |||
30 | font-weight: bolder; | ||
31 | text-decoration: none; | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .result-html { | ||
36 | position: relative; | ||
37 | max-width: 60em; | ||
38 | margin: 0 auto; | ||
39 | padding: 0; | ||
40 | display: flex; | ||
41 | overflow-x: scroll; | ||
42 | /* height: 100vh; */ | ||
43 | |||
44 | #map { | ||
45 | flex: 0; | ||
46 | #mapPlaceholder { | ||
47 | display: none; | ||
48 | } | ||
49 | } | ||
50 | |||
51 | #markdown { | ||
52 | flex: 1; | ||
53 | padding: 20px; | ||
54 | overflow-y: scroll; | ||
55 | |||
56 | > .draggable-block { | ||
57 | width: 100%; | ||
58 | position: relative; | ||
59 | pointer-events: auto; | ||
60 | border-radius: 0.5rem; | ||
61 | padding: 0.5rem; | ||
62 | background-color: white; | ||
63 | margin-bottom: 3rem; | ||
64 | |||
65 | * { | ||
66 | width: fit-content; | ||
67 | } | ||
68 | |||
69 | .draggable { | ||
70 | display: none; | ||
71 | } | ||
72 | } | ||
73 | |||
74 | pre:has(.map-container) { | ||
75 | display: flex; | ||
76 | justify-content: flex-start; | ||
77 | width: 100%; | ||
78 | background-color: inherit; | ||
79 | } | ||
80 | |||
81 | .map-container { | ||
82 | margin: 2px; | ||
83 | &.focus { | ||
84 | border: solid gray; | ||
85 | } | ||
86 | } | ||
87 | |||
88 | #mapPlaceholder { | ||
89 | flex: 1; | ||
90 | display: flex; | ||
91 | justify-content: center; | ||
92 | align-items: center; | ||
93 | background-color: lightgray; | ||
94 | > * { | ||
95 | width: fit-content; | ||
96 | flex: 0 0; | ||
97 | } | ||
98 | } | ||
99 | } | ||
100 | |||
101 | :has(> .map-container) { | ||
102 | display: flex; | ||
103 | width: fit-content; | ||
104 | } | ||
105 | |||
106 | #map .map-container { | ||
107 | width: 100% !important; | ||
108 | height: 100% !important; | ||
109 | } | ||
110 | } | ||
111 | |||
112 | .result-html[data-layout]:not([data-layout=none]) { | ||
113 | margin: 0; | ||
114 | |||
115 | height: 100vh; | ||
116 | width: 100%; | ||
117 | max-width: none; | ||
118 | border: none; | ||
119 | |||
120 | & ~ :not(.leader-line) { | ||
121 | display: none; | ||
122 | pointer-events: none; | ||
123 | } | ||
124 | } | ||
125 | |||
126 | .result-html[data-layout=side] { | ||
127 | #map, | ||
128 | #markdown { | ||
129 | flex: 50%; | ||
130 | overflow-y: scroll; | ||
131 | height: 100vh; | ||
132 | } | ||
133 | } | ||
134 | |||
135 | .result-html[data-layout=overlay] { | ||
136 | #map, | ||
137 | #markdown { | ||
138 | position: fixed; | ||
139 | height: 100%; | ||
140 | width: 100%; | ||
141 | } | ||
142 | |||
143 | #markdown { | ||
144 | font-size: 12px; | ||
145 | pointer-events: none; | ||
146 | |||
147 | > .draggable-block { | ||
148 | box-sizing: content-box; | ||
149 | width: fit-content; | ||
150 | max-height: 50vh; | ||
151 | overflow: scroll; | ||
152 | border: solid gray; | ||
153 | |||
154 | * { | ||
155 | max-width: calc(100vw/4); | ||
156 | } | ||
157 | |||
158 | .draggable { | ||
159 | width: 100%; | ||
160 | display: block; | ||
161 | top: 0; | ||
162 | left: 0; | ||
163 | position: sticky; | ||
164 | margin-bottom: -18px; | ||
165 | opacity: 0.3; | ||
166 | transform: translate(0, -16px); | ||
167 | text-align: center; | ||
168 | z-index: 100; | ||
169 | transition: all 0.3s ease-out; | ||
170 | &:hover { | ||
171 | background-color: lightgray; | ||
172 | } | ||
173 | } | ||
174 | } | ||
175 | > :not(.draggable-block) { | ||
176 | display: none; | ||
177 | } | ||
178 | } | ||
179 | } | ||
180 | |||
181 | *:has(> nav) { | ||
182 | position: absolute; | ||
183 | right: 10px; | ||
184 | bottom: 10px; | ||
185 | /* FIXME */ | ||
186 | display: none; | ||
187 | |||
188 | padding: 0.5rem; | ||
189 | min-width: 120px; | ||
190 | border: 2px solid gray; | ||
191 | border-radius: 0.5rem; | ||
192 | background-color: white; | ||
193 | z-index: 500; | ||
194 | |||
195 | &:has(> nav:empty) { | ||
196 | display: none; | ||
197 | } | ||
198 | ol { | ||
199 | margin-top: 0; | ||
200 | margin-bottom: 0.5rem; | ||
201 | } | ||
202 | } | ||
203 | |||
204 | .bold-options { | ||
205 | font-weight: bold; | ||
206 | } | ||
diff --git a/src/css/index.css b/src/css/index.css new file mode 100644 index 0000000..adc1cfe --- /dev/null +++ b/src/css/index.css | |||
@@ -0,0 +1,80 @@ | |||
1 | body { | ||
2 | display: flex; | ||
3 | justify-items: stretch; | ||
4 | } | ||
5 | |||
6 | .result-html { | ||
7 | flex: 1; | ||
8 | height: calc(100vh - 20px); | ||
9 | border: solid lightgray 2px; | ||
10 | border-radius: 5px; | ||
11 | margin: 10px; | ||
12 | } | ||
13 | |||
14 | .editor { | ||
15 | flex: 1; | ||
16 | max-width: 50vw; | ||
17 | margin: 10px; | ||
18 | } | ||
19 | |||
20 | .TinyMDE { | ||
21 | height: calc(100vh - 55px); | ||
22 | padding: 16px; | ||
23 | overflow-y: scroll; | ||
24 | border: 2px solid lightgray; | ||
25 | border-radius: 5px; | ||
26 | |||
27 | span { | ||
28 | white-space: pre; | ||
29 | } | ||
30 | .invalid-input { | ||
31 | text-decoration: red wavy underline 1px; | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .container__suggestions { | ||
36 | display: none; | ||
37 | position: absolute; | ||
38 | width: fit-content; | ||
39 | min-width: 10rem; | ||
40 | max-height: 40vh; | ||
41 | overflow-y: scroll; | ||
42 | border: 2px solid lightgray; | ||
43 | border-radius: 0.5rem; | ||
44 | |||
45 | background: #fff; | ||
46 | } | ||
47 | .container__suggestion { | ||
48 | cursor: pointer; | ||
49 | display: flex; | ||
50 | justify-content: space-between; | ||
51 | overflow: hidden; | ||
52 | |||
53 | height: fit-content; | ||
54 | min-height: 2rem; | ||
55 | white-space: nowrap; | ||
56 | align-items: center; | ||
57 | |||
58 | * { | ||
59 | flex-shrink: 0; | ||
60 | display: inline-block; | ||
61 | overflow: hidden; | ||
62 | padding-inline: 1em; | ||
63 | } | ||
64 | .info { | ||
65 | color: #4682B4; | ||
66 | font-weight: bold; | ||
67 | } | ||
68 | .truncate { | ||
69 | text-overflow: ellipsis; | ||
70 | ::before { | ||
71 | width: 2rem | ||
72 | } | ||
73 | } | ||
74 | } | ||
75 | .container__suggestion:not(:first-child) { | ||
76 | border-top: 1px solid rgb(203 213 225); | ||
77 | } | ||
78 | .container__suggestion.focus { | ||
79 | background: rgb(226 232 240); | ||
80 | } | ||
diff --git a/src/css/style.css b/src/css/style.css new file mode 100644 index 0000000..e650c1e --- /dev/null +++ b/src/css/style.css | |||
@@ -0,0 +1,114 @@ | |||
1 | /* CSS Reset */ | ||
2 | *, *::before, *::after { | ||
3 | box-sizing: border-box; | ||
4 | } | ||
5 | |||
6 | * { | ||
7 | margin: 0; | ||
8 | line-height: calc(1em + 0.5rem); | ||
9 | } | ||
10 | |||
11 | img, picture, video, canvas, svg { | ||
12 | display: block; | ||
13 | max-width: 100%; | ||
14 | } | ||
15 | |||
16 | input, button, textarea, select { | ||
17 | font: inherit; | ||
18 | } | ||
19 | |||
20 | p, h1, h2, h3, h4, h5, h6 { | ||
21 | overflow-wrap: break-word; | ||
22 | hyphens: auto; | ||
23 | } | ||
24 | /* End of Reset */ | ||
25 | |||
26 | body { | ||
27 | font-family: sans-serif; | ||
28 | line-height: 1.6; | ||
29 | } | ||
30 | |||
31 | h1, h2, h3, h4, h5, h6 { | ||
32 | font-weight: bold; | ||
33 | margin-bottom: 0.5em; | ||
34 | } | ||
35 | |||
36 | h1 { | ||
37 | font-size: 2em; | ||
38 | } | ||
39 | |||
40 | h2 { | ||
41 | font-size: 1.5em; | ||
42 | } | ||
43 | |||
44 | h3 { | ||
45 | font-size: 1.25em; | ||
46 | } | ||
47 | |||
48 | p { | ||
49 | margin-top: 1em; | ||
50 | margin-bottom: 1em; | ||
51 | } | ||
52 | |||
53 | a { | ||
54 | color: #007bff; | ||
55 | text-decoration: none; | ||
56 | } | ||
57 | |||
58 | a:hover { | ||
59 | text-decoration: underline; | ||
60 | } | ||
61 | |||
62 | pre:has([class^="language-"]) { | ||
63 | min-width: 400px; | ||
64 | display: block; | ||
65 | padding: 9.5px; | ||
66 | margin: 0 0 10px; | ||
67 | font-size: 13px; | ||
68 | line-height: 1.42857143; | ||
69 | color: #333; | ||
70 | word-break: break-all; | ||
71 | word-wrap: break-word; | ||
72 | background-color: #f5f5f5; | ||
73 | border: 1px solid #ccc; | ||
74 | border-radius: 4px; | ||
75 | |||
76 | code { | ||
77 | padding: 0; | ||
78 | font-size: inherit; | ||
79 | color: inherit; | ||
80 | white-space: pre-wrap; | ||
81 | background-color: transparent; | ||
82 | border-radius: 0; | ||
83 | } | ||
84 | } | ||
85 | |||
86 | ul, ol { | ||
87 | margin-top: 1em; | ||
88 | margin-bottom: 1em; | ||
89 | padding-left: 20px; | ||
90 | } | ||
91 | |||
92 | li { | ||
93 | margin-bottom: 0.5em; | ||
94 | } | ||
95 | |||
96 | table { | ||
97 | border-collapse: collapse; | ||
98 | width: 100%; | ||
99 | } | ||
100 | |||
101 | th, td { | ||
102 | border: 1px solid #ddd; | ||
103 | padding: 8px; | ||
104 | text-align: left; | ||
105 | } | ||
106 | |||
107 | th { | ||
108 | background-color: #f0f0f0; | ||
109 | } | ||
110 | |||
111 | img { | ||
112 | max-width: 100%; | ||
113 | height: auto; | ||
114 | } | ||
diff --git a/src/css/tiny-mde.min.css b/src/css/tiny-mde.min.css new file mode 100644 index 0000000..f317628 --- /dev/null +++ b/src/css/tiny-mde.min.css | |||
@@ -0,0 +1 @@ | |||
.TinyMDE{background-color:#fff;color:#000;font-size:16px;line-height:24px;outline:none;padding:5px}.TMBlankLine{height:24px}.TMH1,.TMSetextH1{font-size:22px;font-weight:700;line-height:32px;margin-bottom:8px}.TMSetextH1{margin-bottom:0}.TMSetextH1Marker{margin-bottom:8px}.TMH2,.TMSetextH2{font-size:20px;font-weight:700;line-height:28px;margin-bottom:4px}.TMMark_TMCode{font-family:monospace;font-size:.9em}.TMCode,.TMFencedCodeBacktick,.TMFencedCodeTilde,.TMIndentedCode{background-color:#e0e0e0;font-family:monospace;font-size:.9em}.TMCodeFenceBacktickOpen,.TMCodeFenceTildeOpen{border-bottom:1px solid silver;font-family:monospace;font-size:.9em}.TMCodeFenceBacktickClose,.TMCodeFenceTildeClose{border-top:1px solid silver;font-family:monospace;font-size:.9em}.TMInfoString{color:#00f}.TMCode{border:1px solid silver;border-radius:2px}.TMBlockquote{border-left:2px solid silver;font-style:italic;margin-left:10px;padding-left:10px}.TMMark{color:#a0a0a0}.TMMark_TMH1,.TMMark_TMH2,.TMMark_TMOL,.TMMark_TMUL{color:#ff8080}.TMImage{text-decoration:underline;text-decoration-color:#0f0}.TMLink{text-decoration:underline;text-decoration-color:#00f}.TMLinkLabel{font-family:monospace;text-decoration:underline}.TMLinkLabel_Definition,.TMLinkLabel_Valid{color:#40c040}.TMLinkLabel_Invalid{color:red}.TMLinkTitle{font-style:italic}.TMAutolink,.TMLinkDestination{color:#00f;text-decoration:underline}.TMHR{position:relative}.TMHR:before{border-bottom:2px solid grey;bottom:50%;content:"";left:40%;position:absolute;width:20%;z-index:0}.TMHTML,.TMHTMLBlock{color:#8000ff;font-family:monospace;font-size:.9em}.TMHTMLBlock{color:#6000c0}.TMCommandBar{-ms-overflow-style:none;background-color:#f8f8f8;border:4px solid #f8f8f8;box-sizing:content-box;display:flex;height:24px;overflow-x:scroll;overflow-y:hidden;scrollbar-width:none;-webkit-user-select:none;user-select:none}.TMCommandBar::-webkit-scrollbar{display:none}.TMCommandButton{fill:#404040;box-sizing:border-box;color:#404040;cursor:pointer;display:inline-block;font-family:sans-serif;font-size:20px;height:24px;line-height:18px;margin-right:4px;padding:3px;text-align:center;vertical-align:middle;width:24px}.TMCommandDivider{border-left:1px solid silver;border-right:1px solid #fff;box-sizing:content-box;height:24px;margin-left:4px;margin-right:8px;width:0}.TMCommandButton_Active{fill:navy;background-color:#c0c0ff;color:navy;font-weight:700}.TMCommandButton_Inactive{background-color:#f8f8f8}.TMCommandButton_Disabled{fill:#a0a0a0;color:#a0a0a0}@media (hover:hover){.TMCommandButton_Active:hover,.TMCommandButton_Disabled:hover,.TMCommandButton_Inactive:hover{fill:#000;background-color:#e0e0ff}} | |||
diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs new file mode 100644 index 0000000..1cc0f07 --- /dev/null +++ b/src/dumbymap.mjs | |||
@@ -0,0 +1,357 @@ | |||
1 | // vim:foldmethod | ||
2 | import MarkdownIt from 'markdown-it' | ||
3 | import MarkdownItAnchor from 'markdown-it-anchor' | ||
4 | import MarkdownItFootnote from 'markdown-it-footnote' | ||
5 | import MarkdownItFrontMatter from 'markdown-it-front-matter' | ||
6 | import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' | ||
7 | import LeaderLine from 'leader-line' | ||
8 | import PlainDraggable from 'plain-draggable' | ||
9 | import { render, parseConfigsFromText } from 'mapclay' | ||
10 | |||
11 | const observers = new Map() | ||
12 | |||
13 | export const markdown2HTML = async (container, mdContent) => { | ||
14 | // Render: Markdown -> HTML {{{ | ||
15 | |||
16 | container.innerHTML = ` | ||
17 | <div id="map"></div> | ||
18 | <div id="markdown"></div> | ||
19 | ` | ||
20 | |||
21 | const md = MarkdownIt({ html: true }) | ||
22 | .use(MarkdownItAnchor, { | ||
23 | permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: 'before' }) | ||
24 | }) | ||
25 | .use(MarkdownItFootnote) | ||
26 | .use(MarkdownItFrontMatter) | ||
27 | .use(MarkdownItTocDoneRight) | ||
28 | |||
29 | // FIXME A better way to generate draggable code block | ||
30 | md.renderer.rules.draggable_block_open = () => '<div>' | ||
31 | md.renderer.rules.draggable_block_close = () => '</div>' | ||
32 | |||
33 | md.core.ruler.before('block', 'draggable_block', (state) => { | ||
34 | state.tokens.push(new state.Token('draggable_block_open', '', 1)) | ||
35 | }) | ||
36 | |||
37 | // Add close tag for block with more than 2 empty lines | ||
38 | md.block.ruler.before('table', 'draggable_block', (state, startLine) => { | ||
39 | if (state.src[state.bMarks[startLine - 1]] === '\n' && state.src[state.bMarks[startLine - 2]] === '\n') { | ||
40 | state.push('draggable_block_close', '', -1); | ||
41 | state.push('draggable_block_open', '', 1); | ||
42 | } | ||
43 | }) | ||
44 | |||
45 | md.core.ruler.after('block', 'draggable_block', (state) => { | ||
46 | state.tokens.push(new state.Token('draggable_block_close', '', -1)) | ||
47 | }) | ||
48 | |||
49 | const markdown = container.querySelector('#markdown') | ||
50 | const contentWithToc = '${toc}\n\n\n' + mdContent | ||
51 | markdown.innerHTML = md.render(contentWithToc); | ||
52 | markdown.querySelectorAll('*> div:not(:has(nav))') | ||
53 | .forEach(b => b.classList.add('draggable-block')) | ||
54 | |||
55 | |||
56 | // TODO Improve it! | ||
57 | const docLinks = Array.from(container.querySelectorAll('#markdown a[href^="#"][title^="doc"]')) | ||
58 | docLinks.forEach(link => { | ||
59 | link.classList.add('with-leader-line', 'doclink') | ||
60 | link.lines = [] | ||
61 | |||
62 | link.onmouseover = () => { | ||
63 | const target = document.querySelector(link.getAttribute('href')) | ||
64 | if (!target?.checkVisibility()) return | ||
65 | |||
66 | const line = new LeaderLine({ | ||
67 | start: link, | ||
68 | end: target, | ||
69 | hide: true, | ||
70 | path: "magnet" | ||
71 | }) | ||
72 | link.lines.push(line) | ||
73 | line.show('draw', { duration: 300, }) | ||
74 | } | ||
75 | link.onmouseout = () => { | ||
76 | link.lines.forEach(line => line.remove()) | ||
77 | link.lines.length = 0 | ||
78 | } | ||
79 | }) | ||
80 | //}}} | ||
81 | } | ||
82 | |||
83 | export const generateMaps = async (container) => { | ||
84 | // LeaderLine {{{ | ||
85 | |||
86 | // Get anchors with "geo:" scheme | ||
87 | const markdown = container.querySelector('#markdown') | ||
88 | markdown.anchors = [] | ||
89 | |||
90 | // Set focusArea | ||
91 | const focusArea = container.querySelector('#map') | ||
92 | const mapPlaceholder = document.createElement('div') | ||
93 | mapPlaceholder.id = 'mapPlaceholder' | ||
94 | focusArea.appendChild(mapPlaceholder) | ||
95 | |||
96 | // Links points to map by geo schema and id | ||
97 | const geoLinks = Array.from(container.querySelectorAll('#markdown a[href^="geo:"]')) | ||
98 | .filter(link => { | ||
99 | const url = new URL(link.href) | ||
100 | const xy = url?.href?.match(/^geo:([0-9.,]+)/)?.at(1)?.split(',')?.reverse()?.map(Number) | ||
101 | |||
102 | if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false | ||
103 | |||
104 | // Geo information in link | ||
105 | link.url = url | ||
106 | link.xy = xy | ||
107 | link.classList.add('with-leader-line', 'geolink') | ||
108 | link.targets = link.url.searchParams.get('id')?.split(',') ?? null | ||
109 | |||
110 | // LeaderLine | ||
111 | link.lines = [] | ||
112 | link.onmouseover = () => addLeaderLines(link) | ||
113 | link.onmouseout = () => removeLeaderLines(link) | ||
114 | link.onclick = (event) => { | ||
115 | event.preventDefault() | ||
116 | markdown.anchors | ||
117 | .filter(isAnchorPointedBy(link)) | ||
118 | .forEach(updateMapByMarker(xy)) | ||
119 | // TODO Just hide leader line and show it again | ||
120 | removeLeaderLines(link) | ||
121 | } | ||
122 | |||
123 | return true | ||
124 | }) | ||
125 | |||
126 | const isAnchorPointedBy = (link) => (anchor) => { | ||
127 | const mapContainer = anchor.closest('.map-container') | ||
128 | const isTarget = !link.targets || link.targets.includes(mapContainer.id) | ||
129 | return anchor.title === link.url.pathname && isTarget | ||
130 | } | ||
131 | |||
132 | const isAnchorVisible = (anchor) => { | ||
133 | const mapContainer = anchor.closest('.map-container') | ||
134 | return insideWindow(anchor) && insideParent(anchor, mapContainer) | ||
135 | } | ||
136 | |||
137 | const drawLeaderLine = (link) => (anchor) => { | ||
138 | const line = new LeaderLine({ | ||
139 | start: link, | ||
140 | end: anchor, | ||
141 | hide: true, | ||
142 | middleLabel: link.url.searchParams.get('text'), | ||
143 | path: "magnet", | ||
144 | }) | ||
145 | line.show('draw', { duration: 300, }) | ||
146 | return line | ||
147 | } | ||
148 | |||
149 | const addLeaderLines = (link) => { | ||
150 | link.lines = markdown.anchors | ||
151 | .filter(isAnchorPointedBy(link)) | ||
152 | .filter(isAnchorVisible) | ||
153 | .map(drawLeaderLine(link)) | ||
154 | } | ||
155 | |||
156 | const removeLeaderLines = (link) => { | ||
157 | if (!link.lines) return | ||
158 | link.lines.forEach(line => line.remove()) | ||
159 | link.lines = [] | ||
160 | } | ||
161 | |||
162 | const updateMapByMarker = (xy) => (marker) => { | ||
163 | const renderer = marker.closest('.map-container')?.renderer | ||
164 | renderer.updateCamera({ center: xy }, true) | ||
165 | } | ||
166 | |||
167 | const insideWindow = (element) => { | ||
168 | const rect = element.getBoundingClientRect() | ||
169 | return rect.left > 0 && | ||
170 | rect.right < window.innerWidth + rect.width && | ||
171 | rect.top > 0 && | ||
172 | rect.bottom < window.innerHeight + rect.height | ||
173 | } | ||
174 | |||
175 | const insideParent = (childElement, parentElement) => { | ||
176 | const childRect = childElement.getBoundingClientRect(); | ||
177 | const parentRect = parentElement.getBoundingClientRect(); | ||
178 | const offset = 20 | ||
179 | |||
180 | return childRect.left > parentRect.left + offset && | ||
181 | childRect.right < parentRect.right - offset && | ||
182 | childRect.top > parentRect.top + offset && | ||
183 | childRect.bottom < parentRect.bottom - offset | ||
184 | } | ||
185 | //}}} | ||
186 | // Render Maps {{{ | ||
187 | |||
188 | const afterEachMapLoaded = (mapContainer) => { | ||
189 | mapContainer.querySelectorAll('.marker') | ||
190 | .forEach(marker => markdown.anchors.push(marker)) | ||
191 | |||
192 | const focusClickedMap = () => { | ||
193 | if (container.getAttribute('data-layout') !== 'none') return | ||
194 | |||
195 | container.querySelectorAll('.map-container') | ||
196 | .forEach(c => c.classList.remove('focus')) | ||
197 | mapContainer.classList.add('focus') | ||
198 | } | ||
199 | mapContainer.onclick = focusClickedMap | ||
200 | } | ||
201 | |||
202 | // Set unique ID for map container | ||
203 | const mapIdList = [] | ||
204 | const assignMapId = (config) => { | ||
205 | let mapId = config.id | ||
206 | if (!mapId) { | ||
207 | mapId = config.use?.split('/')?.at(-1) | ||
208 | let counter = 2 | ||
209 | while (mapIdList.includes(mapId)) { | ||
210 | mapId = `${config.use}.${counter}` | ||
211 | counter++ | ||
212 | } | ||
213 | config.id = mapId | ||
214 | } | ||
215 | mapIdList.push(mapId) | ||
216 | } | ||
217 | |||
218 | const markerOptions = geoLinks.map(link => { | ||
219 | return { | ||
220 | targets: link.targets, | ||
221 | xy: link.xy, | ||
222 | title: link.url.pathname | ||
223 | } | ||
224 | }) | ||
225 | |||
226 | |||
227 | // Render each code block with "language-map" class | ||
228 | const renderTargets = Array.from(container.querySelectorAll('pre:has(.language-map)')) | ||
229 | const renderAllTargets = renderTargets.map(async (target) => { | ||
230 | // Get text in code block starts with '```map' | ||
231 | // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content | ||
232 | // replace it by normal space | ||
233 | const configText = target.querySelector('.language-map').textContent.replace(/\u00A0/g, '\u0020') | ||
234 | |||
235 | let configList = [] | ||
236 | try { | ||
237 | configList = parseConfigsFromText(configText).map(result => { | ||
238 | assignMapId(result) | ||
239 | const markersFromLinks = markerOptions.filter(marker => | ||
240 | !marker.targets || marker.targets.includes(result.id) | ||
241 | ) | ||
242 | Object.assign(result, { markers: markersFromLinks }) | ||
243 | return result | ||
244 | }) | ||
245 | } catch (err) { | ||
246 | console.error('Fail to parse yaml config for element', target, err) | ||
247 | } | ||
248 | |||
249 | // Render maps | ||
250 | return render(target, configList) | ||
251 | .then(results => { | ||
252 | results.forEach((mapByConfig) => { | ||
253 | if (mapByConfig.status === 'fulfilled') { | ||
254 | afterEachMapLoaded(mapByConfig.value) | ||
255 | return mapByConfig.value | ||
256 | } else { | ||
257 | console.error('Fail to render target element', mapByConfig.value) | ||
258 | } | ||
259 | }) | ||
260 | }) | ||
261 | }) | ||
262 | const renderInfo = await Promise.all(renderAllTargets).then(() => 'Finish Rendering') | ||
263 | console.info(renderInfo) | ||
264 | |||
265 | //}}} | ||
266 | // CSS observer {{{ | ||
267 | if (!observers.get(container)) { | ||
268 | observers.set(container, []) | ||
269 | } | ||
270 | const obs = observers.get(container) | ||
271 | if (obs.length) { | ||
272 | obs.forEach(o => o.disconnect()) | ||
273 | obs.length = 0 | ||
274 | } | ||
275 | // Layout{{{ | ||
276 | |||
277 | // press key to switch layout | ||
278 | const layouts = ['none', 'side', 'overlay'] | ||
279 | container.setAttribute("data-layout", layouts[0]) | ||
280 | document.onkeydown = (event) => { | ||
281 | if (event.key === 'x' && container.querySelector('.map-container')) { | ||
282 | let currentLayout = container.getAttribute('data-layout') | ||
283 | currentLayout = currentLayout ? currentLayout : 'none' | ||
284 | const nextLayout = layouts[(layouts.indexOf(currentLayout) + 1) % layouts.length] | ||
285 | |||
286 | container.setAttribute("data-layout", nextLayout) | ||
287 | } | ||
288 | } | ||
289 | |||
290 | // Add draggable part for blocks | ||
291 | markdown.blocks = Array.from(markdown.querySelectorAll('.draggable-block')) | ||
292 | markdown.blocks.forEach(block => { | ||
293 | const draggablePart = document.createElement('div'); | ||
294 | draggablePart.classList.add('draggable') | ||
295 | draggablePart.textContent = '☰' | ||
296 | |||
297 | // TODO Better way to close block | ||
298 | draggablePart.onmouseup = (e) => { | ||
299 | if (e.button === 1) block.style.display = "none"; | ||
300 | } | ||
301 | block.insertBefore(draggablePart, block.firstChild) | ||
302 | }) | ||
303 | |||
304 | // observe layout change | ||
305 | const layoutObserver = new MutationObserver(() => { | ||
306 | const layout = container.getAttribute('data-layout') | ||
307 | markdown.blocks.forEach(b => b.style.display = "block") | ||
308 | |||
309 | if (layout === 'none') { | ||
310 | mapPlaceholder.innerHTML = "" | ||
311 | const map = focusArea.querySelector('.map-container') | ||
312 | // Swap focused map and palceholder in markdown | ||
313 | if (map) { | ||
314 | mapPlaceholder.parentElement?.replaceChild(map, mapPlaceholder) | ||
315 | focusArea.append(mapPlaceholder) | ||
316 | } | ||
317 | } else { | ||
318 | // If paceholder is not set, create one and put map into focusArea | ||
319 | if (focusArea.contains(mapPlaceholder)) { | ||
320 | const mapContainer = container.querySelector('.map-container.focus') ?? container.querySelector('.map-container') | ||
321 | mapPlaceholder.innerHTML = `<div>Placeholder</div>` | ||
322 | // TODO | ||
323 | // mapPlaceholder.src = map.map.getCanvas().toDataURL() | ||
324 | mapContainer.parentElement?.replaceChild(mapPlaceholder, mapContainer) | ||
325 | focusArea.appendChild(mapContainer) | ||
326 | } | ||
327 | } | ||
328 | |||
329 | if (layout === 'overlay') { | ||
330 | markdown.blocks.forEach(block => { | ||
331 | block.draggableInstance = new PlainDraggable(block, { handle: block.querySelector('.draggable') }) | ||
332 | block.draggableInstance.snap = { x: { step: 20 }, y: { step: 20 } } | ||
333 | // block.draggableInstance.onDragEnd = () => { | ||
334 | // links(block).forEach(link => link.line.position()) | ||
335 | // } | ||
336 | }) | ||
337 | } else { | ||
338 | markdown.blocks.forEach(block => { | ||
339 | try { | ||
340 | block.style.transform = 'none' | ||
341 | block.draggableInstance.remove() | ||
342 | } catch (err) { | ||
343 | console.warn('Fail to remove draggable instance', err) | ||
344 | } | ||
345 | }) | ||
346 | } | ||
347 | }); | ||
348 | layoutObserver.observe(container, { | ||
349 | attributes: true, | ||
350 | attributeFilter: ["data-layout"], | ||
351 | attributeOldValue: true | ||
352 | }); | ||
353 | obs.push(layoutObserver) | ||
354 | //}}} | ||
355 | //}}} | ||
356 | return container | ||
357 | } | ||
diff --git a/src/editor.mjs b/src/editor.mjs new file mode 100644 index 0000000..8b0a6ac --- /dev/null +++ b/src/editor.mjs | |||
@@ -0,0 +1,400 @@ | |||
1 | import TinyMDE from 'tiny-markdown-editor' | ||
2 | import { markdown2HTML, generateMaps } from './dumbymap' | ||
3 | import { defaultAliasesForRenderer, parseConfigsFromText } from 'mapclay' | ||
4 | |||
5 | // Set up Editor {{{ | ||
6 | |||
7 | const HtmlContainer = document.querySelector(".result-html") | ||
8 | const mdeElement = document.querySelector("#tinymde") | ||
9 | |||
10 | // Add Editor | ||
11 | const defaultContent = '## Links\n\n- [Go to marker](geo:24,121?id=foo,leaflet&text=normal "Link Test")\n\n```map\nid: foo\nuse: maplibre\n```\n' | ||
12 | const lastContent = localStorage.getItem('editorContent') | ||
13 | const tinyEditor = new TinyMDE.Editor({ | ||
14 | element: 'tinymde', | ||
15 | content: lastContent ? lastContent : defaultContent | ||
16 | }); | ||
17 | mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false')) | ||
18 | |||
19 | // Add command bar for editor | ||
20 | // Use this command to render maps and geoLinks | ||
21 | const mapCommand = { | ||
22 | name: 'map', | ||
23 | title: 'Switch Map Generation', | ||
24 | innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🌏</div>`, | ||
25 | action: () => { | ||
26 | if (!HtmlContainer.querySelector('.map-container')) { | ||
27 | generateMaps(HtmlContainer) | ||
28 | document.activeElement.blur(); | ||
29 | } else { | ||
30 | markdown2HTML(HtmlContainer, tinyEditor.getContent()) | ||
31 | HtmlContainer.setAttribute('data-layout', 'none') | ||
32 | } | ||
33 | }, | ||
34 | hotkey: 'Ctrl-m' | ||
35 | } | ||
36 | const debugCommand = { | ||
37 | name: 'debug', | ||
38 | title: 'show debug message', | ||
39 | innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🤔</div>`, | ||
40 | action: () => { | ||
41 | // FIXME | ||
42 | alert(tinyEditor.getContent()) | ||
43 | }, | ||
44 | hotkey: 'Ctrl-i' | ||
45 | } | ||
46 | // Set up command bar | ||
47 | new TinyMDE.CommandBar({ | ||
48 | element: 'tinymde_commandbar', editor: tinyEditor, | ||
49 | commands: [mapCommand, debugCommand, '|', 'h1', 'h2', '|', 'insertLink', 'insertImage', '|', 'bold', 'italic', 'strikethrough', 'code', '|', 'ul', 'ol', '|', 'blockquote'] | ||
50 | }); | ||
51 | |||
52 | // Render HTML to result container | ||
53 | markdown2HTML(HtmlContainer, tinyEditor.getContent()) | ||
54 | |||
55 | // FIXME DEBUGONLY | ||
56 | // generateMaps(HtmlContainer) | ||
57 | // setTimeout(() => { | ||
58 | // HtmlContainer.setAttribute("data-layout", 'side') | ||
59 | // }, 500) | ||
60 | |||
61 | // }}} | ||
62 | // Event Listener: change {{{ | ||
63 | |||
64 | // Save editor content to local storage, set timeout for 3 seconds | ||
65 | let rejectLastSaving | ||
66 | const saveContent = (content) => { | ||
67 | new Promise((resolve, reject) => { | ||
68 | // If user is typing, the last change cancel previous ones | ||
69 | if (rejectLastSaving) rejectLastSaving(content.length) | ||
70 | rejectLastSaving = reject | ||
71 | |||
72 | setTimeout(() => { | ||
73 | localStorage.setItem('editorContent', content) | ||
74 | resolve('Content Saved') | ||
75 | }, 3000) | ||
76 | }).catch(() => null) | ||
77 | } | ||
78 | |||
79 | // Render HTML to result container and save current content | ||
80 | tinyEditor.addEventListener('change', e => { | ||
81 | markdown2HTML(HtmlContainer, e.content) | ||
82 | saveContent(e.content) | ||
83 | }); | ||
84 | // }}} | ||
85 | // Completion in Code Blok {{{ | ||
86 | // Elements about suggestions {{{ | ||
87 | const suggestionsEle = document.createElement('div') | ||
88 | suggestionsEle.classList.add('container__suggestions'); | ||
89 | mdeElement.appendChild(suggestionsEle) | ||
90 | |||
91 | const rendererOptions = {} | ||
92 | |||
93 | class Suggestion { | ||
94 | constructor({ text, replace }) { | ||
95 | this.text = text | ||
96 | this.replace = replace | ||
97 | } | ||
98 | } | ||
99 | |||
100 | // }}} | ||
101 | // {{{ Aliases for map options | ||
102 | const aliasesForMapOptions = {} | ||
103 | const defaultApply = '/default.yml' | ||
104 | fetch(defaultApply) | ||
105 | .then(res => res.text()) | ||
106 | .then(rawText => { | ||
107 | const config = parseConfigsFromText(rawText)?.at(0) | ||
108 | Object.assign(aliasesForMapOptions, config.aliases ?? {}) | ||
109 | }) | ||
110 | .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err)) | ||
111 | // }}} | ||
112 | // FUNCTION: Check cursor is inside map code block {{{ | ||
113 | const insideCodeblockForMap = (element) => { | ||
114 | const code = element.closest('.TMFencedCodeBacktick') | ||
115 | if (!code) return false | ||
116 | |||
117 | let ps = code.previousSibling | ||
118 | if (!ps) return false | ||
119 | |||
120 | // Look backward to find pattern of code block: /```map/ | ||
121 | while (!ps.classList.contains('TMCodeFenceBacktickOpen')) { | ||
122 | ps = ps.previousSibling | ||
123 | if (!ps) return false | ||
124 | if (ps.classList.contains('TMCodeFenceBacktickClose')) return false | ||
125 | } | ||
126 | |||
127 | return ps.querySelector('.TMInfoString')?.textContent === 'map' | ||
128 | } | ||
129 | // }}} | ||
130 | // FUNCTION: Get renderer by cursor position in code block {{{ | ||
131 | const getLineWithRenderer = (element) => { | ||
132 | const currentLine = element.closest('.TMFencedCodeBacktick') | ||
133 | if (!currentLine) return null | ||
134 | |||
135 | // Look backward/forward for pattern of used renderer: /use: .+/ | ||
136 | let ps = currentLine | ||
137 | do { | ||
138 | ps = ps.previousSibling | ||
139 | if (ps.textContent.match(/^use: /)) { | ||
140 | return ps | ||
141 | } else if (ps.textContent.match(/^---/)) { | ||
142 | // If yaml doc separator is found | ||
143 | break | ||
144 | } | ||
145 | } while (ps && ps.classList.contains('TMFencedCodeBacktick')) | ||
146 | |||
147 | let ns = currentLine | ||
148 | do { | ||
149 | ns = ns.nextSibling | ||
150 | if (ns.textContent.match(/^use: /)) { | ||
151 | return ns | ||
152 | } else if (ns.textContent.match(/^---/)) { | ||
153 | // If yaml doc separator is found | ||
154 | return null | ||
155 | } | ||
156 | } while (ns && ns.classList.contains('TMFencedCodeBacktick')) | ||
157 | |||
158 | return null | ||
159 | } | ||
160 | // }}} | ||
161 | // FUNCTION: Add suggestions for current selection {{{ | ||
162 | const getSuggestionsForOptions = (optionTyped, validOptions) => { | ||
163 | let suggestOptions = [] | ||
164 | |||
165 | const matchedOptions = validOptions | ||
166 | .filter(o => o.valueOf().toLowerCase().includes(optionTyped.toLowerCase())) | ||
167 | |||
168 | if (matchedOptions.length > 0) { | ||
169 | suggestOptions = matchedOptions | ||
170 | } else { | ||
171 | suggestOptions = validOptions | ||
172 | } | ||
173 | |||
174 | return suggestOptions | ||
175 | .map(o => new Suggestion({ | ||
176 | text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ''}">ⓘ</span>`, | ||
177 | replace: `${o.valueOf()}: `, | ||
178 | })) | ||
179 | } | ||
180 | |||
181 | const getSuggestionFromMapOption = (option) => { | ||
182 | if (!option.example) return null | ||
183 | |||
184 | const text = option.example_desc | ||
185 | ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>` | ||
186 | : `<span>${option.example}</span>` | ||
187 | |||
188 | return new Suggestion({ | ||
189 | text: text, | ||
190 | replace: `${option.valueOf()}: ${option.example ?? ""}`, | ||
191 | }) | ||
192 | } | ||
193 | |||
194 | const getSuggestionsFromAliases = (option) => Object.entries(aliasesForMapOptions[option.valueOf()] ?? {}) | ||
195 | ?.map(record => { | ||
196 | const [alias, value] = record | ||
197 | const valueString = JSON.stringify(value).replaceAll('"', '') | ||
198 | return new Suggestion({ | ||
199 | text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`, | ||
200 | replace: `${option.valueOf()}: ${valueString}`, | ||
201 | }) | ||
202 | }) | ||
203 | ?? [] | ||
204 | |||
205 | // Add HTML element for List of suggestions | ||
206 | const addSuggestions = (currentLine, selection) => { | ||
207 | const text = currentLine.textContent | ||
208 | const markInputIsInvalid = (ele) => (ele ?? currentLine).classList.add('invalid-input') | ||
209 | let suggestions = [] | ||
210 | |||
211 | // Check if "use: <renderer>" is set | ||
212 | const lineWithRenderer = getLineWithRenderer(currentLine) | ||
213 | const renderer = lineWithRenderer?.textContent.split(' ')[1] | ||
214 | if (renderer) { | ||
215 | |||
216 | // Do not check properties | ||
217 | if (text.startsWith(' ')) { | ||
218 | return | ||
219 | } | ||
220 | |||
221 | // If no valid options for current used renderer, go get it! | ||
222 | const validOptions = rendererOptions[renderer] | ||
223 | if (!validOptions) { | ||
224 | // Get list of valid options for current renderer | ||
225 | const rendererUrl = defaultAliasesForRenderer.use[renderer]?.value | ||
226 | import(rendererUrl) | ||
227 | .then(rendererModule => { | ||
228 | rendererOptions[renderer] = rendererModule.default.validOptions | ||
229 | }) | ||
230 | .catch(() => { | ||
231 | markInputIsInvalid(lineWithRenderer) | ||
232 | console.error('Fail to get valid options from renderer, URL is', rendererUrl) | ||
233 | }) | ||
234 | return | ||
235 | } | ||
236 | |||
237 | // If input is "key:value" (no space left after colon), then it is invalid | ||
238 | const isKeyFinished = text.includes(':'); | ||
239 | const isValidKeyValue = text.match(/^[^:]+:\s+/) | ||
240 | if (isKeyFinished && !isValidKeyValue) { | ||
241 | markInputIsInvalid() | ||
242 | return | ||
243 | } | ||
244 | |||
245 | // If user is typing option | ||
246 | const keyTyped = text.split(':')[0].trim() | ||
247 | if (!isKeyFinished) { | ||
248 | suggestions = getSuggestionsForOptions(keyTyped, validOptions) | ||
249 | markInputIsInvalid() | ||
250 | } | ||
251 | |||
252 | // If user is typing value | ||
253 | const matchedOption = validOptions.find(o => o.name === keyTyped) | ||
254 | if (isKeyFinished && !matchedOption) { | ||
255 | markInputIsInvalid() | ||
256 | } | ||
257 | |||
258 | if (isKeyFinished && matchedOption) { | ||
259 | const valueTyped = text.substring(text.indexOf(':') + 1).trim() | ||
260 | const isValidValue = matchedOption.isValid(valueTyped) | ||
261 | if (!valueTyped) { | ||
262 | suggestions = [ | ||
263 | getSuggestionFromMapOption(matchedOption), | ||
264 | ...getSuggestionsFromAliases(matchedOption) | ||
265 | ].filter(s => s instanceof Suggestion) | ||
266 | } | ||
267 | if (valueTyped && !isValidValue) { | ||
268 | markInputIsInvalid() | ||
269 | } | ||
270 | } | ||
271 | |||
272 | } else { | ||
273 | // Suggestion for "use" | ||
274 | const rendererSuggestions = Object.entries(defaultAliasesForRenderer.use) | ||
275 | .filter(([renderer,]) => { | ||
276 | const suggestion = `use: ${renderer}` | ||
277 | const suggetionNoSpace = suggestion.replace(' ', '') | ||
278 | const textNoSpace = text.replace(' ', '') | ||
279 | return suggestion !== text && | ||
280 | (suggetionNoSpace.includes(textNoSpace)) | ||
281 | }) | ||
282 | .map(([renderer, info]) => | ||
283 | new Suggestion({ | ||
284 | text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`, | ||
285 | replace: `use: ${renderer}`, | ||
286 | }) | ||
287 | ) | ||
288 | suggestions = rendererSuggestions.length > 0 ? rendererSuggestions : [] | ||
289 | } | ||
290 | |||
291 | if (suggestions.length === 0) { | ||
292 | suggestionsEle.style.display = 'none'; | ||
293 | return | ||
294 | } else { | ||
295 | suggestionsEle.style.display = 'block'; | ||
296 | } | ||
297 | |||
298 | suggestionsEle.innerHTML = '' | ||
299 | suggestions.forEach((suggestion) => { | ||
300 | const option = document.createElement('div'); | ||
301 | if (suggestion.text.startsWith('<')) { | ||
302 | option.innerHTML = suggestion.text; | ||
303 | } else { | ||
304 | option.innerText = suggestion.text; | ||
305 | } | ||
306 | option.classList.add('container__suggestion'); | ||
307 | option.onmouseover = () => { | ||
308 | Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) | ||
309 | option.classList.add('focus') | ||
310 | } | ||
311 | option.onmouseout = () => { | ||
312 | option.classList.remove('focus') | ||
313 | } | ||
314 | option.onclick = () => { | ||
315 | const newFocus = { ...selection.focus, col: 0 } | ||
316 | const newAnchor = { ...selection.anchor, col: text.length } | ||
317 | tinyEditor.paste(suggestion.replace, newFocus, newAnchor) | ||
318 | suggestionsEle.style.display = 'none'; | ||
319 | option.classList.remove('focus') | ||
320 | }; | ||
321 | suggestionsEle.appendChild(option); | ||
322 | }); | ||
323 | |||
324 | const rect = currentLine.getBoundingClientRect(); | ||
325 | suggestionsEle.style.top = `${rect.top + rect.height + 12}px`; | ||
326 | suggestionsEle.style.left = `${rect.right}px`; | ||
327 | suggestionsEle.style.maxWidth = `calc(${window.innerWidth}px - ${rect.right}px - 2rem)`; | ||
328 | } | ||
329 | // }}} | ||
330 | // Event: suggests for current selection {{{ | ||
331 | tinyEditor.addEventListener('selection', selection => { | ||
332 | // To trigger click event on suggestions list, don't set suggestion list invisible | ||
333 | if (suggestionsEle.querySelector('.container__suggestion.focus:hover') !== null) { | ||
334 | return | ||
335 | } else { | ||
336 | suggestionsEle.style.display = 'none'; | ||
337 | } | ||
338 | |||
339 | // Check selection is inside editor contents | ||
340 | const node = selection?.anchor?.node | ||
341 | if (!node) return | ||
342 | |||
343 | // Get HTML element for current selection | ||
344 | const element = node instanceof HTMLElement | ||
345 | ? node | ||
346 | : node.parentNode | ||
347 | element.setAttribute('spellcheck', 'false') | ||
348 | |||
349 | // Do not show suggestion by attribute | ||
350 | if (suggestionsEle.getAttribute('data-keep-close') === 'true') { | ||
351 | suggestionsEle.setAttribute('data-keep-close', 'false') | ||
352 | return | ||
353 | } | ||
354 | |||
355 | // Show suggestions for map code block | ||
356 | if (insideCodeblockForMap(element)) addSuggestions(element, selection) | ||
357 | }); | ||
358 | // }}} | ||
359 | // Key events for suggestions {{{ | ||
360 | mdeElement.addEventListener('keydown', (e) => { | ||
361 | // Only the following keys are used | ||
362 | const keyForSuggestions = ['Tab', 'Enter', 'Escape'].includes(e.key) | ||
363 | if (!keyForSuggestions || suggestionsEle.style.display === 'none') return; | ||
364 | |||
365 | // Directly add a newline when no suggestion is selected | ||
366 | const currentSuggestion = suggestionsEle.querySelector('.container__suggestion.focus') | ||
367 | if (!currentSuggestion && e.key === 'Enter') return | ||
368 | |||
369 | // Override default behavior | ||
370 | e.preventDefault(); | ||
371 | |||
372 | // Suggestion when pressing Tab or Shift + Tab | ||
373 | const nextSuggestion = currentSuggestion?.nextSibling ?? suggestionsEle.querySelector('.container__suggestion:first-child') | ||
374 | const previousSuggestion = currentSuggestion?.previousSibling ?? suggestionsEle.querySelector('.container__suggestion:last-child') | ||
375 | const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion | ||
376 | |||
377 | // Current editor selection state | ||
378 | const selection = tinyEditor.getSelection(true) | ||
379 | switch (e.key) { | ||
380 | case 'Tab': | ||
381 | Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) | ||
382 | focusSuggestion.classList.add('focus') | ||
383 | focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) | ||
384 | break; | ||
385 | case 'Enter': | ||
386 | currentSuggestion.onclick() | ||
387 | suggestionsEle.style.display = 'none'; | ||
388 | break; | ||
389 | case 'Escape': | ||
390 | suggestionsEle.style.display = 'none'; | ||
391 | // Prevent trigger selection event again | ||
392 | suggestionsEle.setAttribute('data-keep-close', 'true') | ||
393 | setTimeout(() => tinyEditor.setSelection(selection), 100) | ||
394 | break; | ||
395 | } | ||
396 | }); | ||
397 | // }}} | ||
398 | // }}} | ||
399 | |||
400 | // vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}} | ||