aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorHsieh Chin Fan <pham@topo.tw>2024-08-13 23:58:38 +0800
committerHsieh Chin Fan <pham@topo.tw>2024-09-07 17:02:01 +0800
commit15a939d234910016d36d4297ec14de51c96168ce (patch)
tree4b3fe24f842485242949c19e3b2d0c078cae7797 /src
Initial Commit
Diffstat (limited to 'src')
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
7 files changed, 1159 insertions, 0 deletions
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={{{,}}}