aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--default.yml57
-rw-r--r--eslint.config.js36
-rw-r--r--index.html31
-rw-r--r--package.json61
-rw-r--r--rollup.config.js55
l---------src/css/css1
-rw-r--r--src/css/dumbymap.css206
-rw-r--r--src/css/index.css80
-rw-r--r--src/css/style.css114
-rw-r--r--src/css/tiny-mde.min.css1
-rw-r--r--src/dumbymap.mjs357
-rw-r--r--src/editor.mjs400
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 @@
1dist/
2node_modules/
3package-lock.json
4pnpm-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 @@
1aliases:
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 @@
1import globals from "globals";
2import js from "@eslint/js";
3import importPlugin from "eslint-plugin-import";
4import promisePlugin from "eslint-plugin-promise";
5import nodePlugin from "eslint-plugin-node";
6
7export 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 @@
1import node from '@rollup/plugin-node-resolve';
2import commonjs from '@rollup/plugin-commonjs';
3import terser from '@rollup/plugin-terser';
4import { existsSync } from 'fs';
5import { join } from 'path';
6
7const production = !process.env.ROLLUP_WATCH;
8const 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
46export 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 @@
1body {
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
11img, picture, video, canvas, svg {
12 display: block;
13 max-width: 100%;
14}
15
16input, button, textarea, select {
17 font: inherit;
18}
19
20p, h1, h2, h3, h4, h5, h6 {
21 overflow-wrap: break-word;
22 hyphens: auto;
23}
24/* End of Reset */
25
26body {
27 font-family: sans-serif;
28 line-height: 1.6;
29}
30
31h1, h2, h3, h4, h5, h6 {
32 font-weight: bold;
33 margin-bottom: 0.5em;
34}
35
36h1 {
37 font-size: 2em;
38}
39
40h2 {
41 font-size: 1.5em;
42}
43
44h3 {
45 font-size: 1.25em;
46}
47
48p {
49 margin-top: 1em;
50 margin-bottom: 1em;
51}
52
53a {
54 color: #007bff;
55 text-decoration: none;
56}
57
58a:hover {
59 text-decoration: underline;
60}
61
62pre: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
86ul, ol {
87 margin-top: 1em;
88 margin-bottom: 1em;
89 padding-left: 20px;
90}
91
92li {
93 margin-bottom: 0.5em;
94}
95
96table {
97 border-collapse: collapse;
98 width: 100%;
99}
100
101th, td {
102 border: 1px solid #ddd;
103 padding: 8px;
104 text-align: left;
105}
106
107th {
108 background-color: #f0f0f0;
109}
110
111img {
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
2import MarkdownIt from 'markdown-it'
3import MarkdownItAnchor from 'markdown-it-anchor'
4import MarkdownItFootnote from 'markdown-it-footnote'
5import MarkdownItFrontMatter from 'markdown-it-front-matter'
6import MarkdownItTocDoneRight from 'markdown-it-toc-done-right'
7import LeaderLine from 'leader-line'
8import PlainDraggable from 'plain-draggable'
9import { render, parseConfigsFromText } from 'mapclay'
10
11const observers = new Map()
12
13export 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
83export 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 @@
1import TinyMDE from 'tiny-markdown-editor'
2import { markdown2HTML, generateMaps } from './dumbymap'
3import { defaultAliasesForRenderer, parseConfigsFromText } from 'mapclay'
4
5// Set up Editor {{{
6
7const HtmlContainer = document.querySelector(".result-html")
8const mdeElement = document.querySelector("#tinymde")
9
10// Add Editor
11const 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'
12const lastContent = localStorage.getItem('editorContent')
13const tinyEditor = new TinyMDE.Editor({
14 element: 'tinymde',
15 content: lastContent ? lastContent : defaultContent
16});
17mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false'))
18
19// Add command bar for editor
20// Use this command to render maps and geoLinks
21const 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}
36const 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
47new 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
53markdown2HTML(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
65let rejectLastSaving
66const 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
80tinyEditor.addEventListener('change', e => {
81 markdown2HTML(HtmlContainer, e.content)
82 saveContent(e.content)
83});
84// }}}
85// Completion in Code Blok {{{
86// Elements about suggestions {{{
87const suggestionsEle = document.createElement('div')
88suggestionsEle.classList.add('container__suggestions');
89mdeElement.appendChild(suggestionsEle)
90
91const rendererOptions = {}
92
93class Suggestion {
94 constructor({ text, replace }) {
95 this.text = text
96 this.replace = replace
97 }
98}
99
100// }}}
101// {{{ Aliases for map options
102const aliasesForMapOptions = {}
103const defaultApply = '/default.yml'
104fetch(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 {{{
113const 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 {{{
131const 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 {{{
162const 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
181const 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
194const 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
206const 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 {{{
331tinyEditor.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 {{{
360mdeElement.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={{{,}}}