aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorHsieh Chin Fan <pham@topo.tw>2024-09-28 17:47:52 +0800
committerHsieh Chin Fan <pham@topo.tw>2024-09-28 17:47:52 +0800
commit933eba7dc3bdc979fefadd47388b20b8360e1d6b (patch)
tree9b88908d88a025aaaa1d70306afde8e49fedab27
parent5a64dfc8bb8169e72b7c1164f18c6912e9e3576e (diff)
style: prettier
-rw-r--r--package.json3
-rw-r--r--src/Layout.mjs240
-rw-r--r--src/MenuItem.mjs102
-rw-r--r--src/css/dumbymap.css83
-rw-r--r--src/css/index.css4
-rw-r--r--src/css/style.css36
-rw-r--r--src/dumbyUtils.mjs86
-rw-r--r--src/dumbymap.mjs591
-rw-r--r--src/editor.mjs655
-rw-r--r--src/utils.mjs52
10 files changed, 983 insertions, 869 deletions
diff --git a/package.json b/package.json
index e55d489..55a032b 100644
--- a/package.json
+++ b/package.json
@@ -63,5 +63,8 @@
63 "repository": { 63 "repository": {
64 "type": "git", 64 "type": "git",
65 "url": "https://github.com/outdoorsafetylab/dumbymap" 65 "url": "https://github.com/outdoorsafetylab/dumbymap"
66 },
67 "prettier": {
68 "arrowParens": "avoid"
66 } 69 }
67} 70}
diff --git a/src/Layout.mjs b/src/Layout.mjs
index abefd4d..9cf2ab9 100644
--- a/src/Layout.mjs
+++ b/src/Layout.mjs
@@ -1,208 +1,208 @@
1import PlainDraggable from 'plain-draggable' 1import PlainDraggable from "plain-draggable";
2import { onRemove, animateRectTransition } from './utils' 2import { onRemove, animateRectTransition } from "./utils";
3 3
4export class Layout { 4export class Layout {
5 constructor(options = {}) { 5 constructor(options = {}) {
6 if (!options.name) throw Error("Layout name is not given") 6 if (!options.name) throw Error("Layout name is not given");
7 this.name = options.name 7 this.name = options.name;
8 this.enterHandler = options.enterHandler 8 this.enterHandler = options.enterHandler;
9 this.leaveHandler = options.leaveHandler 9 this.leaveHandler = options.leaveHandler;
10 } 10 }
11 valueOf = () => this.name 11 valueOf = () => this.name;
12} 12}
13 13
14export class SideBySide extends Layout { 14export class SideBySide extends Layout {
15 name = "side-by-side" 15 name = "side-by-side";
16 16
17 enterHandler = ({ container, htmlHolder, showcase }) => { 17 enterHandler = ({ container, htmlHolder, showcase }) => {
18 const bar = document.createElement('div') 18 const bar = document.createElement("div");
19 bar.className = 'bar' 19 bar.className = "bar";
20 bar.innerHTML = '<div class="bar-handle"></div>' 20 bar.innerHTML = '<div class="bar-handle"></div>';
21 const handle = bar.querySelector('.bar-handle') 21 const handle = bar.querySelector(".bar-handle");
22 container.appendChild(bar) 22 container.appendChild(bar);
23 23
24 // Resize views by value 24 // Resize views by value
25 const resizeByLeft = (left) => { 25 const resizeByLeft = left => {
26 htmlHolder.style.width = (left) + "px" 26 htmlHolder.style.width = left + "px";
27 showcase.style.width = (parseFloat(getComputedStyle(container).width) - left) + "px" 27 showcase.style.width =
28 } 28 parseFloat(getComputedStyle(container).width) - left + "px";
29 };
29 30
30 const draggable = new PlainDraggable(bar, { 31 const draggable = new PlainDraggable(bar, {
31 handle: handle, 32 handle: handle,
32 containment: { left: '25%', top: 0, right: '75%', height: 0 }, 33 containment: { left: "25%", top: 0, right: "75%", height: 0 },
33 }) 34 });
34 draggable.draggableCursor = "grab" 35 draggable.draggableCursor = "grab";
35 36
36 draggable.onDrag = (pos) => { 37 draggable.onDrag = pos => {
37 handle.style.transform = 'unset' 38 handle.style.transform = "unset";
38 resizeByLeft(pos.left) 39 resizeByLeft(pos.left);
39 } 40 };
40 draggable.onDragEnd = (_) => { 41 draggable.onDragEnd = _ => {
41 handle.removeAttribute('style') 42 handle.removeAttribute("style");
42 } 43 };
43 44
44 onRemove(bar, () => draggable.remove()) 45 onRemove(bar, () => draggable.remove());
45 } 46 };
46 47
47 leaveHandler = ({ container }) => { 48 leaveHandler = ({ container }) => {
48 container.querySelector('.bar')?.remove() 49 container.querySelector(".bar")?.remove();
49 } 50 };
50} 51}
51 52
52export class Overlay extends Layout { 53export class Overlay extends Layout {
53 name = "overlay" 54 name = "overlay";
54 55
55 saveLeftTopAsData = (element) => { 56 saveLeftTopAsData = element => {
56 const { left, top } = element.getBoundingClientRect() 57 const { left, top } = element.getBoundingClientRect();
57 element.setAttribute('data-left', left) 58 element.setAttribute("data-left", left);
58 element.setAttribute('data-top', top) 59 element.setAttribute("data-top", top);
59 } 60 };
60 61
61 addDraggable = (element) => { 62 addDraggable = element => {
62 // Make sure current element always on top 63 // Make sure current element always on top
63 const siblings = Array.from(element.parentElement?.querySelectorAll(':scope > *') ?? []) 64 const siblings = Array.from(
64 let popTimer = null 65 element.parentElement?.querySelectorAll(":scope > *") ?? [],
66 );
67 let popTimer = null;
65 element.onmouseover = () => { 68 element.onmouseover = () => {
66 popTimer = setTimeout(() => { 69 popTimer = setTimeout(() => {
67 siblings.forEach(e => e.style.removeProperty('z-index')) 70 siblings.forEach(e => e.style.removeProperty("z-index"));
68 element.style.zIndex = '9001' 71 element.style.zIndex = "9001";
69 }, 200) 72 }, 200);
70 } 73 };
71 element.onmouseout = () => { 74 element.onmouseout = () => {
72 clearTimeout(popTimer) 75 clearTimeout(popTimer);
73 } 76 };
74 77
75 // Add draggable part 78 // Add draggable part
76 const draggablePart = document.createElement('div') 79 const draggablePart = document.createElement("div");
77 element.appendChild(draggablePart) 80 element.appendChild(draggablePart);
78 draggablePart.className = 'draggable-part' 81 draggablePart.className = "draggable-part";
79 draggablePart.innerHTML = '<div class="handle">\u2630</div>' 82 draggablePart.innerHTML = '<div class="handle">\u2630</div>';
80 83
81 // Add draggable instance 84 // Add draggable instance
82 const { left, top } = element.getBoundingClientRect() 85 const { left, top } = element.getBoundingClientRect();
83 const draggable = new PlainDraggable(element, { 86 const draggable = new PlainDraggable(element, {
84 top: top, 87 top: top,
85 left: left, 88 left: left,
86 handle: draggablePart, 89 handle: draggablePart,
87 snap: { x: { step: 20 }, y: { step: 20 } }, 90 snap: { x: { step: 20 }, y: { step: 20 } },
88 }) 91 });
89 92
90 // FIXME use pure CSS to hide utils 93 // FIXME use pure CSS to hide utils
91 const utils = element.querySelector('.utils') 94 const utils = element.querySelector(".utils");
92 draggable.onDragStart = () => { 95 draggable.onDragStart = () => {
93 utils.style.display = 'none' 96 utils.style.display = "none";
94 element.classList.add('drag') 97 element.classList.add("drag");
95 } 98 };
96 99
97 draggable.onDragEnd = () => { 100 draggable.onDragEnd = () => {
98 utils.style = '' 101 utils.style = "";
99 element.classList.remove('drag') 102 element.classList.remove("drag");
100 element.style.zIndex = '9000' 103 element.style.zIndex = "9000";
101 } 104 };
102 105
103 // Reposition draggable instance when resized 106 // Reposition draggable instance when resized
104 new ResizeObserver(() => { 107 new ResizeObserver(() => {
105 try { 108 try {
106 draggable.position(); 109 draggable.position();
107 } catch (_) { 110 } catch (_) {
108 null 111 null;
109 } 112 }
110 }).observe(element); 113 }).observe(element);
111 114
112 // Callback for remove 115 // Callback for remove
113 onRemove(element, () => { 116 onRemove(element, () => {
114 draggable.remove() 117 draggable.remove();
115 }) 118 });
116 } 119 };
117 120
118 enterHandler = ({ htmlHolder, blocks }) => { 121 enterHandler = ({ htmlHolder, blocks }) => {
119 // FIXME It is weird rect from this method and this scope are different... 122 // FIXME It is weird rect from this method and this scope are different...
120 blocks.forEach(this.saveLeftTopAsData) 123 blocks.forEach(this.saveLeftTopAsData);
121 124
122 // Create draggable blocks and set each position by previous one 125 // Create draggable blocks and set each position by previous one
123 let [left, top] = [20, 20] 126 let [left, top] = [20, 20];
124 blocks.forEach(block => { 127 blocks.forEach(block => {
125 const originLeft = Number(block.getAttribute('data-left')) 128 const originLeft = Number(block.getAttribute("data-left"));
126 const originTop = Number(block.getAttribute('data-top')) 129 const originTop = Number(block.getAttribute("data-top"));
127 130
128 // Create draggable block 131 // Create draggable block
129 const wrapper = document.createElement('div') 132 const wrapper = document.createElement("div");
130 wrapper.classList.add('draggable-block') 133 wrapper.classList.add("draggable-block");
131 wrapper.innerHTML = ` 134 wrapper.innerHTML = `
132 <div class="utils"> 135 <div class="utils">
133 <div id="close">\u274C</div> 136 <div id="close">\u274C</div>
134 <div id="plus-font-size" ">\u2795</div> 137 <div id="plus-font-size" ">\u2795</div>
135 <div id="minus-font-size">\u2796</div> 138 <div id="minus-font-size">\u2796</div>
136 </div> 139 </div>
137 ` 140 `;
138 wrapper.title = 'Middle-click to hide block' 141 wrapper.title = "Middle-click to hide block";
139 wrapper.onmouseup = (e) => { 142 wrapper.onmouseup = e => {
140 // Hide block with middle click 143 // Hide block with middle click
141 if (e.button === 1) { 144 if (e.button === 1) {
142 wrapper.classList.add('hide') 145 wrapper.classList.add("hide");
143 } 146 }
144 } 147 };
145 148
146 // Set DOMRect for wrapper 149 // Set DOMRect for wrapper
147 wrapper.appendChild(block) 150 wrapper.appendChild(block);
148 wrapper.style.left = left + "px" 151 wrapper.style.left = left + "px";
149 wrapper.style.top = top + "px" 152 wrapper.style.top = top + "px";
150 htmlHolder.appendChild(wrapper) 153 htmlHolder.appendChild(wrapper);
151 const { width } = wrapper.getBoundingClientRect() 154 const { width } = wrapper.getBoundingClientRect();
152 left += width + 30 155 left += width + 30;
153 if (left > window.innerWidth) { 156 if (left > window.innerWidth) {
154 top += 200 157 top += 200;
155 left = left % window.innerWidth 158 left = left % window.innerWidth;
156 } 159 }
157 160
158 // Animation for DOMRect 161 // Animation for DOMRect
159 animateRectTransition( 162 animateRectTransition(
160 wrapper, 163 wrapper,
161 { left: originLeft, top: originTop }, 164 { left: originLeft, top: originTop },
162 { resume: true, duration: 300 } 165 { resume: true, duration: 300 },
163 ) 166 ).finished.finally(() => this.addDraggable(wrapper));
164 .finished
165 .finally(() => this.addDraggable(wrapper))
166 167
167 // Trivial case: 168 // Trivial case:
168 // This hack make sure utils remains at the same place even when wrapper resized 169 // This hack make sure utils remains at the same place even when wrapper resized
169 // Prevent DOMRect changes when user clicking plus/minus button many times 170 // Prevent DOMRect changes when user clicking plus/minus button many times
170 const utils = wrapper.querySelector('.utils') 171 const utils = wrapper.querySelector(".utils");
171 utils.onmouseover = () => { 172 utils.onmouseover = () => {
172 const { left, top } = utils.getBoundingClientRect() 173 const { left, top } = utils.getBoundingClientRect();
173 utils.style.cssText = `visibility: visible; z-index: 9000; position: fixed; transition: unset; left: ${left}px; top: ${top}px;` 174 utils.style.cssText = `visibility: visible; z-index: 9000; position: fixed; transition: unset; left: ${left}px; top: ${top}px;`;
174 document.body.appendChild(utils) 175 document.body.appendChild(utils);
175 } 176 };
176 utils.onmouseout = () => { 177 utils.onmouseout = () => {
177 wrapper.appendChild(utils) 178 wrapper.appendChild(utils);
178 utils.removeAttribute('style') 179 utils.removeAttribute("style");
179 } 180 };
180 181
181 // Close button 182 // Close button
182 wrapper.querySelector('#close').onclick = () => { 183 wrapper.querySelector("#close").onclick = () => {
183 wrapper.classList.add('hide') 184 wrapper.classList.add("hide");
184 utils.removeAttribute('style') 185 utils.removeAttribute("style");
185 } 186 };
186 // Plus/Minus font-size of content 187 // Plus/Minus font-size of content
187 wrapper.querySelector('#plus-font-size').onclick = () => { 188 wrapper.querySelector("#plus-font-size").onclick = () => {
188 const fontSize = parseFloat(getComputedStyle(block).fontSize) / 16 189 const fontSize = parseFloat(getComputedStyle(block).fontSize) / 16;
189 block.style.fontSize = `${fontSize + 0.2}rem` 190 block.style.fontSize = `${fontSize + 0.2}rem`;
190 } 191 };
191 wrapper.querySelector('#minus-font-size').onclick = () => { 192 wrapper.querySelector("#minus-font-size").onclick = () => {
192 const fontSize = parseFloat(getComputedStyle(block).fontSize) / 16 193 const fontSize = parseFloat(getComputedStyle(block).fontSize) / 16;
193 block.style.fontSize = `${fontSize - 0.2}rem` 194 block.style.fontSize = `${fontSize - 0.2}rem`;
194 } 195 };
195 }) 196 });
196 } 197 };
197 198
198 leaveHandler = ({ htmlHolder, blocks }) => { 199 leaveHandler = ({ htmlHolder, blocks }) => {
199 const resumeFromDraggable = (block) => { 200 const resumeFromDraggable = block => {
200 const draggableContainer = block.closest('.draggable-block') 201 const draggableContainer = block.closest(".draggable-block");
201 if (!draggableContainer) return 202 if (!draggableContainer) return;
202 htmlHolder.appendChild(block) 203 htmlHolder.appendChild(block);
203 draggableContainer.remove() 204 draggableContainer.remove();
204 } 205 };
205 blocks.forEach(resumeFromDraggable) 206 blocks.forEach(resumeFromDraggable);
206 } 207 };
207} 208}
208
diff --git a/src/MenuItem.mjs b/src/MenuItem.mjs
index 85ab1a2..2f9aff4 100644
--- a/src/MenuItem.mjs
+++ b/src/MenuItem.mjs
@@ -1,97 +1,97 @@
1import { createGeoLink } from './dumbymap' 1import { createGeoLink } from "./dumbymap";
2 2
3export function nextMap() { 3export function nextMap() {
4 const element = document.createElement('div') 4 const element = document.createElement("div");
5 element.className = 'menu-item' 5 element.className = "menu-item";
6 element.innerHTML = 'Next Map <span class="info">(Tab)</span>' 6 element.innerHTML = 'Next Map <span class="info">(Tab)</span>';
7 element.onclick = () => this.utils.focusNextMap() 7 element.onclick = () => this.utils.focusNextMap();
8 8
9 return element 9 return element;
10} 10}
11 11
12export function nextBlock() { 12export function nextBlock() {
13 const element = document.createElement('div') 13 const element = document.createElement("div");
14 element.className = 'menu-item' 14 element.className = "menu-item";
15 element.innerHTML = 'Next Block <span class="info">(n)</span>' 15 element.innerHTML = 'Next Block <span class="info">(n)</span>';
16 element.onclick = () => this.utils.focusNextBlock() 16 element.onclick = () => this.utils.focusNextBlock();
17 17
18 return element 18 return element;
19} 19}
20 20
21export function nextLayout() { 21export function nextLayout() {
22 const element = document.createElement('div') 22 const element = document.createElement("div");
23 element.className = 'menu-item' 23 element.className = "menu-item";
24 element.innerHTML = 'Next Layout <span class="info">(x)</span>' 24 element.innerHTML = 'Next Layout <span class="info">(x)</span>';
25 element.onclick = () => this.utils.switchToNextLayout() 25 element.onclick = () => this.utils.switchToNextLayout();
26 26
27 return element 27 return element;
28} 28}
29 29
30export class GeoLink { 30export class GeoLink {
31
32 constructor({ range }) { 31 constructor({ range }) {
33 this.range = range 32 this.range = range;
34 } 33 }
35 34
36 createElement = () => { 35 createElement = () => {
37 const element = document.createElement('div') 36 const element = document.createElement("div");
38 element.className = 'menu-item' 37 element.className = "menu-item";
39 element.innerText = "Add GeoLink" 38 element.innerText = "Add GeoLink";
40 element.onclick = this.addGeoLinkbyRange 39 element.onclick = this.addGeoLinkbyRange;
41 40
42 return element 41 return element;
43 } 42 };
44 43
45 addGeoLinkbyRange = () => { 44 addGeoLinkbyRange = () => {
46 const range = this.range 45 const range = this.range;
47 const content = range.toString() 46 const content = range.toString();
48 // FIXME Apply geolink only on matching sub-range 47 // FIXME Apply geolink only on matching sub-range
49 const match = content.match(/(^\D*[\d.]+)\D+([\d.]+)\D*$/) 48 const match = content.match(/(^\D*[\d.]+)\D+([\d.]+)\D*$/);
50 if (!match) return false 49 if (!match) return false;
51 50
52 const [x, y] = match.slice(1) 51 const [x, y] = match.slice(1);
53 const anchor = document.createElement('a') 52 const anchor = document.createElement("a");
54 anchor.textContent = content 53 anchor.textContent = content;
55 // FIXME apply WGS84 54 // FIXME apply WGS84
56 anchor.href = `geo:${y},${x}?xy=${x},${y}` 55 anchor.href = `geo:${y},${x}?xy=${x},${y}`;
57 56
58 if (createGeoLink(anchor)) { 57 if (createGeoLink(anchor)) {
59 range.deleteContents() 58 range.deleteContents();
60 range.insertNode(anchor) 59 range.insertNode(anchor);
61 } 60 }
62 } 61 };
63} 62}
64export class Suggestion { 63export class Suggestion {
65 constructor({ text, replace }) { 64 constructor({ text, replace }) {
66 this.text = text 65 this.text = text;
67 this.replace = replace 66 this.replace = replace;
68 } 67 }
69 68
70 createElement(codemirror) { 69 createElement(codemirror) {
71 const option = document.createElement('div'); 70 const option = document.createElement("div");
72 if (this.text.startsWith('<')) { 71 if (this.text.startsWith("<")) {
73 option.innerHTML = this.text; 72 option.innerHTML = this.text;
74 } else { 73 } else {
75 option.innerText = this.text; 74 option.innerText = this.text;
76 } 75 }
77 option.classList.add('container__suggestion'); 76 option.classList.add("container__suggestion");
78 option.onmouseover = () => { 77 option.onmouseover = () => {
79 Array.from(option.parentElement?.children ?? []) 78 Array.from(option.parentElement?.children ?? []).forEach(s =>
80 .forEach(s => s.classList.remove('focus')) 79 s.classList.remove("focus"),
81 option.classList.add('focus') 80 );
82 } 81 option.classList.add("focus");
82 };
83 option.onmouseout = () => { 83 option.onmouseout = () => {
84 option.classList.remove('focus') 84 option.classList.remove("focus");
85 } 85 };
86 option.onclick = () => { 86 option.onclick = () => {
87 const anchor = codemirror.getCursor() 87 const anchor = codemirror.getCursor();
88 codemirror.setSelection(anchor, { ...anchor, ch: 0 }) 88 codemirror.setSelection(anchor, { ...anchor, ch: 0 });
89 codemirror.replaceSelection(this.replace) 89 codemirror.replaceSelection(this.replace);
90 codemirror.focus(); 90 codemirror.focus();
91 const newAnchor = { ...anchor, ch: this.replace.length } 91 const newAnchor = { ...anchor, ch: this.replace.length };
92 codemirror.setCursor(newAnchor); 92 codemirror.setCursor(newAnchor);
93 }; 93 };
94 94
95 return option 95 return option;
96 } 96 }
97} 97}
diff --git a/src/css/dumbymap.css b/src/css/dumbymap.css
index 5a67575..4826b5b 100644
--- a/src/css/dumbymap.css
+++ b/src/css/dumbymap.css
@@ -8,12 +8,18 @@ root {
8} 8}
9 9
10@keyframes fade-in { 10@keyframes fade-in {
11 from { opacity: 0; } 11 from {
12 to { opacity: 1; } 12 opacity: 0;
13 }
14 to {
15 opacity: 1;
16 }
13} 17}
14 18
15@keyframes fade-out { 19@keyframes fade-out {
16 from { opacity: 1; } 20 from {
21 opacity: 1;
22 }
17 23
18 to { 24 to {
19 visibility: hidden; 25 visibility: hidden;
@@ -22,13 +28,21 @@ root {
22} 28}
23 29
24@keyframes map-fade-in { 30@keyframes map-fade-in {
25 from { opacity: 0.3; } 31 from {
26 to { opacity: 1; } 32 opacity: 0.3;
33 }
34 to {
35 opacity: 1;
36 }
27} 37}
28 38
29@keyframes map-fade-out { 39@keyframes map-fade-out {
30 from { opacity: 1; } 40 from {
31 to { opacity: 0.3; } 41 opacity: 1;
42 }
43 to {
44 opacity: 0.3;
45 }
32} 46}
33 47
34.mapclay { 48.mapclay {
@@ -37,7 +51,7 @@ root {
37 51
38 &.focus::after { 52 &.focus::after {
39 content: "Map-ID: " attr(id); 53 content: "Map-ID: " attr(id);
40 padding: .3rem .5rem; 54 padding: 0.3rem 0.5rem;
41 55
42 position: absolute; 56 position: absolute;
43 left: 50%; 57 left: 50%;
@@ -56,7 +70,7 @@ root {
56 70
57 transform: translate(-50%, -50%); 71 transform: translate(-50%, -50%);
58 opacity: 0; 72 opacity: 0;
59 animation: 1.5s forwards fade-out cubic-bezier(.44,.18,.86,-0.21); 73 animation: 1.5s forwards fade-out cubic-bezier(0.44, 0.18, 0.86, -0.21);
60 } 74 }
61} 75}
62 76
@@ -89,7 +103,6 @@ root {
89} 103}
90 104
91*:has(> nav) { 105*:has(> nav) {
92
93 /* FIXME Hide nav at proper time */ 106 /* FIXME Hide nav at proper time */
94 display: none; 107 display: none;
95 padding: 0.5rem; 108 padding: 0.5rem;
@@ -131,11 +144,10 @@ root {
131 144
132 border-left: 0.5em solid #e0e0e0; 145 border-left: 0.5em solid #e0e0e0;
133 146
134 transition: border-color .5s linear; 147 transition: border-color 0.5s linear;
135 } 148 }
136 149
137 &.focus { 150 &.focus {
138
139 /* Dress up when block is focus */ 151 /* Dress up when block is focus */
140 152
141 /* background: var(--block-focus-color); */ 153 /* background: var(--block-focus-color); */
@@ -145,7 +157,7 @@ root {
145 157
146 &::after { 158 &::after {
147 content: counter(block) "/" attr(data-total); 159 content: counter(block) "/" attr(data-total);
148 padding: .3rem .5rem; 160 padding: 0.3rem 0.5rem;
149 161
150 position: absolute; 162 position: absolute;
151 left: 50%; 163 left: 50%;
@@ -163,7 +175,7 @@ root {
163 175
164 transform: translate(-50%, -50%); 176 transform: translate(-50%, -50%);
165 opacity: 0; 177 opacity: 0;
166 animation: 1.5s forwards fade-out cubic-bezier(.44,.18,.86,-0.21); 178 animation: 1.5s forwards fade-out cubic-bezier(0.44, 0.18, 0.86, -0.21);
167 } 179 }
168 } 180 }
169} 181}
@@ -194,7 +206,7 @@ root {
194 } 206 }
195 } 207 }
196 208
197 :has( > .mapclay, > [data-placeholder]) { 209 :has(> .mapclay, > [data-placeholder]) {
198 display: flex; 210 display: flex;
199 gap: 0.5em; 211 gap: 0.5em;
200 212
@@ -225,7 +237,7 @@ root {
225 .Showcase { 237 .Showcase {
226 display: none; 238 display: none;
227 overflow: visible; 239 overflow: visible;
228 width: 100%; 240 width: 100%;
229 height: 100%; 241 height: 100%;
230 242
231 position: relative; 243 position: relative;
@@ -246,7 +258,7 @@ root {
246 258
247 &::after { 259 &::after {
248 content: "Layout: " attr(data-layout); 260 content: "Layout: " attr(data-layout);
249 padding: .3rem .5rem; 261 padding: 0.3rem 0.5rem;
250 262
251 position: absolute; 263 position: absolute;
252 left: 50%; 264 left: 50%;
@@ -272,13 +284,13 @@ root {
272 max-width: 60em; 284 max-width: 60em;
273 285
274 &::after { 286 &::after {
275 animation: 1.5s forwards fade-out cubic-bezier(.44,.18,.86,-0.21); 287 animation: 1.5s forwards fade-out cubic-bezier(0.44, 0.18, 0.86, -0.21);
276 } 288 }
277} 289}
278 290
279.Dumby[data-layout="side-by-side"] { 291.Dumby[data-layout="side-by-side"] {
280 &::after { 292 &::after {
281 animation: 1.5s forwards fade-out cubic-bezier(.44,.18,.86,-0.21); 293 animation: 1.5s forwards fade-out cubic-bezier(0.44, 0.18, 0.86, -0.21);
282 } 294 }
283 295
284 .SemanticHtml, 296 .SemanticHtml,
@@ -336,13 +348,12 @@ root {
336 transform: unset; 348 transform: unset;
337 } 349 }
338 } 350 }
339
340 } 351 }
341} 352}
342 353
343.Dumby[data-layout="overlay"] { 354.Dumby[data-layout="overlay"] {
344 &::after { 355 &::after {
345 animation: 1.5s forwards fade-out cubic-bezier(.44,.18,.86,-0.21); 356 animation: 1.5s forwards fade-out cubic-bezier(0.44, 0.18, 0.86, -0.21);
346 } 357 }
347 358
348 .SemanticHtml, 359 .SemanticHtml,
@@ -384,7 +395,6 @@ root {
384 padding-inline: 1rem; 395 padding-inline: 1rem;
385 padding-block: 1rem; 396 padding-block: 1rem;
386 397
387
388 &:hover { 398 &:hover {
389 visibility: visible; 399 visibility: visible;
390 opacity: 1; 400 opacity: 1;
@@ -402,16 +412,16 @@ root {
402 } 412 }
403 413
404 #close { 414 #close {
405 cursor: pointer; 415 cursor: pointer;
406 } 416 }
407 417
408 #plus-font-size { 418 #plus-font-size {
409 cursor: zoom-in; 419 cursor: zoom-in;
410 } 420 }
411 421
412 #minus-font-size { 422 #minus-font-size {
413 cursor: zoom-out; 423 cursor: zoom-out;
414 } 424 }
415} 425}
416 426
417.draggable-block { 427.draggable-block {
@@ -431,7 +441,7 @@ root {
431 &:has(.dumby-block.focus) { 441 &:has(.dumby-block.focus) {
432 z-index: 9001; 442 z-index: 9001;
433 outline: solid #505050 1px; 443 outline: solid #505050 1px;
434 border-color: #505050 444 border-color: #505050;
435 } 445 }
436 446
437 &:has(pre:only-child [data-placeholder]:only-child) { 447 &:has(pre:only-child [data-placeholder]:only-child) {
@@ -440,7 +450,7 @@ root {
440 450
441 /* Fade out top of contents scrolling */ 451 /* Fade out top of contents scrolling */
442 &::before { 452 &::before {
443 content: ''; 453 content: "";
444 display: block; 454 display: block;
445 height: 1.5rem; 455 height: 1.5rem;
446 456
@@ -504,7 +514,7 @@ root {
504 border-top-right-radius: 0.3rem; 514 border-top-right-radius: 0.3rem;
505 } 515 }
506 516
507 .handle { 517 .handle {
508 font-size: 1.1rem; 518 font-size: 1.1rem;
509 text-align: center; 519 text-align: center;
510 520
@@ -525,7 +535,8 @@ root {
525 } 535 }
526 } 536 }
527 537
528 &.drag, &:has(.draggable-part:hover) { 538 &.drag,
539 &:has(.draggable-part:hover) {
529 .handle { 540 .handle {
530 background: #e1e1e1; 541 background: #e1e1e1;
531 542
@@ -534,7 +545,7 @@ root {
534 545
535 .utils { 546 .utils {
536 visibility: visible; 547 visibility: visible;
537 animation: fade-in .5s; 548 animation: fade-in 0.5s;
538 } 549 }
539 550
540 > *:not(.draggable-part, .utils) { 551 > *:not(.draggable-part, .utils) {
@@ -550,8 +561,8 @@ root {
550 561
551 [data-placeholder] { 562 [data-placeholder] {
552 height: 50px !important; 563 height: 50px !important;
553 scale: .9; 564 scale: 0.9;
554 transition-duration: .4s; 565 transition-duration: 0.4s;
555 transition-timing-function: ease-out; 566 transition-timing-function: ease-out;
556 } 567 }
557} 568}
diff --git a/src/css/index.css b/src/css/index.css
index 194ca9c..a17ecce 100644
--- a/src/css/index.css
+++ b/src/css/index.css
@@ -65,7 +65,7 @@ body {
65 padding-inline: 0; 65 padding-inline: 0;
66 66
67 font-family: monospace; 67 font-family: monospace;
68 font-size: 1.0rem; 68 font-size: 1rem;
69 69
70 &.focus { 70 &.focus {
71 border: var(--content-focus-border); 71 border: var(--content-focus-border);
@@ -158,7 +158,7 @@ body {
158 text-overflow: ellipsis; 158 text-overflow: ellipsis;
159 159
160 ::before { 160 ::before {
161 width: 2rem 161 width: 2rem;
162 } 162 }
163 } 163 }
164} 164}
diff --git a/src/css/style.css b/src/css/style.css
index 14a2e96..1d39cc0 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -1,5 +1,7 @@
1/* CSS Reset */ 1/* CSS Reset */
2*, *::before, *::after { 2*,
3*::before,
4*::after {
3 box-sizing: border-box; 5 box-sizing: border-box;
4} 6}
5 7
@@ -9,15 +11,28 @@
9 line-height: calc(1em + 0.5rem); 11 line-height: calc(1em + 0.5rem);
10} 12}
11 13
12img, picture, video, canvas, svg { 14img,
15picture,
16video,
17canvas,
18svg {
13 display: block; 19 display: block;
14} 20}
15 21
16input, button, textarea, select { 22input,
23button,
24textarea,
25select {
17 font: inherit; 26 font: inherit;
18} 27}
19 28
20p, h1, h2, h3, h4, h5, h6 { 29p,
30h1,
31h2,
32h3,
33h4,
34h5,
35h6 {
21 overflow-wrap: break-word; 36 overflow-wrap: break-word;
22 hyphens: auto; 37 hyphens: auto;
23} 38}
@@ -29,7 +44,12 @@ body {
29 line-height: 1.6; 44 line-height: 1.6;
30} 45}
31 46
32h1, h2, h3, h4, h5, h6 { 47h1,
48h2,
49h3,
50h4,
51h5,
52h6 {
33 font-weight: bold; 53 font-weight: bold;
34 margin-bottom: 0.5em; 54 margin-bottom: 0.5em;
35} 55}
@@ -102,7 +122,8 @@ pre:has(code) {
102 } 122 }
103} 123}
104 124
105ul, ol { 125ul,
126ol {
106 margin-top: 1em; 127 margin-top: 1em;
107 margin-bottom: 1em; 128 margin-bottom: 1em;
108 padding-left: 20px; 129 padding-left: 20px;
@@ -118,7 +139,8 @@ table {
118 width: 100%; 139 width: 100%;
119} 140}
120 141
121th, td { 142th,
143td {
122 padding: 8px; 144 padding: 8px;
123 145
124 border: 1px solid #ddd; 146 border: 1px solid #ddd;
diff --git a/src/dumbyUtils.mjs b/src/dumbyUtils.mjs
index c878e48..d30145c 100644
--- a/src/dumbyUtils.mjs
+++ b/src/dumbyUtils.mjs
@@ -1,58 +1,72 @@
1export function focusNextMap(reverse = false) { 1export function focusNextMap(reverse = false) {
2 const renderedList = Array.from(this.htmlHolder.querySelectorAll('[data-render=fulfilled]')) 2 const renderedList = Array.from(
3 const mapNum = renderedList.length 3 this.htmlHolder.querySelectorAll("[data-render=fulfilled]"),
4 if (mapNum === 0) return 4 );
5 const mapNum = renderedList.length;
6 if (mapNum === 0) return;
5 7
6 // Get current focused map element 8 // Get current focused map element
7 const currentFocus = this.container.querySelector('.mapclay.focus') 9 const currentFocus = this.container.querySelector(".mapclay.focus");
8 10
9 // Remove class name of focus for ALL candidates 11 // Remove class name of focus for ALL candidates
10 // This may trigger animation 12 // This may trigger animation
11 renderedList.forEach(ele => ele.classList.remove('focus')) 13 renderedList.forEach(ele => ele.classList.remove("focus"));
12 14
13 // Get next existing map element 15 // Get next existing map element
14 const padding = reverse ? -1 : 1 16 const padding = reverse ? -1 : 1;
15 let nextIndex = currentFocus ? renderedList.indexOf(currentFocus) + padding : 0 17 let nextIndex = currentFocus
16 nextIndex = (nextIndex + mapNum) % mapNum 18 ? renderedList.indexOf(currentFocus) + padding
17 const nextFocus = renderedList[nextIndex] 19 : 0;
18 nextFocus.classList.add("focus") 20 nextIndex = (nextIndex + mapNum) % mapNum;
21 const nextFocus = renderedList[nextIndex];
22 nextFocus.classList.add("focus");
19 23
20 return nextFocus 24 return nextFocus;
21} 25}
22 26
23export function focusDelay() { 27export function focusDelay() {
24 return window.getComputedStyle(this.showcase).display === 'none' ? 50 : 300 28 return window.getComputedStyle(this.showcase).display === "none" ? 50 : 300;
25} 29}
26 30
27export function switchToNextLayout(reverse = false) { 31export function switchToNextLayout(reverse = false) {
28 const layouts = this.layouts 32 const layouts = this.layouts;
29 const currentLayoutName = this.container.getAttribute('data-layout') 33 const currentLayoutName = this.container.getAttribute("data-layout");
30 const currentIndex = layouts.map(l => l.name).indexOf(currentLayoutName) 34 const currentIndex = layouts.map(l => l.name).indexOf(currentLayoutName);
31 const padding = reverse ? -1 : 1 35 const padding = reverse ? -1 : 1;
32 const nextIndex = currentIndex === -1 ? 0 : (currentIndex + padding + layouts.length) % layouts.length 36 const nextIndex =
33 const nextLayout = layouts[nextIndex] 37 currentIndex === -1
34 this.container.setAttribute("data-layout", nextLayout.name) 38 ? 0
39 : (currentIndex + padding + layouts.length) % layouts.length;
40 const nextLayout = layouts[nextIndex];
41 this.container.setAttribute("data-layout", nextLayout.name);
35} 42}
36 43
37export function focusNextBlock(reverse = false) { 44export function focusNextBlock(reverse = false) {
38 const blocks = this.blocks.filter(b => b.checkVisibility({ 45 const blocks = this.blocks.filter(b =>
39 contentVisibilityAuto: true, 46 b.checkVisibility({
40 opacityProperty: true, 47 contentVisibilityAuto: true,
41 visibilityProperty: true, 48 opacityProperty: true,
42 })) 49 visibilityProperty: true,
43 const currentBlock = blocks.find(b => b.classList.contains('focus')) 50 }),
44 const currentIndex = blocks.indexOf(currentBlock) 51 );
45 const padding = reverse ? -1 : 1 52 const currentBlock = blocks.find(b => b.classList.contains("focus"));
46 const nextIndex = currentIndex === -1 ? 0 : (currentIndex + padding + blocks.length) % blocks.length 53 const currentIndex = blocks.indexOf(currentBlock);
47 const nextBlock = blocks[nextIndex] 54 const padding = reverse ? -1 : 1;
48 blocks.forEach(b => b.classList.remove('focus')) 55 const nextIndex =
49 nextBlock?.classList?.add('focus') 56 currentIndex === -1
50 const scrollBlock = nextBlock.getBoundingClientRect().height > nextBlock.parentElement.getBoundingClientRect().height * 0.8 57 ? 0
51 ? 'nearest' 58 : (currentIndex + padding + blocks.length) % blocks.length;
52 : 'center' 59 const nextBlock = blocks[nextIndex];
53 nextBlock.scrollIntoView({ behavior: 'smooth', block: scrollBlock }) 60 blocks.forEach(b => b.classList.remove("focus"));
61 nextBlock?.classList?.add("focus");
62 const scrollBlock =
63 nextBlock.getBoundingClientRect().height >
64 nextBlock.parentElement.getBoundingClientRect().height * 0.8
65 ? "nearest"
66 : "center";
67 nextBlock.scrollIntoView({ behavior: "smooth", block: scrollBlock });
54} 68}
55 69
56export function removeBlockFocus() { 70export function removeBlockFocus() {
57 this.blocks.forEach(b => b.classList.remove('focus')) 71 this.blocks.forEach(b => b.classList.remove("focus"));
58} 72}
diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs
index 56375f7..cb029de 100644
--- a/src/dumbymap.mjs
+++ b/src/dumbymap.mjs
@@ -1,23 +1,23 @@
1import MarkdownIt from 'markdown-it' 1import MarkdownIt from "markdown-it";
2import MarkdownItAnchor from 'markdown-it-anchor' 2import MarkdownItAnchor from "markdown-it-anchor";
3import MarkdownItFootnote from 'markdown-it-footnote' 3import MarkdownItFootnote from "markdown-it-footnote";
4import MarkdownItFrontMatter from 'markdown-it-front-matter' 4import MarkdownItFrontMatter from "markdown-it-front-matter";
5import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' 5import MarkdownItTocDoneRight from "markdown-it-toc-done-right";
6import LeaderLine from 'leader-line' 6import LeaderLine from "leader-line";
7import { renderWith, defaultAliases, parseConfigsFromYaml } from 'mapclay' 7import { renderWith, defaultAliases, parseConfigsFromYaml } from "mapclay";
8import { onRemove, animateRectTransition, throttle } from './utils' 8import { onRemove, animateRectTransition, throttle } from "./utils";
9import { Layout, SideBySide, Overlay } from './Layout' 9import { Layout, SideBySide, Overlay } from "./Layout";
10import * as utils from './dumbyUtils' 10import * as utils from "./dumbyUtils";
11 11
12const docLinkSelector = 'a[href^="#"][title^="=>"]' 12const docLinkSelector = 'a[href^="#"][title^="=>"]';
13const geoLinkSelector = 'a[href^="geo:"]' 13const geoLinkSelector = 'a[href^="geo:"]';
14 14
15const layouts = [ 15const layouts = [
16 new Layout({ name: "normal" }), 16 new Layout({ name: "normal" }),
17 new SideBySide({ name: "side-by-side" }), 17 new SideBySide({ name: "side-by-side" }),
18 new Overlay({ name: "overlay" }), 18 new Overlay({ name: "overlay" }),
19] 19];
20const mapCache = {} 20const mapCache = {};
21 21
22// FUNCTION: Get DocLinks from special anchor element {{{ 22// FUNCTION: Get DocLinks from special anchor element {{{
23/** 23/**
@@ -25,34 +25,34 @@ const mapCache = {}
25 * 25 *
26 * @param {HTMLElement} Elements contains anchor elements for doclinks 26 * @param {HTMLElement} Elements contains anchor elements for doclinks
27 */ 27 */
28export const createDocLink = (link) => { 28export const createDocLink = link => {
29 link.classList.add('with-leader-line', 'doclink') 29 link.classList.add("with-leader-line", "doclink");
30 link.lines = [] 30 link.lines = [];
31 31
32 link.onmouseover = () => { 32 link.onmouseover = () => {
33 const label = decodeURIComponent(link.href.split('#')[1]) 33 const label = decodeURIComponent(link.href.split("#")[1]);
34 const selector = link.title.split('=>')[1] ?? '#' + label 34 const selector = link.title.split("=>")[1] ?? "#" + label;
35 const target = document.querySelector(selector) 35 const target = document.querySelector(selector);
36 if (!target?.checkVisibility()) return 36 if (!target?.checkVisibility()) return;
37 37
38 const line = new LeaderLine({ 38 const line = new LeaderLine({
39 start: link, 39 start: link,
40 end: target, 40 end: target,
41 middleLabel: LeaderLine.pathLabel({ 41 middleLabel: LeaderLine.pathLabel({
42 text: label, 42 text: label,
43 fontWeight: 'bold', 43 fontWeight: "bold",
44 }), 44 }),
45 hide: true, 45 hide: true,
46 path: "magnet" 46 path: "magnet",
47 }) 47 });
48 link.lines.push(line) 48 link.lines.push(line);
49 line.show('draw', { duration: 300, }) 49 line.show("draw", { duration: 300 });
50 } 50 };
51 link.onmouseout = () => { 51 link.onmouseout = () => {
52 link.lines.forEach(line => line.remove()) 52 link.lines.forEach(line => line.remove());
53 link.lines.length = 0 53 link.lines.length = 0;
54 } 54 };
55} 55};
56// }}} 56// }}}
57// FUNCTION: Get GeoLinks from special anchor element {{{ 57// FUNCTION: Get GeoLinks from special anchor element {{{
58/** 58/**
@@ -62,92 +62,98 @@ export const createDocLink = (link) => {
62 * @returns {Boolean} ture is link is created, false if coordinates are invalid 62 * @returns {Boolean} ture is link is created, false if coordinates are invalid
63 */ 63 */
64export const createGeoLink = (link, callback = null) => { 64export const createGeoLink = (link, callback = null) => {
65 const url = new URL(link.href) 65 const url = new URL(link.href);
66 const xyInParams = url.searchParams.get('xy') 66 const xyInParams = url.searchParams.get("xy");
67 const xy = xyInParams 67 const xy = xyInParams
68 ? xyInParams.split(',')?.map(Number) 68 ? xyInParams.split(",")?.map(Number)
69 : url?.href?.match(/^geo:([0-9.,]+)/)?.at(1)?.split(',')?.reverse()?.map(Number) 69 : url?.href
70 ?.match(/^geo:([0-9.,]+)/)
71 ?.at(1)
72 ?.split(",")
73 ?.reverse()
74 ?.map(Number);
70 75
71 if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false 76 if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false;
72 77
73 // Geo information in link 78 // Geo information in link
74 link.url = url 79 link.url = url;
75 link.xy = xy 80 link.xy = xy;
76 link.classList.add('with-leader-line', 'geolink') 81 link.classList.add("with-leader-line", "geolink");
77 link.targets = link.url.searchParams.get('id')?.split(',') ?? null 82 link.targets = link.url.searchParams.get("id")?.split(",") ?? null;
78 83
79 // LeaderLine 84 // LeaderLine
80 link.lines = [] 85 link.lines = [];
81 callback?.call(this, link) 86 callback?.call(this, link);
82 87
83 return true 88 return true;
84} 89};
85// }}} 90// }}}
86 91
87export const markdown2HTML = (container, mdContent) => { 92export const markdown2HTML = (container, mdContent) => {
88 // Render: Markdown -> HTML {{{ 93 // Render: Markdown -> HTML {{{
89 Array.from(container.children).map(e => e.remove()) 94 Array.from(container.children).map(e => e.remove());
90 95
91 96 container.innerHTML = '<div class="SemanticHtml"></div>';
92 container.innerHTML = '<div class="SemanticHtml"></div>' 97 const htmlHolder = container.querySelector(".SemanticHtml");
93 const htmlHolder = container.querySelector('.SemanticHtml')
94 98
95 const md = MarkdownIt({ 99 const md = MarkdownIt({
96 html: true, 100 html: true,
97 breaks: true, 101 breaks: true,
98 }) 102 })
99 .use(MarkdownItAnchor, { 103 .use(MarkdownItAnchor, {
100 permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: 'before' }) 104 permalink: MarkdownItAnchor.permalink.linkInsideHeader({
105 placement: "before",
106 }),
101 }) 107 })
102 .use(MarkdownItFootnote) 108 .use(MarkdownItFootnote)
103 .use(MarkdownItFrontMatter) 109 .use(MarkdownItFrontMatter)
104 .use(MarkdownItTocDoneRight) 110 .use(MarkdownItTocDoneRight);
105 111
106 // FIXME A better way to generate blocks 112 // FIXME A better way to generate blocks
107 md.renderer.rules.dumby_block_open = () => '<div>' 113 md.renderer.rules.dumby_block_open = () => "<div>";
108 md.renderer.rules.dumby_block_close = () => '</div>' 114 md.renderer.rules.dumby_block_close = () => "</div>";
109 115
110 md.core.ruler.before('block', 'dumby_block', (state) => { 116 md.core.ruler.before("block", "dumby_block", state => {
111 state.tokens.push(new state.Token('dumby_block_open', '', 1)) 117 state.tokens.push(new state.Token("dumby_block_open", "", 1));
112 }) 118 });
113 119
114 // Add close tag for block with more than 2 empty lines 120 // Add close tag for block with more than 2 empty lines
115 md.block.ruler.before('table', 'dumby_block', (state, startLine) => { 121 md.block.ruler.before("table", "dumby_block", (state, startLine) => {
116 if ( 122 if (
117 state.src[state.bMarks[startLine - 1]] === '\n' && 123 state.src[state.bMarks[startLine - 1]] === "\n" &&
118 state.src[state.bMarks[startLine - 2]] === '\n' && 124 state.src[state.bMarks[startLine - 2]] === "\n" &&
119 state.tokens.at(-1).type !== 'list_item_open' // Quick hack for not adding tag after "::marker" for <li> 125 state.tokens.at(-1).type !== "list_item_open" // Quick hack for not adding tag after "::marker" for <li>
120 ) { 126 ) {
121 state.push('dumby_block_close', '', -1); 127 state.push("dumby_block_close", "", -1);
122 state.push('dumby_block_open', '', 1); 128 state.push("dumby_block_open", "", 1);
123 } 129 }
124 }) 130 });
125 131
126 md.core.ruler.after('block', 'dumby_block', (state) => { 132 md.core.ruler.after("block", "dumby_block", state => {
127 state.tokens.push(new state.Token('dumby_block_close', '', -1)) 133 state.tokens.push(new state.Token("dumby_block_close", "", -1));
128 }) 134 });
129 135
130 const contentWithToc = '${toc}\n\n\n' + mdContent 136 const contentWithToc = "${toc}\n\n\n" + mdContent;
131 htmlHolder.innerHTML = md.render(contentWithToc); 137 htmlHolder.innerHTML = md.render(contentWithToc);
132 138
133 // TODO Do this in markdown-it 139 // TODO Do this in markdown-it
134 const blocks = htmlHolder.querySelectorAll(':scope > div:not(:has(nav))') 140 const blocks = htmlHolder.querySelectorAll(":scope > div:not(:has(nav))");
135 blocks.forEach(b => { 141 blocks.forEach(b => {
136 b.classList.add('dumby-block') 142 b.classList.add("dumby-block");
137 b.setAttribute('data-total', blocks.length) 143 b.setAttribute("data-total", blocks.length);
138 }) 144 });
139 145
140 return container 146 return container;
141 //}}} 147 //}}}
142} 148};
143export const generateMaps = (container, {delay, mapCallback}) => { 149export const generateMaps = (container, { delay, mapCallback }) => {
144 container.classList.add('Dumby') 150 container.classList.add("Dumby");
145 const htmlHolder = container.querySelector('.SemanticHtml') ?? container 151 const htmlHolder = container.querySelector(".SemanticHtml") ?? container;
146 const blocks = Array.from(htmlHolder.querySelectorAll('.dumby-block')) 152 const blocks = Array.from(htmlHolder.querySelectorAll(".dumby-block"));
147 const showcase = document.createElement('div') 153 const showcase = document.createElement("div");
148 container.appendChild(showcase) 154 container.appendChild(showcase);
149 showcase.classList.add('Showcase') 155 showcase.classList.add("Showcase");
150 const renderMaps = [] 156 const renderMaps = [];
151 157
152 const dumbymap = { 158 const dumbymap = {
153 layouts, 159 layouts,
@@ -161,310 +167,325 @@ export const generateMaps = (container, {delay, mapCallback}) => {
161 switchToNextLayout: throttle(utils.switchToNextLayout, 300), 167 switchToNextLayout: throttle(utils.switchToNextLayout, 300),
162 focusNextBlock: utils.focusNextBlock, 168 focusNextBlock: utils.focusNextBlock,
163 removeBlockFocus: utils.removeBlockFocus, 169 removeBlockFocus: utils.removeBlockFocus,
164 } 170 },
165 } 171 };
166 Object.entries(dumbymap.utils) 172 Object.entries(dumbymap.utils).forEach(([util, func]) => {
167 .forEach(([util, func]) => { 173 dumbymap.utils[util] = func.bind(dumbymap);
168 dumbymap.utils[util] = func.bind(dumbymap) 174 });
169 })
170 175
171 // LeaderLine {{{ 176 // LeaderLine {{{
172 177
173 Array.from(container.querySelectorAll(docLinkSelector)) 178 Array.from(container.querySelectorAll(docLinkSelector)).filter(createDocLink);
174 .filter(createDocLink)
175 179
176 // Get anchors with "geo:" scheme 180 // Get anchors with "geo:" scheme
177 htmlHolder.anchors = [] 181 htmlHolder.anchors = [];
178 const geoLinkCallback = (link) => { 182 const geoLinkCallback = link => {
179 link.onmouseover = () => addLeaderLines(link) 183 link.onmouseover = () => addLeaderLines(link);
180 link.onmouseout = () => removeLeaderLines(link) 184 link.onmouseout = () => removeLeaderLines(link);
181 link.onclick = (event) => { 185 link.onclick = event => {
182 event.preventDefault() 186 event.preventDefault();
183 htmlHolder.anchors 187 htmlHolder.anchors
184 .filter(isAnchorPointedBy(link)) 188 .filter(isAnchorPointedBy(link))
185 .forEach(updateMapByMarker(link.xy)) 189 .forEach(updateMapByMarker(link.xy));
186 // TODO Just hide leader line and show it again 190 // TODO Just hide leader line and show it again
187 removeLeaderLines(link) 191 removeLeaderLines(link);
188 } 192 };
189 } 193 };
190 const geoLinks = Array.from(container.querySelectorAll(geoLinkSelector)) 194 const geoLinks = Array.from(
191 .filter(l => createGeoLink(l, geoLinkCallback)) 195 container.querySelectorAll(geoLinkSelector),
192 196 ).filter(l => createGeoLink(l, geoLinkCallback));
193 const isAnchorPointedBy = (link) => (anchor) => { 197
194 const mapContainer = anchor.closest('.mapclay') 198 const isAnchorPointedBy = link => anchor => {
195 const isTarget = !link.targets || link.targets.includes(mapContainer.id) 199 const mapContainer = anchor.closest(".mapclay");
196 return anchor.title === link.url.pathname && isTarget 200 const isTarget = !link.targets || link.targets.includes(mapContainer.id);
197 } 201 return anchor.title === link.url.pathname && isTarget;
198 202 };
199 const isAnchorVisible = (anchor) => { 203
200 const mapContainer = anchor.closest('.mapclay') 204 const isAnchorVisible = anchor => {
201 return insideWindow(anchor) && insideParent(anchor, mapContainer) 205 const mapContainer = anchor.closest(".mapclay");
202 } 206 return insideWindow(anchor) && insideParent(anchor, mapContainer);
203 207 };
204 const drawLeaderLine = (link) => (anchor) => { 208
209 const drawLeaderLine = link => anchor => {
205 const line = new LeaderLine({ 210 const line = new LeaderLine({
206 start: link, 211 start: link,
207 end: anchor, 212 end: anchor,
208 hide: true, 213 hide: true,
209 middleLabel: link.url.searchParams.get('text'), 214 middleLabel: link.url.searchParams.get("text"),
210 path: "magnet", 215 path: "magnet",
211 }) 216 });
212 line.show('draw', { duration: 300, }) 217 line.show("draw", { duration: 300 });
213 return line 218 return line;
214 } 219 };
215 220
216 const addLeaderLines = (link) => { 221 const addLeaderLines = link => {
217 link.lines = htmlHolder.anchors 222 link.lines = htmlHolder.anchors
218 .filter(isAnchorPointedBy(link)) 223 .filter(isAnchorPointedBy(link))
219 .filter(isAnchorVisible) 224 .filter(isAnchorVisible)
220 .map(drawLeaderLine(link)) 225 .map(drawLeaderLine(link));
221 } 226 };
222 227
223 const removeLeaderLines = (link) => { 228 const removeLeaderLines = link => {
224 if (!link.lines) return 229 if (!link.lines) return;
225 link.lines.forEach(line => line.remove()) 230 link.lines.forEach(line => line.remove());
226 link.lines = [] 231 link.lines = [];
227 } 232 };
228 233
229 const updateMapByMarker = (xy) => (marker) => { 234 const updateMapByMarker = xy => marker => {
230 const renderer = marker.closest('.mapclay')?.renderer 235 const renderer = marker.closest(".mapclay")?.renderer;
231 renderer.updateCamera({ center: xy }, true) 236 renderer.updateCamera({ center: xy }, true);
232 } 237 };
233 238
234 const insideWindow = (element) => { 239 const insideWindow = element => {
235 const rect = element.getBoundingClientRect() 240 const rect = element.getBoundingClientRect();
236 return rect.left > 0 && 241 return (
242 rect.left > 0 &&
237 rect.right < window.innerWidth + rect.width && 243 rect.right < window.innerWidth + rect.width &&
238 rect.top > 0 && 244 rect.top > 0 &&
239 rect.bottom < window.innerHeight + rect.height 245 rect.bottom < window.innerHeight + rect.height
240 } 246 );
247 };
241 248
242 const insideParent = (childElement, parentElement) => { 249 const insideParent = (childElement, parentElement) => {
243 const childRect = childElement.getBoundingClientRect(); 250 const childRect = childElement.getBoundingClientRect();
244 const parentRect = parentElement.getBoundingClientRect(); 251 const parentRect = parentElement.getBoundingClientRect();
245 const offset = 20 252 const offset = 20;
246 253
247 return childRect.left > parentRect.left + offset && 254 return (
255 childRect.left > parentRect.left + offset &&
248 childRect.right < parentRect.right - offset && 256 childRect.right < parentRect.right - offset &&
249 childRect.top > parentRect.top + offset && 257 childRect.top > parentRect.top + offset &&
250 childRect.bottom < parentRect.bottom - offset 258 childRect.bottom < parentRect.bottom - offset
251 } 259 );
260 };
252 //}}} 261 //}}}
253 // Draggable Blocks {{{ 262 // Draggable Blocks {{{
254 // Add draggable part for blocks 263 // Add draggable part for blocks
255 264
256
257 // }}} 265 // }}}
258 // CSS observer {{{ 266 // CSS observer {{{
259 // Focus Map {{{ 267 // Focus Map {{{
260 // Set focusArea 268 // Set focusArea
261 269
262 const mapFocusObserver = () => new MutationObserver((mutations) => { 270 const mapFocusObserver = () =>
263 const mutation = mutations.at(-1) 271 new MutationObserver(mutations => {
264 const target = mutation.target 272 const mutation = mutations.at(-1);
265 const focus = target.getAttribute(mutation.attributeName).includes('focus') 273 const target = mutation.target;
266 const shouldBeInShowcase = focus && showcase.checkVisibility({ 274 const focus = target
267 contentVisibilityAuto: true, 275 .getAttribute(mutation.attributeName)
268 opacityProperty: true, 276 .includes("focus");
269 visibilityProperty: true, 277 const shouldBeInShowcase =
270 }) 278 focus &&
271 279 showcase.checkVisibility({
272 if (shouldBeInShowcase) { 280 contentVisibilityAuto: true,
273 if (showcase.contains(target)) return 281 opacityProperty: true,
274 282 visibilityProperty: true,
275 // Placeholder for map in Showcase, it should has the same DOMRect 283 });
276 const placeholder = target.cloneNode(true) 284
277 placeholder.removeAttribute('id') 285 if (shouldBeInShowcase) {
278 placeholder.classList.remove('mapclay', 'focus') 286 if (showcase.contains(target)) return;
279 target.parentElement.replaceChild(placeholder, target) 287
280 288 // Placeholder for map in Showcase, it should has the same DOMRect
281 // FIXME Maybe use @start-style for CSS 289 const placeholder = target.cloneNode(true);
282 // Trigger CSS transition, if placeholde is the olny chil element in block, 290 placeholder.removeAttribute("id");
283 // reduce its height to zero. 291 placeholder.classList.remove("mapclay", "focus");
284 // To make sure the original height of placeholder is applied, DOM changes seems needed 292 target.parentElement.replaceChild(placeholder, target);
285 // then set data-attribute for CSS selector to change height to 0 293
286 placeholder.getBoundingClientRect() 294 // FIXME Maybe use @start-style for CSS
287 placeholder.setAttribute('data-placeholder', target.id) 295 // Trigger CSS transition, if placeholde is the olny chil element in block,
288 296 // reduce its height to zero.
289 // To fit showcase, remove all inline style 297 // To make sure the original height of placeholder is applied, DOM changes seems needed
290 target.removeAttribute('style') 298 // then set data-attribute for CSS selector to change height to 0
291 showcase.appendChild(target) 299 placeholder.getBoundingClientRect();
292 300 placeholder.setAttribute("data-placeholder", target.id);
293 // Resume rect from Semantic HTML to Showcase, with animation 301
294 animateRectTransition(target, placeholder.getBoundingClientRect(), { 302 // To fit showcase, remove all inline style
295 duration: 300, 303 target.removeAttribute("style");
296 resume: true 304 showcase.appendChild(target);
297 }) 305
298 } else if (showcase.contains(target)) { 306 // Resume rect from Semantic HTML to Showcase, with animation
299 // Check placeholder is inside Semantic HTML 307 animateRectTransition(target, placeholder.getBoundingClientRect(), {
300 const placeholder = htmlHolder.querySelector(`[data-placeholder="${target.id}"]`) 308 duration: 300,
301 if (!placeholder) throw Error(`Cannot fine placeholder for map "${target.id}"`) 309 resume: true,
302 310 });
303 // Consider animation may fail, write callback 311 } else if (showcase.contains(target)) {
304 const afterAnimation = () => { 312 // Check placeholder is inside Semantic HTML
305 placeholder.parentElement.replaceChild(target, placeholder) 313 const placeholder = htmlHolder.querySelector(
306 target.style = placeholder.style.cssText 314 `[data-placeholder="${target.id}"]`,
307 placeholder.remove() 315 );
316 if (!placeholder)
317 throw Error(`Cannot fine placeholder for map "${target.id}"`);
318
319 // Consider animation may fail, write callback
320 const afterAnimation = () => {
321 placeholder.parentElement.replaceChild(target, placeholder);
322 target.style = placeholder.style.cssText;
323 placeholder.remove();
324 };
325
326 // animation from Showcase to placeholder
327 animateRectTransition(target, placeholder.getBoundingClientRect(), {
328 duration: 300,
329 }).finished.finally(afterAnimation);
308 } 330 }
309 331 });
310 // animation from Showcase to placeholder
311 animateRectTransition(target, placeholder.getBoundingClientRect(), { duration: 300 })
312 .finished
313 .finally(afterAnimation)
314 }
315 })
316 // }}} 332 // }}}
317 // Layout {{{ 333 // Layout {{{
318 // press key to switch layout 334 // press key to switch layout
319 const defaultLayout = layouts[0] 335 const defaultLayout = layouts[0];
320 container.setAttribute("data-layout", defaultLayout.name) 336 container.setAttribute("data-layout", defaultLayout.name);
321 337
322 // observe layout change 338 // observe layout change
323 const layoutObserver = new MutationObserver((mutations) => { 339 const layoutObserver = new MutationObserver(mutations => {
324 const mutation = mutations.at(-1) 340 const mutation = mutations.at(-1);
325 const oldLayout = mutation.oldValue 341 const oldLayout = mutation.oldValue;
326 const newLayout = container.getAttribute(mutation.attributeName) 342 const newLayout = container.getAttribute(mutation.attributeName);
327 343
328 // Apply handler for leaving/entering layouts 344 // Apply handler for leaving/entering layouts
329 if (oldLayout) { 345 if (oldLayout) {
330 layouts.find(l => l.name === oldLayout) 346 layouts
331 ?.leaveHandler 347 .find(l => l.name === oldLayout)
332 ?.call(this, dumbymap) 348 ?.leaveHandler?.call(this, dumbymap);
333 } 349 }
334 350
335 Object.values(dumbymap) 351 Object.values(dumbymap)
336 .flat() 352 .flat()
337 .filter(ele => ele instanceof HTMLElement) 353 .filter(ele => ele instanceof HTMLElement)
338 .forEach(ele => ele.removeAttribute('style')) 354 .forEach(ele => ele.removeAttribute("style"));
339 355
340 if (newLayout) { 356 if (newLayout) {
341 layouts.find(l => l.name === newLayout) 357 layouts
342 ?.enterHandler 358 .find(l => l.name === newLayout)
343 ?.call(this, dumbymap) 359 ?.enterHandler?.call(this, dumbymap);
344 } 360 }
345 361
346 // Since layout change may show/hide showcase, the current focused map should do something 362 // Since layout change may show/hide showcase, the current focused map should do something
347 // Reset attribute triggers MutationObserver which is observing it 363 // Reset attribute triggers MutationObserver which is observing it
348 const focusMap = container.querySelector('.mapclay.focus') 364 const focusMap =
349 ?? container.querySelector('.mapclay') 365 container.querySelector(".mapclay.focus") ??
350 focusMap?.classList?.add('focus') 366 container.querySelector(".mapclay");
367 focusMap?.classList?.add("focus");
351 }); 368 });
352 layoutObserver.observe(container, { 369 layoutObserver.observe(container, {
353 attributes: true, 370 attributes: true,
354 attributeFilter: ["data-layout"], 371 attributeFilter: ["data-layout"],
355 attributeOldValue: true, 372 attributeOldValue: true,
356 characterDataOldValue: true 373 characterDataOldValue: true,
357 }); 374 });
358 375
359 onRemove(htmlHolder, () => layoutObserver.disconnect()) 376 onRemove(htmlHolder, () => layoutObserver.disconnect());
360 //}}} 377 //}}}
361 //}}} 378 //}}}
362 // Render Maps {{{ 379 // Render Maps {{{
363 380
364 const afterMapRendered = (renderer) => { 381 const afterMapRendered = renderer => {
365 const mapElement = renderer.target 382 const mapElement = renderer.target;
366 mapElement.setAttribute('tabindex', "-1") 383 mapElement.setAttribute("tabindex", "-1");
367 if (mapElement.getAttribute('data-render') === 'fulfilled') { 384 if (mapElement.getAttribute("data-render") === "fulfilled") {
368 mapCache[mapElement.id] = renderer 385 mapCache[mapElement.id] = renderer;
369 } 386 }
370 387
371 // Execute callback from caller 388 // Execute callback from caller
372 mapCallback?.call(this, mapElement) 389 mapCallback?.call(this, mapElement);
373 const markers = geoLinks 390 const markers = geoLinks
374 .filter(link => !link.targets || link.targets.includes(mapElement.id)) 391 .filter(link => !link.targets || link.targets.includes(mapElement.id))
375 .map(link => ({ xy: link.xy, title: link.url.pathname })) 392 .map(link => ({ xy: link.xy, title: link.url.pathname }));
376 393
377 // Add markers with Geolinks 394 // Add markers with Geolinks
378 renderer.addMarkers(markers) 395 renderer.addMarkers(markers);
379 mapElement.querySelectorAll('.marker') 396 mapElement
380 .forEach(marker => htmlHolder.anchors.push(marker)) 397 .querySelectorAll(".marker")
398 .forEach(marker => htmlHolder.anchors.push(marker));
381 399
382 // Work with Mutation Observer 400 // Work with Mutation Observer
383 const observer = mapFocusObserver() 401 const observer = mapFocusObserver();
384 mapFocusObserver().observe(mapElement, { 402 mapFocusObserver().observe(mapElement, {
385 attributes: true, 403 attributes: true,
386 attributeFilter: ["class"], 404 attributeFilter: ["class"],
387 attributeOldValue: true 405 attributeOldValue: true,
388 }); 406 });
389 onRemove(mapElement, () => observer.disconnect()) 407 onRemove(mapElement, () => observer.disconnect());
390 } 408 };
391 409
392 // Set unique ID for map container 410 // Set unique ID for map container
393 const mapIdList = [] 411 const mapIdList = [];
394 const assignMapId = (config) => { 412 const assignMapId = config => {
395 let mapId = config.id 413 let mapId = config.id;
396 if (!mapId) { 414 if (!mapId) {
397 mapId = config.use?.split('/')?.at(-1) 415 mapId = config.use?.split("/")?.at(-1);
398 let counter = 1 416 let counter = 1;
399 while (!mapId || mapIdList.includes(mapId)) { 417 while (!mapId || mapIdList.includes(mapId)) {
400 mapId = `${config.use ?? "unnamed"}-${counter}` 418 mapId = `${config.use ?? "unnamed"}-${counter}`;
401 counter++ 419 counter++;
402 } 420 }
403 config.id = mapId 421 config.id = mapId;
404 } 422 }
405 mapIdList.push(mapId) 423 mapIdList.push(mapId);
406 return config 424 return config;
407 } 425 };
408 426
409 // Render each code block with "language-map" class 427 // Render each code block with "language-map" class
410 const elementsWithMapConfig = Array.from(container.querySelectorAll('pre:has(.language-map)') ?? []) 428 const elementsWithMapConfig = Array.from(
429 container.querySelectorAll("pre:has(.language-map)") ?? [],
430 );
411 // Add default aliases into each config 431 // Add default aliases into each config
412 const configConverter = (config => ({ 432 const configConverter = config => ({
413 use: config.use ?? 'Leaflet', 433 use: config.use ?? "Leaflet",
414 width: "100%", 434 width: "100%",
415 ...config, 435 ...config,
416 aliases: { 436 aliases: {
417 ...defaultAliases, 437 ...defaultAliases,
418 ...config.aliases ?? {} 438 ...(config.aliases ?? {}),
419 }, 439 },
420 })) 440 });
421 const render = renderWith(configConverter) 441 const render = renderWith(configConverter);
422 elementsWithMapConfig 442 elementsWithMapConfig.forEach(target => {
423 .forEach(target => { 443 // Get text in code block starts with markdown text '```map'
424 // Get text in code block starts with markdown text '```map' 444 const configText = target
425 const configText = target.querySelector('.language-map') 445 .querySelector(".language-map")
426 .textContent 446 .textContent // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content
427 // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content 447 // replace it by normal space
428 // replace it by normal space 448 .replace(/\u00A0/g, "\u0020");
429 .replace(/\u00A0/g, '\u0020') 449
430 450 let configList = [];
431 let configList = [] 451 try {
432 try { 452 configList = parseConfigsFromYaml(configText).map(assignMapId);
433 configList = parseConfigsFromYaml(configText).map(assignMapId) 453 } catch (_) {
434 } catch (_) { 454 console.warn("Fail to parse yaml config for element", target);
435 console.warn('Fail to parse yaml config for element', target) 455 return;
436 return 456 }
437 }
438 457
439 // If map in cache has the same ID, just put it into target 458 // If map in cache has the same ID, just put it into target
440 // So user won't feel anything changes when editing markdown 459 // So user won't feel anything changes when editing markdown
441 configList.forEach(config => { 460 configList.forEach(config => {
442 const cache = mapCache[config.id] 461 const cache = mapCache[config.id];
443 if (!cache) return; 462 if (!cache) return;
444 463
445 target.appendChild(cache.target) 464 target.appendChild(cache.target);
446 config.target = cache.target 465 config.target = cache.target;
447 }) 466 });
448 467
449 // trivial: if map cache is applied, do not show yaml text 468 // trivial: if map cache is applied, do not show yaml text
450 if (target.querySelector('.mapclay')) { 469 if (target.querySelector(".mapclay")) {
451 target.querySelectorAll(':scope > :not([data-render=fulfilled])') 470 target
452 .forEach(e => e.remove()) 471 .querySelectorAll(":scope > :not([data-render=fulfilled])")
453 } 472 .forEach(e => e.remove());
473 }
454 474
455 // Render maps with delay 475 // Render maps with delay
456 const timer = setTimeout(() => 476 const timer = setTimeout(
477 () =>
457 render(target, configList).forEach(renderMap => { 478 render(target, configList).forEach(renderMap => {
458 renderMaps.push(renderMap) 479 renderMaps.push(renderMap);
459 renderMap.then(afterMapRendered) 480 renderMap.then(afterMapRendered);
460 }), 481 }),
461 delay ?? 1000 482 delay ?? 1000,
462 ) 483 );
463 onRemove(htmlHolder, () => { 484 onRemove(htmlHolder, () => {
464 clearTimeout(timer) 485 clearTimeout(timer);
465 }) 486 });
466 }) 487 });
467 488
468 //}}} 489 //}}}
469 return Object.seal(dumbymap) 490 return Object.seal(dumbymap);
470} 491};
diff --git a/src/editor.mjs b/src/editor.mjs
index c04ba24..73177b6 100644
--- a/src/editor.mjs
+++ b/src/editor.mjs
@@ -1,143 +1,170 @@
1/*global EasyMDE*/ 1/*global EasyMDE*/
2/*eslint no-undef: "error"*/ 2/*eslint no-undef: "error"*/
3import { markdown2HTML, generateMaps } from './dumbymap' 3import { markdown2HTML, generateMaps } from "./dumbymap";
4import { defaultAliases, parseConfigsFromYaml } from 'mapclay' 4import { defaultAliases, parseConfigsFromYaml } from "mapclay";
5import * as menuItem from './MenuItem' 5import * as menuItem from "./MenuItem";
6 6
7// Set up Containers {{{ 7// Set up Containers {{{
8 8
9const HtmlContainer = document.querySelector(".DumbyMap") 9const HtmlContainer = document.querySelector(".DumbyMap");
10const textArea = document.querySelector(".editor textarea") 10const textArea = document.querySelector(".editor textarea");
11let dumbymap 11let dumbymap;
12 12
13const toggleEditing = () => { 13const toggleEditing = () => {
14 if (document.body.getAttribute("data-mode") === "editing") { 14 if (document.body.getAttribute("data-mode") === "editing") {
15 document.body.removeAttribute("data-mode") 15 document.body.removeAttribute("data-mode");
16 } else { 16 } else {
17 document.body.setAttribute("data-mode", "editing") 17 document.body.setAttribute("data-mode", "editing");
18 } 18 }
19 HtmlContainer.setAttribute("data-layout", "normal") 19 HtmlContainer.setAttribute("data-layout", "normal");
20} 20};
21// }}} 21// }}}
22// Set up EasyMDE {{{ 22// Set up EasyMDE {{{
23 23
24// Content values for editor 24// Content values for editor
25 25
26const 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' 26const defaultContent =
27 '## 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';
27const editor = new EasyMDE({ 28const editor = new EasyMDE({
28 element: textArea, 29 element: textArea,
29 initialValue: defaultContent, 30 initialValue: defaultContent,
30 autosave: { 31 autosave: {
31 enabled: true, 32 enabled: true,
32 uniqueId: 'dumbymap', 33 uniqueId: "dumbymap",
33 }, 34 },
34 indentWithTabs: false, 35 indentWithTabs: false,
35 lineNumbers: true, 36 lineNumbers: true,
36 promptURLs: true, 37 promptURLs: true,
37 uploadImage: true, 38 uploadImage: true,
38 spellChecker: false, 39 spellChecker: false,
39 toolbarButtonClassPrefix: 'mde', 40 toolbarButtonClassPrefix: "mde",
40 status: false, 41 status: false,
41 shortcuts: { 42 shortcuts: {
42 "map": "Ctrl-Alt-M", 43 map: "Ctrl-Alt-M",
43 "debug": "Ctrl-Alt-D", 44 debug: "Ctrl-Alt-D",
44 "toggleUnorderedList": null, 45 toggleUnorderedList: null,
45 "toggleOrderedList": null, 46 toggleOrderedList: null,
46 }, 47 },
47 toolbar: [ 48 toolbar: [
48 { 49 {
49 name: 'map', 50 name: "map",
50 title: 'Toggle Map Generation', 51 title: "Toggle Map Generation",
51 text: "🌏", 52 text: "🌏",
52 action: () => toggleEditing(), 53 action: () => toggleEditing(),
53 }, 54 },
54 { 55 {
55 name: 'debug', 56 name: "debug",
56 title: 'Save content as URL', 57 title: "Save content as URL",
57 text: "🤔", 58 text: "🤔",
58 action: () => { 59 action: () => {
59 const state = { content: editor.value() } 60 const state = { content: editor.value() };
60 window.location.hash = encodeURIComponent(JSON.stringify(state)) 61 window.location.hash = encodeURIComponent(JSON.stringify(state));
61 navigator.clipboard.writeText(window.location.href) 62 navigator.clipboard.writeText(window.location.href);
62 alert('URL copied to clipboard') 63 alert("URL copied to clipboard");
63 }, 64 },
64 }, 'undo', 'redo', '|', 'heading-1', 'heading-2', '|', 'link', 'image', '|', 'bold', 'italic', 'strikethrough', 'code', 'clean-block', '|', 'unordered-list', 'ordered-list', 'quote', 'table' 65 },
66 "undo",
67 "redo",
68 "|",
69 "heading-1",
70 "heading-2",
71 "|",
72 "link",
73 "image",
74 "|",
75 "bold",
76 "italic",
77 "strikethrough",
78 "code",
79 "clean-block",
80 "|",
81 "unordered-list",
82 "ordered-list",
83 "quote",
84 "table",
65 ], 85 ],
66}); 86});
67 87
68const cm = editor.codemirror 88const cm = editor.codemirror;
69 89
70const getStateFromHash = (hash) => { 90const getStateFromHash = hash => {
71 const hashValue = hash.substring(1); 91 const hashValue = hash.substring(1);
72 const stateString = decodeURIComponent(hashValue) 92 const stateString = decodeURIComponent(hashValue);
73 try { return JSON.parse(stateString) ?? {} } 93 try {
74 catch (_) { return {} } 94 return JSON.parse(stateString) ?? {};
75} 95 } catch (_) {
96 return {};
97 }
98};
76 99
77const getContentFromHash = (hash) => { 100const getContentFromHash = hash => {
78 const state = getStateFromHash(hash) 101 const state = getStateFromHash(hash);
79 return state.content 102 return state.content;
80} 103};
81 104
82const initialState = getStateFromHash(window.location.hash) 105const initialState = getStateFromHash(window.location.hash);
83window.location.hash = '' 106window.location.hash = "";
84const contentFromHash = initialState.content 107const contentFromHash = initialState.content;
85 108
86// Seems like autosave would overwrite initialValue, set content from hash here 109// Seems like autosave would overwrite initialValue, set content from hash here
87if (contentFromHash) { 110if (contentFromHash) {
88 editor.cleanup() 111 editor.cleanup();
89 editor.value(contentFromHash) 112 editor.value(contentFromHash);
90} 113}
91// }}} 114// }}}
92// Set up logic about editor content {{{ 115// Set up logic about editor content {{{
93const afterMapRendered = (_) => { 116const afterMapRendered = _ => {
94 // mapHolder.oncontextmenu = (event) => { 117 // mapHolder.oncontextmenu = (event) => {
95 // event.preventDefault() 118 // event.preventDefault()
96 // const lonLat = mapHolder.renderer.unproject([event.x, event.y]) 119 // const lonLat = mapHolder.renderer.unproject([event.x, event.y])
97 // // TODO... 120 // // TODO...
98 // } 121 // }
99} 122};
100markdown2HTML(HtmlContainer, editor.value()) 123markdown2HTML(HtmlContainer, editor.value());
101dumbymap = generateMaps(HtmlContainer, afterMapRendered) 124dumbymap = generateMaps(HtmlContainer, afterMapRendered);
102 125
103// Quick hack to style lines inside code block 126// Quick hack to style lines inside code block
104const addClassToCodeLines = () => { 127const addClassToCodeLines = () => {
105 const lines = cm.getLineHandle(0).parent.lines 128 const lines = cm.getLineHandle(0).parent.lines;
106 let insideCodeBlock = false 129 let insideCodeBlock = false;
107 lines.forEach((line, index) => { 130 lines.forEach((line, index) => {
108 if (line.text.match(/^[\u0060]{3}/)) { 131 if (line.text.match(/^[\u0060]{3}/)) {
109 insideCodeBlock = !insideCodeBlock 132 insideCodeBlock = !insideCodeBlock;
110 } else if (insideCodeBlock) { 133 } else if (insideCodeBlock) {
111 cm.addLineClass(index, "text", "inside-code-block") 134 cm.addLineClass(index, "text", "inside-code-block");
112 } else { 135 } else {
113 cm.removeLineClass(index, "text", "inside-code-block") 136 cm.removeLineClass(index, "text", "inside-code-block");
114 } 137 }
115 }) 138 });
116} 139};
117addClassToCodeLines() 140addClassToCodeLines();
118 141
119const completeForCodeBlock = (change) => { 142const completeForCodeBlock = change => {
120 const line = change.to.line 143 const line = change.to.line;
121 if (change.origin === "+input") { 144 if (change.origin === "+input") {
122 const text = change.text[0] 145 const text = change.text[0];
123 146
124 // Completion for YAML doc separator 147 // Completion for YAML doc separator
125 if (text === "-" && change.to.ch === 0 && insideCodeblockForMap(cm.getCursor())) { 148 if (
126 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 }) 149 text === "-" &&
127 cm.replaceSelection(text.repeat(3) + '\n') 150 change.to.ch === 0 &&
151 insideCodeblockForMap(cm.getCursor())
152 ) {
153 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 });
154 cm.replaceSelection(text.repeat(3) + "\n");
128 } 155 }
129 156
130 // Completion for Code fence 157 // Completion for Code fence
131 if (text === "`" && change.to.ch === 0) { 158 if (text === "`" && change.to.ch === 0) {
132 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 }) 159 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 1 });
133 cm.replaceSelection(text.repeat(3)) 160 cm.replaceSelection(text.repeat(3));
134 const numberOfFences = cm.getValue() 161 const numberOfFences = cm
135 .split('\n') 162 .getValue()
136 .filter(line => line.match(/[\u0060]{3}/)) 163 .split("\n")
137 .length 164 .filter(line => line.match(/[\u0060]{3}/)).length;
138 if (numberOfFences % 2 === 1) { 165 if (numberOfFences % 2 === 1) {
139 cm.replaceSelection('map\n\n```') 166 cm.replaceSelection("map\n\n```");
140 cm.setCursor({ line: line + 1 }) 167 cm.setCursor({ line: line + 1 });
141 } 168 }
142 } 169 }
143 } 170 }
@@ -145,63 +172,63 @@ const completeForCodeBlock = (change) => {
145 // For YAML doc separator, <hr> and code fence 172 // For YAML doc separator, <hr> and code fence
146 // Auto delete to start of line 173 // Auto delete to start of line
147 if (change.origin === "+delete") { 174 if (change.origin === "+delete") {
148 const match = change.removed[0].match(/^[-\u0060]$/)?.at(0) 175 const match = change.removed[0].match(/^[-\u0060]$/)?.at(0);
149 if (match && cm.getLine(line) === match.repeat(2) && match) { 176 if (match && cm.getLine(line) === match.repeat(2) && match) {
150 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 2 }) 177 cm.setSelection({ line: line, ch: 0 }, { line: line, ch: 2 });
151 cm.replaceSelection('') 178 cm.replaceSelection("");
152 } 179 }
153 } 180 }
154} 181};
155 182
156const debounceForMap = (() => { 183const debounceForMap = (() => {
157 let timer = null; 184 let timer = null;
158 185
159 return function(...args) { 186 return function (...args) {
160 dumbymap = generateMaps.apply(this, args) 187 dumbymap = generateMaps.apply(this, args);
161 // clearTimeout(timer); 188 // clearTimeout(timer);
162 // timer = setTimeout(() => { 189 // timer = setTimeout(() => {
163 // dumbymap = generateMaps.apply(this, args) 190 // dumbymap = generateMaps.apply(this, args)
164 // }, 10); 191 // }, 10);
165 } 192 };
166})() 193})();
167 194
168const updateDumbyMap = () => { 195const updateDumbyMap = () => {
169 markdown2HTML(HtmlContainer, editor.value()) 196 markdown2HTML(HtmlContainer, editor.value());
170 // TODO Test if generate maps intantly is OK with map cache 197 // TODO Test if generate maps intantly is OK with map cache
171 // debounceForMap(HtmlContainer, afterMapRendered) 198 // debounceForMap(HtmlContainer, afterMapRendered)
172 dumbymap = generateMaps(HtmlContainer, afterMapRendered) 199 dumbymap = generateMaps(HtmlContainer, afterMapRendered);
173} 200};
174 201
175updateDumbyMap() 202updateDumbyMap();
176 203
177// Re-render HTML by editor content 204// Re-render HTML by editor content
178cm.on("change", (_, change) => { 205cm.on("change", (_, change) => {
179 updateDumbyMap() 206 updateDumbyMap();
180 addClassToCodeLines() 207 addClassToCodeLines();
181 completeForCodeBlock(change) 208 completeForCodeBlock(change);
182}) 209});
183 210
184// Set class for focus 211// Set class for focus
185cm.on("focus", () => { 212cm.on("focus", () => {
186 cm.getWrapperElement().classList.add('focus') 213 cm.getWrapperElement().classList.add("focus");
187 HtmlContainer.classList.remove('focus') 214 HtmlContainer.classList.remove("focus");
188}) 215});
189 216
190cm.on("beforeChange", (_, change) => { 217cm.on("beforeChange", (_, change) => {
191 const line = change.to.line 218 const line = change.to.line;
192 // Don't allow more content after YAML doc separator 219 // Don't allow more content after YAML doc separator
193 if (change.origin.match(/^(\+input|paste)$/)) { 220 if (change.origin.match(/^(\+input|paste)$/)) {
194 if (cm.getLine(line) === "---" && change.text[0] !== "") { 221 if (cm.getLine(line) === "---" && change.text[0] !== "") {
195 change.cancel() 222 change.cancel();
196 } 223 }
197 } 224 }
198}) 225});
199 226
200// Reload editor content by hash value 227// Reload editor content by hash value
201window.onhashchange = () => { 228window.onhashchange = () => {
202 const content = getContentFromHash(window.location.hash) 229 const content = getContentFromHash(window.location.hash);
203 if (content) editor.value(content) 230 if (content) editor.value(content);
204} 231};
205 232
206// FIXME DEBUGONLY 233// FIXME DEBUGONLY
207// generateMaps(HtmlContainer) 234// generateMaps(HtmlContainer)
@@ -212,401 +239,415 @@ window.onhashchange = () => {
212// }}} 239// }}}
213// Completion in Code Blok {{{ 240// Completion in Code Blok {{{
214// Elements about suggestions {{{ 241// Elements about suggestions {{{
215const menu = document.createElement('div') 242const menu = document.createElement("div");
216menu.id = 'menu' 243menu.id = "menu";
217menu.onclick = () => menu.style.display = 'none' 244menu.onclick = () => (menu.style.display = "none");
218document.body.append(menu) 245document.body.append(menu);
219 246
220const rendererOptions = {} 247const rendererOptions = {};
221 248
222// }}} 249// }}}
223// Aliases for map options {{{ 250// Aliases for map options {{{
224const aliasesForMapOptions = {} 251const aliasesForMapOptions = {};
225const defaultApply = './dist/default.yml' 252const defaultApply = "./dist/default.yml";
226fetch(defaultApply) 253fetch(defaultApply)
227 .then(res => res.text()) 254 .then(res => res.text())
228 .then(rawText => { 255 .then(rawText => {
229 const config = parseConfigsFromYaml(rawText)?.at(0) 256 const config = parseConfigsFromYaml(rawText)?.at(0);
230 Object.assign(aliasesForMapOptions, config.aliases ?? {}) 257 Object.assign(aliasesForMapOptions, config.aliases ?? {});
231 }) 258 })
232 .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err)) 259 .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err));
233// }}} 260// }}}
234// FUNCTION: Check if current token is inside code block {{{ 261// FUNCTION: Check if current token is inside code block {{{
235const insideCodeblockForMap = (anchor) => { 262const insideCodeblockForMap = anchor => {
236 const token = cm.getTokenAt(anchor) 263 const token = cm.getTokenAt(anchor);
237 const insideCodeBlock = token.state.overlay.codeBlock && !cm.getLine(anchor.line).match(/^[\u0060]{3}/) 264 const insideCodeBlock =
238 if (!insideCodeBlock) return false 265 token.state.overlay.codeBlock &&
239 266 !cm.getLine(anchor.line).match(/^[\u0060]{3}/);
240 let line = anchor.line - 1 267 if (!insideCodeBlock) return false;
268
269 let line = anchor.line - 1;
241 while (line >= 0) { 270 while (line >= 0) {
242 const content = cm.getLine(line) 271 const content = cm.getLine(line);
243 if (content === '```map') { 272 if (content === "```map") {
244 return true 273 return true;
245 } else if (content === '```') { 274 } else if (content === "```") {
246 return false 275 return false;
247 } 276 }
248 line = line - 1 277 line = line - 1;
249 } 278 }
250 return false 279 return false;
251} 280};
252// }}} 281// }}}
253// FUNCTION: Get Renderer by cursor position in code block {{{ 282// FUNCTION: Get Renderer by cursor position in code block {{{
254const getLineWithRenderer = (anchor) => { 283const getLineWithRenderer = anchor => {
255 const currentLine = anchor.line 284 const currentLine = anchor.line;
256 if (!cm.getLine) return null 285 if (!cm.getLine) return null;
257 286
258 const match = (line) => cm.getLine(line).match(/^use: /) 287 const match = line => cm.getLine(line).match(/^use: /);
259
260 if (match(currentLine)) return currentLine
261 288
289 if (match(currentLine)) return currentLine;
262 290
263 // Look backward/forward for pattern of used renderer: /use: .+/ 291 // Look backward/forward for pattern of used renderer: /use: .+/
264 let pl = currentLine - 1 292 let pl = currentLine - 1;
265 while (pl > 0 && insideCodeblockForMap(anchor)) { 293 while (pl > 0 && insideCodeblockForMap(anchor)) {
266 const text = cm.getLine(pl) 294 const text = cm.getLine(pl);
267 if (match(pl)) { 295 if (match(pl)) {
268 return pl 296 return pl;
269 } else if (text.match(/^---|^[\u0060]{3}/)) { 297 } else if (text.match(/^---|^[\u0060]{3}/)) {
270 break 298 break;
271 } 299 }
272 pl = pl - 1 300 pl = pl - 1;
273 } 301 }
274 302
275 let nl = currentLine + 1 303 let nl = currentLine + 1;
276 while (insideCodeblockForMap(anchor)) { 304 while (insideCodeblockForMap(anchor)) {
277 const text = cm.getLine(nl) 305 const text = cm.getLine(nl);
278 if (match(nl)) { 306 if (match(nl)) {
279 return nl 307 return nl;
280 } else if (text.match(/^---|^[\u0060]{3}/)) { 308 } else if (text.match(/^---|^[\u0060]{3}/)) {
281 return null 309 return null;
282 } 310 }
283 nl = nl + 1 311 nl = nl + 1;
284 } 312 }
285 313
286 return null 314 return null;
287} 315};
288// }}} 316// }}}
289// FUNCTION: Return suggestions for valid options {{{ 317// FUNCTION: Return suggestions for valid options {{{
290const getSuggestionsForOptions = (optionTyped, validOptions) => { 318const getSuggestionsForOptions = (optionTyped, validOptions) => {
291 let suggestOptions = [] 319 let suggestOptions = [];
292 320
293 const matchedOptions = validOptions 321 const matchedOptions = validOptions.filter(o =>
294 .filter(o => o.valueOf().toLowerCase().includes(optionTyped.toLowerCase())) 322 o.valueOf().toLowerCase().includes(optionTyped.toLowerCase()),
323 );
295 324
296 if (matchedOptions.length > 0) { 325 if (matchedOptions.length > 0) {
297 suggestOptions = matchedOptions 326 suggestOptions = matchedOptions;
298 } else { 327 } else {
299 suggestOptions = validOptions 328 suggestOptions = validOptions;
300 } 329 }
301 330
302 return suggestOptions 331 return suggestOptions.map(
303 .map(o => new menuItem.Suggestion({ 332 o =>
304 text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ''}">ⓘ</span>`, 333 new menuItem.Suggestion({
305 replace: `${o.valueOf()}: `, 334 text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ""}">ⓘ</span>`,
306 })) 335 replace: `${o.valueOf()}: `,
307} 336 }),
337 );
338};
308// }}} 339// }}}
309// FUNCTION: Return suggestion for example of option value {{{ 340// FUNCTION: Return suggestion for example of option value {{{
310const getSuggestionFromMapOption = (option) => { 341const getSuggestionFromMapOption = option => {
311 if (!option.example) return null 342 if (!option.example) return null;
312 343
313 const text = option.example_desc 344 const text = option.example_desc
314 ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>` 345 ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>`
315 : `<span>${option.example}</span>` 346 : `<span>${option.example}</span>`;
316 347
317 return new menuItem.Suggestion({ 348 return new menuItem.Suggestion({
318 text: text, 349 text: text,
319 replace: `${option.valueOf()}: ${option.example ?? ""}`, 350 replace: `${option.valueOf()}: ${option.example ?? ""}`,
320 }) 351 });
321} 352};
322// }}} 353// }}}
323// FUNCTION: Return suggestions from aliases {{{ 354// FUNCTION: Return suggestions from aliases {{{
324const getSuggestionsFromAliases = (option) => Object.entries(aliasesForMapOptions[option.valueOf()] ?? {}) 355const getSuggestionsFromAliases = option =>
325 ?.map(record => { 356 Object.entries(aliasesForMapOptions[option.valueOf()] ?? {})?.map(record => {
326 const [alias, value] = record 357 const [alias, value] = record;
327 const valueString = JSON.stringify(value).replaceAll('"', '') 358 const valueString = JSON.stringify(value).replaceAll('"', "");
328 return new menuItem.Suggestion({ 359 return new menuItem.Suggestion({
329 text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`, 360 text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`,
330 replace: `${option.valueOf()}: ${valueString}`, 361 replace: `${option.valueOf()}: ${valueString}`,
331 }) 362 });
332 }) 363 }) ?? [];
333 ?? []
334// }}} 364// }}}
335// FUCNTION: Handler for map codeblock {{{ 365// FUCNTION: Handler for map codeblock {{{
336const handleTypingInCodeBlock = (anchor) => { 366const handleTypingInCodeBlock = anchor => {
337 const text = cm.getLine(anchor.line) 367 const text = cm.getLine(anchor.line);
338 if (text.match(/^\s\+$/) && text.length % 2 !== 0) { 368 if (text.match(/^\s\+$/) && text.length % 2 !== 0) {
339 // TODO Completion for even number of spaces 369 // TODO Completion for even number of spaces
340 } else if (text.match(/^-/)) { 370 } else if (text.match(/^-/)) {
341 // TODO Completion for YAML doc separator 371 // TODO Completion for YAML doc separator
342 } else { 372 } else {
343 const suggestions = getSuggestions(anchor) 373 const suggestions = getSuggestions(anchor);
344 addSuggestions(anchor, suggestions) 374 addSuggestions(anchor, suggestions);
345 } 375 }
346} 376};
347// }}} 377// }}}
348// FUNCTION: get suggestions by current input {{{ 378// FUNCTION: get suggestions by current input {{{
349const getSuggestions = (anchor) => { 379const getSuggestions = anchor => {
350 const text = cm.getLine(anchor.line) 380 const text = cm.getLine(anchor.line);
351 381
352 // Clear marks on text 382 // Clear marks on text
353 cm.findMarks( 383 cm.findMarks({ ...anchor, ch: 0 }, { ...anchor, ch: text.length }).forEach(
354 { ...anchor, ch: 0 }, 384 m => m.clear(),
355 { ...anchor, ch: text.length } 385 );
356 ).forEach(m => m.clear())
357 386
358 // Mark user input invalid by case 387 // Mark user input invalid by case
359 const markInputIsInvalid = () => 388 const markInputIsInvalid = () =>
360 cm.getDoc().markText( 389 cm
361 { ...anchor, ch: 0 }, 390 .getDoc()
362 { ...anchor, ch: text.length }, 391 .markText(
363 { className: 'invalid-input' }, 392 { ...anchor, ch: 0 },
364 ) 393 { ...anchor, ch: text.length },
394 { className: "invalid-input" },
395 );
365 396
366 // Check if "use: <renderer>" is set 397 // Check if "use: <renderer>" is set
367 const lineWithRenderer = getLineWithRenderer(anchor) 398 const lineWithRenderer = getLineWithRenderer(anchor);
368 const renderer = lineWithRenderer 399 const renderer = lineWithRenderer
369 ? cm.getLine(lineWithRenderer).split(' ')[1] 400 ? cm.getLine(lineWithRenderer).split(" ")[1]
370 : null 401 : null;
371 if (renderer && anchor.line !== lineWithRenderer) { 402 if (renderer && anchor.line !== lineWithRenderer) {
372 // Do not check properties 403 // Do not check properties
373 if (text.startsWith(' ')) return [] 404 if (text.startsWith(" ")) return [];
374 405
375 // If no valid options for current used renderer, go get it! 406 // If no valid options for current used renderer, go get it!
376 const validOptions = rendererOptions[renderer] 407 const validOptions = rendererOptions[renderer];
377 if (!validOptions) { 408 if (!validOptions) {
378 // Get list of valid options for current renderer 409 // Get list of valid options for current renderer
379 const rendererUrl = defaultAliases.use[renderer]?.value 410 const rendererUrl = defaultAliases.use[renderer]?.value;
380 import(rendererUrl) 411 import(rendererUrl)
381 .then(rendererModule => { 412 .then(rendererModule => {
382 rendererOptions[renderer] = rendererModule.default.validOptions 413 rendererOptions[renderer] = rendererModule.default.validOptions;
383 const currentAnchor = cm.getCursor() 414 const currentAnchor = cm.getCursor();
384 if (insideCodeblockForMap(currentAnchor)) { 415 if (insideCodeblockForMap(currentAnchor)) {
385 handleTypingInCodeBlock(currentAnchor) 416 handleTypingInCodeBlock(currentAnchor);
386 } 417 }
387 }) 418 })
388 .catch(_ => { 419 .catch(_ => {
389 markInputIsInvalid(lineWithRenderer) 420 markInputIsInvalid(lineWithRenderer);
390 console.warn(`Fail to get valid options from Renderer typed: ${renderer}`) 421 console.warn(
391 }) 422 `Fail to get valid options from Renderer typed: ${renderer}`,
392 return [] 423 );
424 });
425 return [];
393 } 426 }
394 427
395 // If input is "key:value" (no space left after colon), then it is invalid 428 // If input is "key:value" (no space left after colon), then it is invalid
396 const isKeyFinished = text.includes(':'); 429 const isKeyFinished = text.includes(":");
397 const isValidKeyValue = text.match(/^[^:]+:\s+/) 430 const isValidKeyValue = text.match(/^[^:]+:\s+/);
398 if (isKeyFinished && !isValidKeyValue) { 431 if (isKeyFinished && !isValidKeyValue) {
399 markInputIsInvalid() 432 markInputIsInvalid();
400 return [] 433 return [];
401 } 434 }
402 435
403 // If user is typing option 436 // If user is typing option
404 const keyTyped = text.split(':')[0].trim() 437 const keyTyped = text.split(":")[0].trim();
405 if (!isKeyFinished) { 438 if (!isKeyFinished) {
406 markInputIsInvalid() 439 markInputIsInvalid();
407 return getSuggestionsForOptions(keyTyped, validOptions) 440 return getSuggestionsForOptions(keyTyped, validOptions);
408 } 441 }
409 442
410 // If user is typing value 443 // If user is typing value
411 const matchedOption = validOptions.find(o => o.name === keyTyped) 444 const matchedOption = validOptions.find(o => o.name === keyTyped);
412 if (isKeyFinished && !matchedOption) { 445 if (isKeyFinished && !matchedOption) {
413 markInputIsInvalid() 446 markInputIsInvalid();
414 } 447 }
415 448
416 if (isKeyFinished && matchedOption) { 449 if (isKeyFinished && matchedOption) {
417 const valueTyped = text.substring(text.indexOf(':') + 1).trim() 450 const valueTyped = text.substring(text.indexOf(":") + 1).trim();
418 const isValidValue = matchedOption.isValid(valueTyped) 451 const isValidValue = matchedOption.isValid(valueTyped);
419 if (!valueTyped) { 452 if (!valueTyped) {
420 return [ 453 return [
421 getSuggestionFromMapOption(matchedOption), 454 getSuggestionFromMapOption(matchedOption),
422 ...getSuggestionsFromAliases(matchedOption) 455 ...getSuggestionsFromAliases(matchedOption),
423 ].filter(s => s instanceof menuItem.Suggestion) 456 ].filter(s => s instanceof menuItem.Suggestion);
424 } 457 }
425 if (valueTyped && !isValidValue) { 458 if (valueTyped && !isValidValue) {
426 markInputIsInvalid() 459 markInputIsInvalid();
427 return [] 460 return [];
428 } 461 }
429 } 462 }
430
431 } else { 463 } else {
432 // Suggestion for "use" 464 // Suggestion for "use"
433 const rendererSuggestions = Object.entries(defaultAliases.use) 465 const rendererSuggestions = Object.entries(defaultAliases.use)
434 .filter(([renderer,]) => { 466 .filter(([renderer]) => {
435 const suggestion = `use: ${renderer}` 467 const suggestion = `use: ${renderer}`;
436 const suggestionPattern = suggestion.replace(' ', '').toLowerCase() 468 const suggestionPattern = suggestion.replace(" ", "").toLowerCase();
437 const textPattern = text.replace(' ', '').toLowerCase() 469 const textPattern = text.replace(" ", "").toLowerCase();
438 return suggestion !== text && 470 return suggestion !== text && suggestionPattern.includes(textPattern);
439 (suggestionPattern.includes(textPattern))
440 }) 471 })
441 .map(([renderer, info]) => 472 .map(
442 new menuItem.Suggestion({ 473 ([renderer, info]) =>
443 text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`, 474 new menuItem.Suggestion({
444 replace: `use: ${renderer}`, 475 text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`,
445 }) 476 replace: `use: ${renderer}`,
446 ) 477 }),
447 return rendererSuggestions.length > 0 ? rendererSuggestions : [] 478 );
479 return rendererSuggestions.length > 0 ? rendererSuggestions : [];
448 } 480 }
449 return [] 481 return [];
450} 482};
451// }}} 483// }}}
452// {{{ FUNCTION: Show element about suggestions 484// {{{ FUNCTION: Show element about suggestions
453const addSuggestions = (anchor, suggestions) => { 485const addSuggestions = (anchor, suggestions) => {
454
455 if (suggestions.length === 0) { 486 if (suggestions.length === 0) {
456 menu.style.display = 'none'; 487 menu.style.display = "none";
457 return 488 return;
458 } else { 489 } else {
459 menu.style.display = 'block'; 490 menu.style.display = "block";
460 } 491 }
461 492
462 menu.innerHTML = '' 493 menu.innerHTML = "";
463 suggestions 494 suggestions
464 .map(s => s.createElement(cm)) 495 .map(s => s.createElement(cm))
465 .forEach(option => menu.appendChild(option)) 496 .forEach(option => menu.appendChild(option));
466 497
467 const widgetAnchor = document.createElement('div') 498 const widgetAnchor = document.createElement("div");
468 cm.addWidget(anchor, widgetAnchor, true) 499 cm.addWidget(anchor, widgetAnchor, true);
469 const rect = widgetAnchor.getBoundingClientRect() 500 const rect = widgetAnchor.getBoundingClientRect();
470 menu.style.left = `calc(${rect.left}px + 2rem)`; 501 menu.style.left = `calc(${rect.left}px + 2rem)`;
471 menu.style.top = `calc(${rect.bottom}px + 1rem)`; 502 menu.style.top = `calc(${rect.bottom}px + 1rem)`;
472 menu.style.maxWidth = `calc(${window.innerWidth}px - ${rect.x}px - 3rem)`; 503 menu.style.maxWidth = `calc(${window.innerWidth}px - ${rect.x}px - 3rem)`;
473 menu.style.display = 'block' 504 menu.style.display = "block";
474} 505};
475// }}} 506// }}}
476// EVENT: Suggests for current selection {{{ 507// EVENT: Suggests for current selection {{{
477// FIXME Dont show suggestion when selecting multiple chars 508// FIXME Dont show suggestion when selecting multiple chars
478cm.on("cursorActivity", (_) => { 509cm.on("cursorActivity", _ => {
479 menu.style.display = 'none' 510 menu.style.display = "none";
480 const anchor = cm.getCursor() 511 const anchor = cm.getCursor();
481 512
482 if (insideCodeblockForMap(anchor)) { 513 if (insideCodeblockForMap(anchor)) {
483 handleTypingInCodeBlock(anchor) 514 handleTypingInCodeBlock(anchor);
484 } 515 }
485}); 516});
486cm.on("blur", () => { 517cm.on("blur", () => {
487 menu.style.display = 'none' 518 menu.style.display = "none";
488 cm.getWrapperElement().classList.remove('focus') 519 cm.getWrapperElement().classList.remove("focus");
489 HtmlContainer.classList.add('focus') 520 HtmlContainer.classList.add("focus");
490}) 521});
491// }}} 522// }}}
492// EVENT: keydown for suggestions {{{ 523// EVENT: keydown for suggestions {{{
493const keyForSuggestions = ['Tab', 'Enter', 'Escape'] 524const keyForSuggestions = ["Tab", "Enter", "Escape"];
494cm.on('keydown', (_, e) => { 525cm.on("keydown", (_, e) => {
495 if (!cm.hasFocus || !keyForSuggestions.includes(e.key) || menu.style.display === 'none') return; 526 if (
527 !cm.hasFocus ||
528 !keyForSuggestions.includes(e.key) ||
529 menu.style.display === "none"
530 )
531 return;
496 532
497 // Directly add a newline when no suggestion is selected 533 // Directly add a newline when no suggestion is selected
498 const currentSuggestion = menu.querySelector('.container__suggestion.focus') 534 const currentSuggestion = menu.querySelector(".container__suggestion.focus");
499 if (!currentSuggestion && e.key === 'Enter') return 535 if (!currentSuggestion && e.key === "Enter") return;
500 536
501 // Override default behavior 537 // Override default behavior
502 e.preventDefault(); 538 e.preventDefault();
503 539
504 // Suggestion when pressing Tab or Shift + Tab 540 // Suggestion when pressing Tab or Shift + Tab
505 const nextSuggestion = currentSuggestion?.nextSibling ?? menu.querySelector('.container__suggestion:first-child') 541 const nextSuggestion =
506 const previousSuggestion = currentSuggestion?.previousSibling ?? menu.querySelector('.container__suggestion:last-child') 542 currentSuggestion?.nextSibling ??
507 const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion 543 menu.querySelector(".container__suggestion:first-child");
544 const previousSuggestion =
545 currentSuggestion?.previousSibling ??
546 menu.querySelector(".container__suggestion:last-child");
547 const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion;
508 548
509 // Current editor selection state 549 // Current editor selection state
510 const anchor = cm.getCursor() 550 const anchor = cm.getCursor();
511 switch (e.key) { 551 switch (e.key) {
512 case 'Tab': 552 case "Tab":
513 Array.from(menu.children).forEach(s => s.classList.remove('focus')) 553 Array.from(menu.children).forEach(s => s.classList.remove("focus"));
514 focusSuggestion.classList.add('focus') 554 focusSuggestion.classList.add("focus");
515 focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) 555 focusSuggestion.scrollIntoView({ behavior: "smooth", block: "nearest" });
516 break; 556 break;
517 case 'Enter': 557 case "Enter":
518 currentSuggestion.onclick() 558 currentSuggestion.onclick();
519 break; 559 break;
520 case 'Escape': 560 case "Escape":
521 menu.style.display = 'none'; 561 menu.style.display = "none";
522 // Focus editor again 562 // Focus editor again
523 setTimeout(() => cm.focus() && cm.setCursor(anchor), 100) 563 setTimeout(() => cm.focus() && cm.setCursor(anchor), 100);
524 break; 564 break;
525 } 565 }
526}); 566});
527 567
528document.onkeydown = (e) => { 568document.onkeydown = e => {
529 if (e.altKey && e.ctrlKey && e.key === 'm') { 569 if (e.altKey && e.ctrlKey && e.key === "m") {
530 toggleEditing() 570 toggleEditing();
531 e.preventDefault() 571 e.preventDefault();
532 return null 572 return null;
533 } 573 }
534 574
535 if (!cm.hasFocus()) { 575 if (!cm.hasFocus()) {
536 if (e.key === 'F1') { 576 if (e.key === "F1") {
537 e.preventDefault() 577 e.preventDefault();
538 cm.focus() 578 cm.focus();
539 } 579 }
540 if (e.key === 'Tab') { 580 if (e.key === "Tab") {
541 e.preventDefault() 581 e.preventDefault();
542 dumbymap.utils.focusNextMap(e.shiftKey) 582 dumbymap.utils.focusNextMap(e.shiftKey);
543 } 583 }
544 if (e.key === 'x' || e.key === 'X') { 584 if (e.key === "x" || e.key === "X") {
545 e.preventDefault() 585 e.preventDefault();
546 dumbymap.utils.switchToNextLayout(e.shiftKey) 586 dumbymap.utils.switchToNextLayout(e.shiftKey);
547 } 587 }
548 if (e.key === 'n') { 588 if (e.key === "n") {
549 e.preventDefault() 589 e.preventDefault();
550 dumbymap.utils.focusNextBlock() 590 dumbymap.utils.focusNextBlock();
551 } 591 }
552 if (e.key === 'p') { 592 if (e.key === "p") {
553 e.preventDefault() 593 e.preventDefault();
554 dumbymap.utils.focusNextBlock(true) 594 dumbymap.utils.focusNextBlock(true);
555 } 595 }
556 if (e.key === 'Escape') { 596 if (e.key === "Escape") {
557 e.preventDefault() 597 e.preventDefault();
558 dumbymap.utils.removeBlockFocus() 598 dumbymap.utils.removeBlockFocus();
559 } 599 }
560 } 600 }
561} 601};
562 602
563// }}} 603// }}}
564// }}} 604// }}}
565// Layout Switch {{{ 605// Layout Switch {{{
566const layoutObserver = new MutationObserver(() => { 606const layoutObserver = new MutationObserver(() => {
567 const layout = HtmlContainer.getAttribute('data-layout') 607 const layout = HtmlContainer.getAttribute("data-layout");
568 if (layout !== 'normal') { 608 if (layout !== "normal") {
569 document.body.removeAttribute('data-mode') 609 document.body.removeAttribute("data-mode");
570 } 610 }
571}) 611});
572 612
573layoutObserver.observe(HtmlContainer, { 613layoutObserver.observe(HtmlContainer, {
574 attributes: true, 614 attributes: true,
575 attributeFilter: ["data-layout"], 615 attributeFilter: ["data-layout"],
576 attributeOldValue: true 616 attributeOldValue: true,
577}); 617});
578// }}} 618// }}}
579// ContextMenu {{{ 619// ContextMenu {{{
580document.oncontextmenu = (e) => { 620document.oncontextmenu = e => {
581 if (cm.hasFocus()) return 621 if (cm.hasFocus()) return;
582 622
583 const selection = document.getSelection() 623 const selection = document.getSelection();
584 const range = selection.getRangeAt(0) 624 const range = selection.getRangeAt(0);
585 if (selection) { 625 if (selection) {
586 e.preventDefault() 626 e.preventDefault();
587 menu.innerHTML = '' 627 menu.innerHTML = "";
588 menu.style.cssText = `display: block; left: ${e.clientX + 10}px; top: ${e.clientY + 5}px;` 628 menu.style.cssText = `display: block; left: ${e.clientX + 10}px; top: ${e.clientY + 5}px;`;
589 const addGeoLink = new menuItem.GeoLink({ range }) 629 const addGeoLink = new menuItem.GeoLink({ range });
590 menu.appendChild(addGeoLink.createElement()) 630 menu.appendChild(addGeoLink.createElement());
591 } 631 }
592 menu.appendChild(menuItem.nextMap.bind(dumbymap)()) 632 menu.appendChild(menuItem.nextMap.bind(dumbymap)());
593 menu.appendChild(menuItem.nextBlock.bind(dumbymap)()) 633 menu.appendChild(menuItem.nextBlock.bind(dumbymap)());
594 menu.appendChild(menuItem.nextLayout.bind(dumbymap)()) 634 menu.appendChild(menuItem.nextLayout.bind(dumbymap)());
595} 635};
596 636
597const actionOutsideMenu = (e) => { 637const actionOutsideMenu = e => {
598 if (menu.style.display === 'none' || cm.hasFocus()) return 638 if (menu.style.display === "none" || cm.hasFocus()) return;
599 const rect = menu.getBoundingClientRect() 639 const rect = menu.getBoundingClientRect();
600 if (e.clientX < rect.left 640 if (
601 || e.clientX > rect.left + rect.width 641 e.clientX < rect.left ||
602 || e.clientY < rect.top 642 e.clientX > rect.left + rect.width ||
603 || e.clientY > rect.top + rect.height 643 e.clientY < rect.top ||
644 e.clientY > rect.top + rect.height
604 ) { 645 ) {
605 menu.style.display = 'none' 646 menu.style.display = "none";
606 } 647 }
607} 648};
608 649
609document.addEventListener('click', actionOutsideMenu) 650document.addEventListener("click", actionOutsideMenu);
610 651
611// }}} 652// }}}
612 653
diff --git a/src/utils.mjs b/src/utils.mjs
index ead3002..ad9131e 100644
--- a/src/utils.mjs
+++ b/src/utils.mjs
@@ -18,8 +18,8 @@ export const onRemove = (element, callback) => {
18 } 18 }
19 } 19 }
20 }); 20 });
21 obs.observe(parent, { childList: true, }); 21 obs.observe(parent, { childList: true });
22} 22};
23 23
24/** 24/**
25 * Animate transition of DOMRect, with Web Animation API 25 * Animate transition of DOMRect, with Web Animation API
@@ -32,18 +32,24 @@ export const onRemove = (element, callback) => {
32 * @returns {Animation} https://developer.mozilla.org/en-US/docs/Web/API/Animation 32 * @returns {Animation} https://developer.mozilla.org/en-US/docs/Web/API/Animation
33 */ 33 */
34export const animateRectTransition = (element, rect, options = {}) => { 34export const animateRectTransition = (element, rect, options = {}) => {
35 if (!element.parentElement) throw new Error("The node must already be attached"); 35 if (!element.parentElement)
36 throw new Error("The node must already be attached");
36 37
37 const { width: w1, height: h1, left: x1, top: y1 } = rect 38 const { width: w1, height: h1, left: x1, top: y1 } = rect;
38 const { width: w2, height: h2, left: x2, top: y2 } = element.getBoundingClientRect() 39 const {
40 width: w2,
41 height: h2,
42 left: x2,
43 top: y2,
44 } = element.getBoundingClientRect();
39 45
40 const rw = (w1 ?? w2) / w2 46 const rw = (w1 ?? w2) / w2;
41 const rh = (h1 ?? h2) / h2 47 const rh = (h1 ?? h2) / h2;
42 const dx = x1 - x2; 48 const dx = x1 - x2;
43 const dy = y1 - y2; 49 const dy = y1 - y2;
44 50
45 if (dx === 0 && dy === 0 || !isFinite(rw) || !isFinite(rh)) { 51 if ((dx === 0 && dy === 0) || !isFinite(rw) || !isFinite(rh)) {
46 return element.animate([], { duration: 0 }) 52 return element.animate([], { duration: 0 });
47 } 53 }
48 54
49 const transform1 = `translate(0, 0) scale(1, 1)`; 55 const transform1 = `translate(0, 0) scale(1, 1)`;
@@ -51,18 +57,14 @@ export const animateRectTransition = (element, rect, options = {}) => {
51 const keyframes = [ 57 const keyframes = [
52 { transform: transform1, opacity: 1 }, 58 { transform: transform1, opacity: 1 },
53 { transform: transform2, opacity: 0.3 }, 59 { transform: transform2, opacity: 0.3 },
54 ] 60 ];
55 if (options.resume === true) keyframes.reverse() 61 if (options.resume === true) keyframes.reverse();
56
57 return element.animate(
58 keyframes,
59 {
60 duration: options.duration ?? 500,
61 easing: 'ease-in-out',
62 }
63 );
64}
65 62
63 return element.animate(keyframes, {
64 duration: options.duration ?? 500,
65 easing: "ease-in-out",
66 });
67};
66 68
67/** 69/**
68 * Throttle for function call 70 * Throttle for function call
@@ -74,13 +76,13 @@ export const animateRectTransition = (element, rect, options = {}) => {
74export function throttle(func, delay) { 76export function throttle(func, delay) {
75 let timerFlag = null; 77 let timerFlag = null;
76 78
77 return function(...args) { 79 return function (...args) {
78 const context = this 80 const context = this;
79 if (timerFlag !== null) return null 81 if (timerFlag !== null) return null;
80 82
81 timerFlag = setTimeout( 83 timerFlag = setTimeout(
82 () => timerFlag = null, 84 () => (timerFlag = null),
83 typeof delay === 'function' ? delay.call(context) : delay 85 typeof delay === "function" ? delay.call(context) : delay,
84 ); 86 );
85 87
86 return func.call(context, ...args); 88 return func.call(context, ...args);