diff options
Diffstat (limited to 'src')
l--------- | src/css/css | 1 | ||||
-rw-r--r-- | src/css/dumbymap.css | 206 | ||||
-rw-r--r-- | src/css/index.css | 80 | ||||
-rw-r--r-- | src/css/style.css | 114 | ||||
-rw-r--r-- | src/css/tiny-mde.min.css | 1 | ||||
-rw-r--r-- | src/dumbymap.mjs | 357 | ||||
-rw-r--r-- | src/editor.mjs | 400 |
7 files changed, 1159 insertions, 0 deletions
diff --git a/src/css/css b/src/css/css new file mode 120000 index 0000000..9913b8c --- /dev/null +++ b/src/css/css | |||
@@ -0,0 +1 @@ | |||
/home/pham/git/dumbymap/src/css \ No newline at end of file | |||
diff --git a/src/css/dumbymap.css b/src/css/dumbymap.css new file mode 100644 index 0000000..bfdcc2b --- /dev/null +++ b/src/css/dumbymap.css | |||
@@ -0,0 +1,206 @@ | |||
1 | [class^="leader-line"] { | ||
2 | z-index: 9999; | ||
3 | } | ||
4 | |||
5 | .with-leader-line:not(:has(> *)) { | ||
6 | display: inline-block; | ||
7 | left: 0px; | ||
8 | top: 0px; | ||
9 | |||
10 | padding-right: 15px; | ||
11 | padding-left: 6px; | ||
12 | |||
13 | background-image: url("data:image/svg+xml;charset=utf-8;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cG9seWdvbiBwb2ludHM9IjI0LDAgMCw4IDgsMTEgMCwxOSA1LDI0IDEzLDE2IDE2LDI0IiBmaWxsPSJjb3JhbCIvPjwvc3ZnPg=="); | ||
14 | background-size: 12px 12px; | ||
15 | background-repeat: no-repeat; | ||
16 | background-position: right 2px top 2px; | ||
17 | color: #555; | ||
18 | |||
19 | &.geolink { | ||
20 | background-color: rgb(248, 248, 129); | ||
21 | } | ||
22 | &.doclink { | ||
23 | background-color: #9ee7ea; | ||
24 | } | ||
25 | |||
26 | &:hover { | ||
27 | padding-right: 6px; | ||
28 | background-image: none; | ||
29 | |||
30 | font-weight: bolder; | ||
31 | text-decoration: none; | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .result-html { | ||
36 | position: relative; | ||
37 | max-width: 60em; | ||
38 | margin: 0 auto; | ||
39 | padding: 0; | ||
40 | display: flex; | ||
41 | overflow-x: scroll; | ||
42 | /* height: 100vh; */ | ||
43 | |||
44 | #map { | ||
45 | flex: 0; | ||
46 | #mapPlaceholder { | ||
47 | display: none; | ||
48 | } | ||
49 | } | ||
50 | |||
51 | #markdown { | ||
52 | flex: 1; | ||
53 | padding: 20px; | ||
54 | overflow-y: scroll; | ||
55 | |||
56 | > .draggable-block { | ||
57 | width: 100%; | ||
58 | position: relative; | ||
59 | pointer-events: auto; | ||
60 | border-radius: 0.5rem; | ||
61 | padding: 0.5rem; | ||
62 | background-color: white; | ||
63 | margin-bottom: 3rem; | ||
64 | |||
65 | * { | ||
66 | width: fit-content; | ||
67 | } | ||
68 | |||
69 | .draggable { | ||
70 | display: none; | ||
71 | } | ||
72 | } | ||
73 | |||
74 | pre:has(.map-container) { | ||
75 | display: flex; | ||
76 | justify-content: flex-start; | ||
77 | width: 100%; | ||
78 | background-color: inherit; | ||
79 | } | ||
80 | |||
81 | .map-container { | ||
82 | margin: 2px; | ||
83 | &.focus { | ||
84 | border: solid gray; | ||
85 | } | ||
86 | } | ||
87 | |||
88 | #mapPlaceholder { | ||
89 | flex: 1; | ||
90 | display: flex; | ||
91 | justify-content: center; | ||
92 | align-items: center; | ||
93 | background-color: lightgray; | ||
94 | > * { | ||
95 | width: fit-content; | ||
96 | flex: 0 0; | ||
97 | } | ||
98 | } | ||
99 | } | ||
100 | |||
101 | :has(> .map-container) { | ||
102 | display: flex; | ||
103 | width: fit-content; | ||
104 | } | ||
105 | |||
106 | #map .map-container { | ||
107 | width: 100% !important; | ||
108 | height: 100% !important; | ||
109 | } | ||
110 | } | ||
111 | |||
112 | .result-html[data-layout]:not([data-layout=none]) { | ||
113 | margin: 0; | ||
114 | |||
115 | height: 100vh; | ||
116 | width: 100%; | ||
117 | max-width: none; | ||
118 | border: none; | ||
119 | |||
120 | & ~ :not(.leader-line) { | ||
121 | display: none; | ||
122 | pointer-events: none; | ||
123 | } | ||
124 | } | ||
125 | |||
126 | .result-html[data-layout=side] { | ||
127 | #map, | ||
128 | #markdown { | ||
129 | flex: 50%; | ||
130 | overflow-y: scroll; | ||
131 | height: 100vh; | ||
132 | } | ||
133 | } | ||
134 | |||
135 | .result-html[data-layout=overlay] { | ||
136 | #map, | ||
137 | #markdown { | ||
138 | position: fixed; | ||
139 | height: 100%; | ||
140 | width: 100%; | ||
141 | } | ||
142 | |||
143 | #markdown { | ||
144 | font-size: 12px; | ||
145 | pointer-events: none; | ||
146 | |||
147 | > .draggable-block { | ||
148 | box-sizing: content-box; | ||
149 | width: fit-content; | ||
150 | max-height: 50vh; | ||
151 | overflow: scroll; | ||
152 | border: solid gray; | ||
153 | |||
154 | * { | ||
155 | max-width: calc(100vw/4); | ||
156 | } | ||
157 | |||
158 | .draggable { | ||
159 | width: 100%; | ||
160 | display: block; | ||
161 | top: 0; | ||
162 | left: 0; | ||
163 | position: sticky; | ||
164 | margin-bottom: -18px; | ||
165 | opacity: 0.3; | ||
166 | transform: translate(0, -16px); | ||
167 | text-align: center; | ||
168 | z-index: 100; | ||
169 | transition: all 0.3s ease-out; | ||
170 | &:hover { | ||
171 | background-color: lightgray; | ||
172 | } | ||
173 | } | ||
174 | } | ||
175 | > :not(.draggable-block) { | ||
176 | display: none; | ||
177 | } | ||
178 | } | ||
179 | } | ||
180 | |||
181 | *:has(> nav) { | ||
182 | position: absolute; | ||
183 | right: 10px; | ||
184 | bottom: 10px; | ||
185 | /* FIXME */ | ||
186 | display: none; | ||
187 | |||
188 | padding: 0.5rem; | ||
189 | min-width: 120px; | ||
190 | border: 2px solid gray; | ||
191 | border-radius: 0.5rem; | ||
192 | background-color: white; | ||
193 | z-index: 500; | ||
194 | |||
195 | &:has(> nav:empty) { | ||
196 | display: none; | ||
197 | } | ||
198 | ol { | ||
199 | margin-top: 0; | ||
200 | margin-bottom: 0.5rem; | ||
201 | } | ||
202 | } | ||
203 | |||
204 | .bold-options { | ||
205 | font-weight: bold; | ||
206 | } | ||
diff --git a/src/css/index.css b/src/css/index.css new file mode 100644 index 0000000..adc1cfe --- /dev/null +++ b/src/css/index.css | |||
@@ -0,0 +1,80 @@ | |||
1 | body { | ||
2 | display: flex; | ||
3 | justify-items: stretch; | ||
4 | } | ||
5 | |||
6 | .result-html { | ||
7 | flex: 1; | ||
8 | height: calc(100vh - 20px); | ||
9 | border: solid lightgray 2px; | ||
10 | border-radius: 5px; | ||
11 | margin: 10px; | ||
12 | } | ||
13 | |||
14 | .editor { | ||
15 | flex: 1; | ||
16 | max-width: 50vw; | ||
17 | margin: 10px; | ||
18 | } | ||
19 | |||
20 | .TinyMDE { | ||
21 | height: calc(100vh - 55px); | ||
22 | padding: 16px; | ||
23 | overflow-y: scroll; | ||
24 | border: 2px solid lightgray; | ||
25 | border-radius: 5px; | ||
26 | |||
27 | span { | ||
28 | white-space: pre; | ||
29 | } | ||
30 | .invalid-input { | ||
31 | text-decoration: red wavy underline 1px; | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .container__suggestions { | ||
36 | display: none; | ||
37 | position: absolute; | ||
38 | width: fit-content; | ||
39 | min-width: 10rem; | ||
40 | max-height: 40vh; | ||
41 | overflow-y: scroll; | ||
42 | border: 2px solid lightgray; | ||
43 | border-radius: 0.5rem; | ||
44 | |||
45 | background: #fff; | ||
46 | } | ||
47 | .container__suggestion { | ||
48 | cursor: pointer; | ||
49 | display: flex; | ||
50 | justify-content: space-between; | ||
51 | overflow: hidden; | ||
52 | |||
53 | height: fit-content; | ||
54 | min-height: 2rem; | ||
55 | white-space: nowrap; | ||
56 | align-items: center; | ||
57 | |||
58 | * { | ||
59 | flex-shrink: 0; | ||
60 | display: inline-block; | ||
61 | overflow: hidden; | ||
62 | padding-inline: 1em; | ||
63 | } | ||
64 | .info { | ||
65 | color: #4682B4; | ||
66 | font-weight: bold; | ||
67 | } | ||
68 | .truncate { | ||
69 | text-overflow: ellipsis; | ||
70 | ::before { | ||
71 | width: 2rem | ||
72 | } | ||
73 | } | ||
74 | } | ||
75 | .container__suggestion:not(:first-child) { | ||
76 | border-top: 1px solid rgb(203 213 225); | ||
77 | } | ||
78 | .container__suggestion.focus { | ||
79 | background: rgb(226 232 240); | ||
80 | } | ||
diff --git a/src/css/style.css b/src/css/style.css new file mode 100644 index 0000000..e650c1e --- /dev/null +++ b/src/css/style.css | |||
@@ -0,0 +1,114 @@ | |||
1 | /* CSS Reset */ | ||
2 | *, *::before, *::after { | ||
3 | box-sizing: border-box; | ||
4 | } | ||
5 | |||
6 | * { | ||
7 | margin: 0; | ||
8 | line-height: calc(1em + 0.5rem); | ||
9 | } | ||
10 | |||
11 | img, picture, video, canvas, svg { | ||
12 | display: block; | ||
13 | max-width: 100%; | ||
14 | } | ||
15 | |||
16 | input, button, textarea, select { | ||
17 | font: inherit; | ||
18 | } | ||
19 | |||
20 | p, h1, h2, h3, h4, h5, h6 { | ||
21 | overflow-wrap: break-word; | ||
22 | hyphens: auto; | ||
23 | } | ||
24 | /* End of Reset */ | ||
25 | |||
26 | body { | ||
27 | font-family: sans-serif; | ||
28 | line-height: 1.6; | ||
29 | } | ||
30 | |||
31 | h1, h2, h3, h4, h5, h6 { | ||
32 | font-weight: bold; | ||
33 | margin-bottom: 0.5em; | ||
34 | } | ||
35 | |||
36 | h1 { | ||
37 | font-size: 2em; | ||
38 | } | ||
39 | |||
40 | h2 { | ||
41 | font-size: 1.5em; | ||
42 | } | ||
43 | |||
44 | h3 { | ||
45 | font-size: 1.25em; | ||
46 | } | ||
47 | |||
48 | p { | ||
49 | margin-top: 1em; | ||
50 | margin-bottom: 1em; | ||
51 | } | ||
52 | |||
53 | a { | ||
54 | color: #007bff; | ||
55 | text-decoration: none; | ||
56 | } | ||
57 | |||
58 | a:hover { | ||
59 | text-decoration: underline; | ||
60 | } | ||
61 | |||
62 | pre:has([class^="language-"]) { | ||
63 | min-width: 400px; | ||
64 | display: block; | ||
65 | padding: 9.5px; | ||
66 | margin: 0 0 10px; | ||
67 | font-size: 13px; | ||
68 | line-height: 1.42857143; | ||
69 | color: #333; | ||
70 | word-break: break-all; | ||
71 | word-wrap: break-word; | ||
72 | background-color: #f5f5f5; | ||
73 | border: 1px solid #ccc; | ||
74 | border-radius: 4px; | ||
75 | |||
76 | code { | ||
77 | padding: 0; | ||
78 | font-size: inherit; | ||
79 | color: inherit; | ||
80 | white-space: pre-wrap; | ||
81 | background-color: transparent; | ||
82 | border-radius: 0; | ||
83 | } | ||
84 | } | ||
85 | |||
86 | ul, ol { | ||
87 | margin-top: 1em; | ||
88 | margin-bottom: 1em; | ||
89 | padding-left: 20px; | ||
90 | } | ||
91 | |||
92 | li { | ||
93 | margin-bottom: 0.5em; | ||
94 | } | ||
95 | |||
96 | table { | ||
97 | border-collapse: collapse; | ||
98 | width: 100%; | ||
99 | } | ||
100 | |||
101 | th, td { | ||
102 | border: 1px solid #ddd; | ||
103 | padding: 8px; | ||
104 | text-align: left; | ||
105 | } | ||
106 | |||
107 | th { | ||
108 | background-color: #f0f0f0; | ||
109 | } | ||
110 | |||
111 | img { | ||
112 | max-width: 100%; | ||
113 | height: auto; | ||
114 | } | ||
diff --git a/src/css/tiny-mde.min.css b/src/css/tiny-mde.min.css new file mode 100644 index 0000000..f317628 --- /dev/null +++ b/src/css/tiny-mde.min.css | |||
@@ -0,0 +1 @@ | |||
.TinyMDE{background-color:#fff;color:#000;font-size:16px;line-height:24px;outline:none;padding:5px}.TMBlankLine{height:24px}.TMH1,.TMSetextH1{font-size:22px;font-weight:700;line-height:32px;margin-bottom:8px}.TMSetextH1{margin-bottom:0}.TMSetextH1Marker{margin-bottom:8px}.TMH2,.TMSetextH2{font-size:20px;font-weight:700;line-height:28px;margin-bottom:4px}.TMMark_TMCode{font-family:monospace;font-size:.9em}.TMCode,.TMFencedCodeBacktick,.TMFencedCodeTilde,.TMIndentedCode{background-color:#e0e0e0;font-family:monospace;font-size:.9em}.TMCodeFenceBacktickOpen,.TMCodeFenceTildeOpen{border-bottom:1px solid silver;font-family:monospace;font-size:.9em}.TMCodeFenceBacktickClose,.TMCodeFenceTildeClose{border-top:1px solid silver;font-family:monospace;font-size:.9em}.TMInfoString{color:#00f}.TMCode{border:1px solid silver;border-radius:2px}.TMBlockquote{border-left:2px solid silver;font-style:italic;margin-left:10px;padding-left:10px}.TMMark{color:#a0a0a0}.TMMark_TMH1,.TMMark_TMH2,.TMMark_TMOL,.TMMark_TMUL{color:#ff8080}.TMImage{text-decoration:underline;text-decoration-color:#0f0}.TMLink{text-decoration:underline;text-decoration-color:#00f}.TMLinkLabel{font-family:monospace;text-decoration:underline}.TMLinkLabel_Definition,.TMLinkLabel_Valid{color:#40c040}.TMLinkLabel_Invalid{color:red}.TMLinkTitle{font-style:italic}.TMAutolink,.TMLinkDestination{color:#00f;text-decoration:underline}.TMHR{position:relative}.TMHR:before{border-bottom:2px solid grey;bottom:50%;content:"";left:40%;position:absolute;width:20%;z-index:0}.TMHTML,.TMHTMLBlock{color:#8000ff;font-family:monospace;font-size:.9em}.TMHTMLBlock{color:#6000c0}.TMCommandBar{-ms-overflow-style:none;background-color:#f8f8f8;border:4px solid #f8f8f8;box-sizing:content-box;display:flex;height:24px;overflow-x:scroll;overflow-y:hidden;scrollbar-width:none;-webkit-user-select:none;user-select:none}.TMCommandBar::-webkit-scrollbar{display:none}.TMCommandButton{fill:#404040;box-sizing:border-box;color:#404040;cursor:pointer;display:inline-block;font-family:sans-serif;font-size:20px;height:24px;line-height:18px;margin-right:4px;padding:3px;text-align:center;vertical-align:middle;width:24px}.TMCommandDivider{border-left:1px solid silver;border-right:1px solid #fff;box-sizing:content-box;height:24px;margin-left:4px;margin-right:8px;width:0}.TMCommandButton_Active{fill:navy;background-color:#c0c0ff;color:navy;font-weight:700}.TMCommandButton_Inactive{background-color:#f8f8f8}.TMCommandButton_Disabled{fill:#a0a0a0;color:#a0a0a0}@media (hover:hover){.TMCommandButton_Active:hover,.TMCommandButton_Disabled:hover,.TMCommandButton_Inactive:hover{fill:#000;background-color:#e0e0ff}} | |||
diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs new file mode 100644 index 0000000..1cc0f07 --- /dev/null +++ b/src/dumbymap.mjs | |||
@@ -0,0 +1,357 @@ | |||
1 | // vim:foldmethod | ||
2 | import MarkdownIt from 'markdown-it' | ||
3 | import MarkdownItAnchor from 'markdown-it-anchor' | ||
4 | import MarkdownItFootnote from 'markdown-it-footnote' | ||
5 | import MarkdownItFrontMatter from 'markdown-it-front-matter' | ||
6 | import MarkdownItTocDoneRight from 'markdown-it-toc-done-right' | ||
7 | import LeaderLine from 'leader-line' | ||
8 | import PlainDraggable from 'plain-draggable' | ||
9 | import { render, parseConfigsFromText } from 'mapclay' | ||
10 | |||
11 | const observers = new Map() | ||
12 | |||
13 | export const markdown2HTML = async (container, mdContent) => { | ||
14 | // Render: Markdown -> HTML {{{ | ||
15 | |||
16 | container.innerHTML = ` | ||
17 | <div id="map"></div> | ||
18 | <div id="markdown"></div> | ||
19 | ` | ||
20 | |||
21 | const md = MarkdownIt({ html: true }) | ||
22 | .use(MarkdownItAnchor, { | ||
23 | permalink: MarkdownItAnchor.permalink.linkInsideHeader({ placement: 'before' }) | ||
24 | }) | ||
25 | .use(MarkdownItFootnote) | ||
26 | .use(MarkdownItFrontMatter) | ||
27 | .use(MarkdownItTocDoneRight) | ||
28 | |||
29 | // FIXME A better way to generate draggable code block | ||
30 | md.renderer.rules.draggable_block_open = () => '<div>' | ||
31 | md.renderer.rules.draggable_block_close = () => '</div>' | ||
32 | |||
33 | md.core.ruler.before('block', 'draggable_block', (state) => { | ||
34 | state.tokens.push(new state.Token('draggable_block_open', '', 1)) | ||
35 | }) | ||
36 | |||
37 | // Add close tag for block with more than 2 empty lines | ||
38 | md.block.ruler.before('table', 'draggable_block', (state, startLine) => { | ||
39 | if (state.src[state.bMarks[startLine - 1]] === '\n' && state.src[state.bMarks[startLine - 2]] === '\n') { | ||
40 | state.push('draggable_block_close', '', -1); | ||
41 | state.push('draggable_block_open', '', 1); | ||
42 | } | ||
43 | }) | ||
44 | |||
45 | md.core.ruler.after('block', 'draggable_block', (state) => { | ||
46 | state.tokens.push(new state.Token('draggable_block_close', '', -1)) | ||
47 | }) | ||
48 | |||
49 | const markdown = container.querySelector('#markdown') | ||
50 | const contentWithToc = '${toc}\n\n\n' + mdContent | ||
51 | markdown.innerHTML = md.render(contentWithToc); | ||
52 | markdown.querySelectorAll('*> div:not(:has(nav))') | ||
53 | .forEach(b => b.classList.add('draggable-block')) | ||
54 | |||
55 | |||
56 | // TODO Improve it! | ||
57 | const docLinks = Array.from(container.querySelectorAll('#markdown a[href^="#"][title^="doc"]')) | ||
58 | docLinks.forEach(link => { | ||
59 | link.classList.add('with-leader-line', 'doclink') | ||
60 | link.lines = [] | ||
61 | |||
62 | link.onmouseover = () => { | ||
63 | const target = document.querySelector(link.getAttribute('href')) | ||
64 | if (!target?.checkVisibility()) return | ||
65 | |||
66 | const line = new LeaderLine({ | ||
67 | start: link, | ||
68 | end: target, | ||
69 | hide: true, | ||
70 | path: "magnet" | ||
71 | }) | ||
72 | link.lines.push(line) | ||
73 | line.show('draw', { duration: 300, }) | ||
74 | } | ||
75 | link.onmouseout = () => { | ||
76 | link.lines.forEach(line => line.remove()) | ||
77 | link.lines.length = 0 | ||
78 | } | ||
79 | }) | ||
80 | //}}} | ||
81 | } | ||
82 | |||
83 | export const generateMaps = async (container) => { | ||
84 | // LeaderLine {{{ | ||
85 | |||
86 | // Get anchors with "geo:" scheme | ||
87 | const markdown = container.querySelector('#markdown') | ||
88 | markdown.anchors = [] | ||
89 | |||
90 | // Set focusArea | ||
91 | const focusArea = container.querySelector('#map') | ||
92 | const mapPlaceholder = document.createElement('div') | ||
93 | mapPlaceholder.id = 'mapPlaceholder' | ||
94 | focusArea.appendChild(mapPlaceholder) | ||
95 | |||
96 | // Links points to map by geo schema and id | ||
97 | const geoLinks = Array.from(container.querySelectorAll('#markdown a[href^="geo:"]')) | ||
98 | .filter(link => { | ||
99 | const url = new URL(link.href) | ||
100 | const xy = url?.href?.match(/^geo:([0-9.,]+)/)?.at(1)?.split(',')?.reverse()?.map(Number) | ||
101 | |||
102 | if (!xy || isNaN(xy[0]) || isNaN(xy[1])) return false | ||
103 | |||
104 | // Geo information in link | ||
105 | link.url = url | ||
106 | link.xy = xy | ||
107 | link.classList.add('with-leader-line', 'geolink') | ||
108 | link.targets = link.url.searchParams.get('id')?.split(',') ?? null | ||
109 | |||
110 | // LeaderLine | ||
111 | link.lines = [] | ||
112 | link.onmouseover = () => addLeaderLines(link) | ||
113 | link.onmouseout = () => removeLeaderLines(link) | ||
114 | link.onclick = (event) => { | ||
115 | event.preventDefault() | ||
116 | markdown.anchors | ||
117 | .filter(isAnchorPointedBy(link)) | ||
118 | .forEach(updateMapByMarker(xy)) | ||
119 | // TODO Just hide leader line and show it again | ||
120 | removeLeaderLines(link) | ||
121 | } | ||
122 | |||
123 | return true | ||
124 | }) | ||
125 | |||
126 | const isAnchorPointedBy = (link) => (anchor) => { | ||
127 | const mapContainer = anchor.closest('.map-container') | ||
128 | const isTarget = !link.targets || link.targets.includes(mapContainer.id) | ||
129 | return anchor.title === link.url.pathname && isTarget | ||
130 | } | ||
131 | |||
132 | const isAnchorVisible = (anchor) => { | ||
133 | const mapContainer = anchor.closest('.map-container') | ||
134 | return insideWindow(anchor) && insideParent(anchor, mapContainer) | ||
135 | } | ||
136 | |||
137 | const drawLeaderLine = (link) => (anchor) => { | ||
138 | const line = new LeaderLine({ | ||
139 | start: link, | ||
140 | end: anchor, | ||
141 | hide: true, | ||
142 | middleLabel: link.url.searchParams.get('text'), | ||
143 | path: "magnet", | ||
144 | }) | ||
145 | line.show('draw', { duration: 300, }) | ||
146 | return line | ||
147 | } | ||
148 | |||
149 | const addLeaderLines = (link) => { | ||
150 | link.lines = markdown.anchors | ||
151 | .filter(isAnchorPointedBy(link)) | ||
152 | .filter(isAnchorVisible) | ||
153 | .map(drawLeaderLine(link)) | ||
154 | } | ||
155 | |||
156 | const removeLeaderLines = (link) => { | ||
157 | if (!link.lines) return | ||
158 | link.lines.forEach(line => line.remove()) | ||
159 | link.lines = [] | ||
160 | } | ||
161 | |||
162 | const updateMapByMarker = (xy) => (marker) => { | ||
163 | const renderer = marker.closest('.map-container')?.renderer | ||
164 | renderer.updateCamera({ center: xy }, true) | ||
165 | } | ||
166 | |||
167 | const insideWindow = (element) => { | ||
168 | const rect = element.getBoundingClientRect() | ||
169 | return rect.left > 0 && | ||
170 | rect.right < window.innerWidth + rect.width && | ||
171 | rect.top > 0 && | ||
172 | rect.bottom < window.innerHeight + rect.height | ||
173 | } | ||
174 | |||
175 | const insideParent = (childElement, parentElement) => { | ||
176 | const childRect = childElement.getBoundingClientRect(); | ||
177 | const parentRect = parentElement.getBoundingClientRect(); | ||
178 | const offset = 20 | ||
179 | |||
180 | return childRect.left > parentRect.left + offset && | ||
181 | childRect.right < parentRect.right - offset && | ||
182 | childRect.top > parentRect.top + offset && | ||
183 | childRect.bottom < parentRect.bottom - offset | ||
184 | } | ||
185 | //}}} | ||
186 | // Render Maps {{{ | ||
187 | |||
188 | const afterEachMapLoaded = (mapContainer) => { | ||
189 | mapContainer.querySelectorAll('.marker') | ||
190 | .forEach(marker => markdown.anchors.push(marker)) | ||
191 | |||
192 | const focusClickedMap = () => { | ||
193 | if (container.getAttribute('data-layout') !== 'none') return | ||
194 | |||
195 | container.querySelectorAll('.map-container') | ||
196 | .forEach(c => c.classList.remove('focus')) | ||
197 | mapContainer.classList.add('focus') | ||
198 | } | ||
199 | mapContainer.onclick = focusClickedMap | ||
200 | } | ||
201 | |||
202 | // Set unique ID for map container | ||
203 | const mapIdList = [] | ||
204 | const assignMapId = (config) => { | ||
205 | let mapId = config.id | ||
206 | if (!mapId) { | ||
207 | mapId = config.use?.split('/')?.at(-1) | ||
208 | let counter = 2 | ||
209 | while (mapIdList.includes(mapId)) { | ||
210 | mapId = `${config.use}.${counter}` | ||
211 | counter++ | ||
212 | } | ||
213 | config.id = mapId | ||
214 | } | ||
215 | mapIdList.push(mapId) | ||
216 | } | ||
217 | |||
218 | const markerOptions = geoLinks.map(link => { | ||
219 | return { | ||
220 | targets: link.targets, | ||
221 | xy: link.xy, | ||
222 | title: link.url.pathname | ||
223 | } | ||
224 | }) | ||
225 | |||
226 | |||
227 | // Render each code block with "language-map" class | ||
228 | const renderTargets = Array.from(container.querySelectorAll('pre:has(.language-map)')) | ||
229 | const renderAllTargets = renderTargets.map(async (target) => { | ||
230 | // Get text in code block starts with '```map' | ||
231 | // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content | ||
232 | // replace it by normal space | ||
233 | const configText = target.querySelector('.language-map').textContent.replace(/\u00A0/g, '\u0020') | ||
234 | |||
235 | let configList = [] | ||
236 | try { | ||
237 | configList = parseConfigsFromText(configText).map(result => { | ||
238 | assignMapId(result) | ||
239 | const markersFromLinks = markerOptions.filter(marker => | ||
240 | !marker.targets || marker.targets.includes(result.id) | ||
241 | ) | ||
242 | Object.assign(result, { markers: markersFromLinks }) | ||
243 | return result | ||
244 | }) | ||
245 | } catch (err) { | ||
246 | console.error('Fail to parse yaml config for element', target, err) | ||
247 | } | ||
248 | |||
249 | // Render maps | ||
250 | return render(target, configList) | ||
251 | .then(results => { | ||
252 | results.forEach((mapByConfig) => { | ||
253 | if (mapByConfig.status === 'fulfilled') { | ||
254 | afterEachMapLoaded(mapByConfig.value) | ||
255 | return mapByConfig.value | ||
256 | } else { | ||
257 | console.error('Fail to render target element', mapByConfig.value) | ||
258 | } | ||
259 | }) | ||
260 | }) | ||
261 | }) | ||
262 | const renderInfo = await Promise.all(renderAllTargets).then(() => 'Finish Rendering') | ||
263 | console.info(renderInfo) | ||
264 | |||
265 | //}}} | ||
266 | // CSS observer {{{ | ||
267 | if (!observers.get(container)) { | ||
268 | observers.set(container, []) | ||
269 | } | ||
270 | const obs = observers.get(container) | ||
271 | if (obs.length) { | ||
272 | obs.forEach(o => o.disconnect()) | ||
273 | obs.length = 0 | ||
274 | } | ||
275 | // Layout{{{ | ||
276 | |||
277 | // press key to switch layout | ||
278 | const layouts = ['none', 'side', 'overlay'] | ||
279 | container.setAttribute("data-layout", layouts[0]) | ||
280 | document.onkeydown = (event) => { | ||
281 | if (event.key === 'x' && container.querySelector('.map-container')) { | ||
282 | let currentLayout = container.getAttribute('data-layout') | ||
283 | currentLayout = currentLayout ? currentLayout : 'none' | ||
284 | const nextLayout = layouts[(layouts.indexOf(currentLayout) + 1) % layouts.length] | ||
285 | |||
286 | container.setAttribute("data-layout", nextLayout) | ||
287 | } | ||
288 | } | ||
289 | |||
290 | // Add draggable part for blocks | ||
291 | markdown.blocks = Array.from(markdown.querySelectorAll('.draggable-block')) | ||
292 | markdown.blocks.forEach(block => { | ||
293 | const draggablePart = document.createElement('div'); | ||
294 | draggablePart.classList.add('draggable') | ||
295 | draggablePart.textContent = '☰' | ||
296 | |||
297 | // TODO Better way to close block | ||
298 | draggablePart.onmouseup = (e) => { | ||
299 | if (e.button === 1) block.style.display = "none"; | ||
300 | } | ||
301 | block.insertBefore(draggablePart, block.firstChild) | ||
302 | }) | ||
303 | |||
304 | // observe layout change | ||
305 | const layoutObserver = new MutationObserver(() => { | ||
306 | const layout = container.getAttribute('data-layout') | ||
307 | markdown.blocks.forEach(b => b.style.display = "block") | ||
308 | |||
309 | if (layout === 'none') { | ||
310 | mapPlaceholder.innerHTML = "" | ||
311 | const map = focusArea.querySelector('.map-container') | ||
312 | // Swap focused map and palceholder in markdown | ||
313 | if (map) { | ||
314 | mapPlaceholder.parentElement?.replaceChild(map, mapPlaceholder) | ||
315 | focusArea.append(mapPlaceholder) | ||
316 | } | ||
317 | } else { | ||
318 | // If paceholder is not set, create one and put map into focusArea | ||
319 | if (focusArea.contains(mapPlaceholder)) { | ||
320 | const mapContainer = container.querySelector('.map-container.focus') ?? container.querySelector('.map-container') | ||
321 | mapPlaceholder.innerHTML = `<div>Placeholder</div>` | ||
322 | // TODO | ||
323 | // mapPlaceholder.src = map.map.getCanvas().toDataURL() | ||
324 | mapContainer.parentElement?.replaceChild(mapPlaceholder, mapContainer) | ||
325 | focusArea.appendChild(mapContainer) | ||
326 | } | ||
327 | } | ||
328 | |||
329 | if (layout === 'overlay') { | ||
330 | markdown.blocks.forEach(block => { | ||
331 | block.draggableInstance = new PlainDraggable(block, { handle: block.querySelector('.draggable') }) | ||
332 | block.draggableInstance.snap = { x: { step: 20 }, y: { step: 20 } } | ||
333 | // block.draggableInstance.onDragEnd = () => { | ||
334 | // links(block).forEach(link => link.line.position()) | ||
335 | // } | ||
336 | }) | ||
337 | } else { | ||
338 | markdown.blocks.forEach(block => { | ||
339 | try { | ||
340 | block.style.transform = 'none' | ||
341 | block.draggableInstance.remove() | ||
342 | } catch (err) { | ||
343 | console.warn('Fail to remove draggable instance', err) | ||
344 | } | ||
345 | }) | ||
346 | } | ||
347 | }); | ||
348 | layoutObserver.observe(container, { | ||
349 | attributes: true, | ||
350 | attributeFilter: ["data-layout"], | ||
351 | attributeOldValue: true | ||
352 | }); | ||
353 | obs.push(layoutObserver) | ||
354 | //}}} | ||
355 | //}}} | ||
356 | return container | ||
357 | } | ||
diff --git a/src/editor.mjs b/src/editor.mjs new file mode 100644 index 0000000..8b0a6ac --- /dev/null +++ b/src/editor.mjs | |||
@@ -0,0 +1,400 @@ | |||
1 | import TinyMDE from 'tiny-markdown-editor' | ||
2 | import { markdown2HTML, generateMaps } from './dumbymap' | ||
3 | import { defaultAliasesForRenderer, parseConfigsFromText } from 'mapclay' | ||
4 | |||
5 | // Set up Editor {{{ | ||
6 | |||
7 | const HtmlContainer = document.querySelector(".result-html") | ||
8 | const mdeElement = document.querySelector("#tinymde") | ||
9 | |||
10 | // Add Editor | ||
11 | const defaultContent = '## Links\n\n- [Go to marker](geo:24,121?id=foo,leaflet&text=normal "Link Test")\n\n```map\nid: foo\nuse: maplibre\n```\n' | ||
12 | const lastContent = localStorage.getItem('editorContent') | ||
13 | const tinyEditor = new TinyMDE.Editor({ | ||
14 | element: 'tinymde', | ||
15 | content: lastContent ? lastContent : defaultContent | ||
16 | }); | ||
17 | mdeElement.querySelectorAll('span').forEach(e => e.setAttribute('spellcheck', 'false')) | ||
18 | |||
19 | // Add command bar for editor | ||
20 | // Use this command to render maps and geoLinks | ||
21 | const mapCommand = { | ||
22 | name: 'map', | ||
23 | title: 'Switch Map Generation', | ||
24 | innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🌏</div>`, | ||
25 | action: () => { | ||
26 | if (!HtmlContainer.querySelector('.map-container')) { | ||
27 | generateMaps(HtmlContainer) | ||
28 | document.activeElement.blur(); | ||
29 | } else { | ||
30 | markdown2HTML(HtmlContainer, tinyEditor.getContent()) | ||
31 | HtmlContainer.setAttribute('data-layout', 'none') | ||
32 | } | ||
33 | }, | ||
34 | hotkey: 'Ctrl-m' | ||
35 | } | ||
36 | const debugCommand = { | ||
37 | name: 'debug', | ||
38 | title: 'show debug message', | ||
39 | innerHTML: `<div style="font-size: 16px; line-height: 1.1;">🤔</div>`, | ||
40 | action: () => { | ||
41 | // FIXME | ||
42 | alert(tinyEditor.getContent()) | ||
43 | }, | ||
44 | hotkey: 'Ctrl-i' | ||
45 | } | ||
46 | // Set up command bar | ||
47 | new TinyMDE.CommandBar({ | ||
48 | element: 'tinymde_commandbar', editor: tinyEditor, | ||
49 | commands: [mapCommand, debugCommand, '|', 'h1', 'h2', '|', 'insertLink', 'insertImage', '|', 'bold', 'italic', 'strikethrough', 'code', '|', 'ul', 'ol', '|', 'blockquote'] | ||
50 | }); | ||
51 | |||
52 | // Render HTML to result container | ||
53 | markdown2HTML(HtmlContainer, tinyEditor.getContent()) | ||
54 | |||
55 | // FIXME DEBUGONLY | ||
56 | // generateMaps(HtmlContainer) | ||
57 | // setTimeout(() => { | ||
58 | // HtmlContainer.setAttribute("data-layout", 'side') | ||
59 | // }, 500) | ||
60 | |||
61 | // }}} | ||
62 | // Event Listener: change {{{ | ||
63 | |||
64 | // Save editor content to local storage, set timeout for 3 seconds | ||
65 | let rejectLastSaving | ||
66 | const saveContent = (content) => { | ||
67 | new Promise((resolve, reject) => { | ||
68 | // If user is typing, the last change cancel previous ones | ||
69 | if (rejectLastSaving) rejectLastSaving(content.length) | ||
70 | rejectLastSaving = reject | ||
71 | |||
72 | setTimeout(() => { | ||
73 | localStorage.setItem('editorContent', content) | ||
74 | resolve('Content Saved') | ||
75 | }, 3000) | ||
76 | }).catch(() => null) | ||
77 | } | ||
78 | |||
79 | // Render HTML to result container and save current content | ||
80 | tinyEditor.addEventListener('change', e => { | ||
81 | markdown2HTML(HtmlContainer, e.content) | ||
82 | saveContent(e.content) | ||
83 | }); | ||
84 | // }}} | ||
85 | // Completion in Code Blok {{{ | ||
86 | // Elements about suggestions {{{ | ||
87 | const suggestionsEle = document.createElement('div') | ||
88 | suggestionsEle.classList.add('container__suggestions'); | ||
89 | mdeElement.appendChild(suggestionsEle) | ||
90 | |||
91 | const rendererOptions = {} | ||
92 | |||
93 | class Suggestion { | ||
94 | constructor({ text, replace }) { | ||
95 | this.text = text | ||
96 | this.replace = replace | ||
97 | } | ||
98 | } | ||
99 | |||
100 | // }}} | ||
101 | // {{{ Aliases for map options | ||
102 | const aliasesForMapOptions = {} | ||
103 | const defaultApply = '/default.yml' | ||
104 | fetch(defaultApply) | ||
105 | .then(res => res.text()) | ||
106 | .then(rawText => { | ||
107 | const config = parseConfigsFromText(rawText)?.at(0) | ||
108 | Object.assign(aliasesForMapOptions, config.aliases ?? {}) | ||
109 | }) | ||
110 | .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err)) | ||
111 | // }}} | ||
112 | // FUNCTION: Check cursor is inside map code block {{{ | ||
113 | const insideCodeblockForMap = (element) => { | ||
114 | const code = element.closest('.TMFencedCodeBacktick') | ||
115 | if (!code) return false | ||
116 | |||
117 | let ps = code.previousSibling | ||
118 | if (!ps) return false | ||
119 | |||
120 | // Look backward to find pattern of code block: /```map/ | ||
121 | while (!ps.classList.contains('TMCodeFenceBacktickOpen')) { | ||
122 | ps = ps.previousSibling | ||
123 | if (!ps) return false | ||
124 | if (ps.classList.contains('TMCodeFenceBacktickClose')) return false | ||
125 | } | ||
126 | |||
127 | return ps.querySelector('.TMInfoString')?.textContent === 'map' | ||
128 | } | ||
129 | // }}} | ||
130 | // FUNCTION: Get renderer by cursor position in code block {{{ | ||
131 | const getLineWithRenderer = (element) => { | ||
132 | const currentLine = element.closest('.TMFencedCodeBacktick') | ||
133 | if (!currentLine) return null | ||
134 | |||
135 | // Look backward/forward for pattern of used renderer: /use: .+/ | ||
136 | let ps = currentLine | ||
137 | do { | ||
138 | ps = ps.previousSibling | ||
139 | if (ps.textContent.match(/^use: /)) { | ||
140 | return ps | ||
141 | } else if (ps.textContent.match(/^---/)) { | ||
142 | // If yaml doc separator is found | ||
143 | break | ||
144 | } | ||
145 | } while (ps && ps.classList.contains('TMFencedCodeBacktick')) | ||
146 | |||
147 | let ns = currentLine | ||
148 | do { | ||
149 | ns = ns.nextSibling | ||
150 | if (ns.textContent.match(/^use: /)) { | ||
151 | return ns | ||
152 | } else if (ns.textContent.match(/^---/)) { | ||
153 | // If yaml doc separator is found | ||
154 | return null | ||
155 | } | ||
156 | } while (ns && ns.classList.contains('TMFencedCodeBacktick')) | ||
157 | |||
158 | return null | ||
159 | } | ||
160 | // }}} | ||
161 | // FUNCTION: Add suggestions for current selection {{{ | ||
162 | const getSuggestionsForOptions = (optionTyped, validOptions) => { | ||
163 | let suggestOptions = [] | ||
164 | |||
165 | const matchedOptions = validOptions | ||
166 | .filter(o => o.valueOf().toLowerCase().includes(optionTyped.toLowerCase())) | ||
167 | |||
168 | if (matchedOptions.length > 0) { | ||
169 | suggestOptions = matchedOptions | ||
170 | } else { | ||
171 | suggestOptions = validOptions | ||
172 | } | ||
173 | |||
174 | return suggestOptions | ||
175 | .map(o => new Suggestion({ | ||
176 | text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ''}">ⓘ</span>`, | ||
177 | replace: `${o.valueOf()}: `, | ||
178 | })) | ||
179 | } | ||
180 | |||
181 | const getSuggestionFromMapOption = (option) => { | ||
182 | if (!option.example) return null | ||
183 | |||
184 | const text = option.example_desc | ||
185 | ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>` | ||
186 | : `<span>${option.example}</span>` | ||
187 | |||
188 | return new Suggestion({ | ||
189 | text: text, | ||
190 | replace: `${option.valueOf()}: ${option.example ?? ""}`, | ||
191 | }) | ||
192 | } | ||
193 | |||
194 | const getSuggestionsFromAliases = (option) => Object.entries(aliasesForMapOptions[option.valueOf()] ?? {}) | ||
195 | ?.map(record => { | ||
196 | const [alias, value] = record | ||
197 | const valueString = JSON.stringify(value).replaceAll('"', '') | ||
198 | return new Suggestion({ | ||
199 | text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`, | ||
200 | replace: `${option.valueOf()}: ${valueString}`, | ||
201 | }) | ||
202 | }) | ||
203 | ?? [] | ||
204 | |||
205 | // Add HTML element for List of suggestions | ||
206 | const addSuggestions = (currentLine, selection) => { | ||
207 | const text = currentLine.textContent | ||
208 | const markInputIsInvalid = (ele) => (ele ?? currentLine).classList.add('invalid-input') | ||
209 | let suggestions = [] | ||
210 | |||
211 | // Check if "use: <renderer>" is set | ||
212 | const lineWithRenderer = getLineWithRenderer(currentLine) | ||
213 | const renderer = lineWithRenderer?.textContent.split(' ')[1] | ||
214 | if (renderer) { | ||
215 | |||
216 | // Do not check properties | ||
217 | if (text.startsWith(' ')) { | ||
218 | return | ||
219 | } | ||
220 | |||
221 | // If no valid options for current used renderer, go get it! | ||
222 | const validOptions = rendererOptions[renderer] | ||
223 | if (!validOptions) { | ||
224 | // Get list of valid options for current renderer | ||
225 | const rendererUrl = defaultAliasesForRenderer.use[renderer]?.value | ||
226 | import(rendererUrl) | ||
227 | .then(rendererModule => { | ||
228 | rendererOptions[renderer] = rendererModule.default.validOptions | ||
229 | }) | ||
230 | .catch(() => { | ||
231 | markInputIsInvalid(lineWithRenderer) | ||
232 | console.error('Fail to get valid options from renderer, URL is', rendererUrl) | ||
233 | }) | ||
234 | return | ||
235 | } | ||
236 | |||
237 | // If input is "key:value" (no space left after colon), then it is invalid | ||
238 | const isKeyFinished = text.includes(':'); | ||
239 | const isValidKeyValue = text.match(/^[^:]+:\s+/) | ||
240 | if (isKeyFinished && !isValidKeyValue) { | ||
241 | markInputIsInvalid() | ||
242 | return | ||
243 | } | ||
244 | |||
245 | // If user is typing option | ||
246 | const keyTyped = text.split(':')[0].trim() | ||
247 | if (!isKeyFinished) { | ||
248 | suggestions = getSuggestionsForOptions(keyTyped, validOptions) | ||
249 | markInputIsInvalid() | ||
250 | } | ||
251 | |||
252 | // If user is typing value | ||
253 | const matchedOption = validOptions.find(o => o.name === keyTyped) | ||
254 | if (isKeyFinished && !matchedOption) { | ||
255 | markInputIsInvalid() | ||
256 | } | ||
257 | |||
258 | if (isKeyFinished && matchedOption) { | ||
259 | const valueTyped = text.substring(text.indexOf(':') + 1).trim() | ||
260 | const isValidValue = matchedOption.isValid(valueTyped) | ||
261 | if (!valueTyped) { | ||
262 | suggestions = [ | ||
263 | getSuggestionFromMapOption(matchedOption), | ||
264 | ...getSuggestionsFromAliases(matchedOption) | ||
265 | ].filter(s => s instanceof Suggestion) | ||
266 | } | ||
267 | if (valueTyped && !isValidValue) { | ||
268 | markInputIsInvalid() | ||
269 | } | ||
270 | } | ||
271 | |||
272 | } else { | ||
273 | // Suggestion for "use" | ||
274 | const rendererSuggestions = Object.entries(defaultAliasesForRenderer.use) | ||
275 | .filter(([renderer,]) => { | ||
276 | const suggestion = `use: ${renderer}` | ||
277 | const suggetionNoSpace = suggestion.replace(' ', '') | ||
278 | const textNoSpace = text.replace(' ', '') | ||
279 | return suggestion !== text && | ||
280 | (suggetionNoSpace.includes(textNoSpace)) | ||
281 | }) | ||
282 | .map(([renderer, info]) => | ||
283 | new Suggestion({ | ||
284 | text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`, | ||
285 | replace: `use: ${renderer}`, | ||
286 | }) | ||
287 | ) | ||
288 | suggestions = rendererSuggestions.length > 0 ? rendererSuggestions : [] | ||
289 | } | ||
290 | |||
291 | if (suggestions.length === 0) { | ||
292 | suggestionsEle.style.display = 'none'; | ||
293 | return | ||
294 | } else { | ||
295 | suggestionsEle.style.display = 'block'; | ||
296 | } | ||
297 | |||
298 | suggestionsEle.innerHTML = '' | ||
299 | suggestions.forEach((suggestion) => { | ||
300 | const option = document.createElement('div'); | ||
301 | if (suggestion.text.startsWith('<')) { | ||
302 | option.innerHTML = suggestion.text; | ||
303 | } else { | ||
304 | option.innerText = suggestion.text; | ||
305 | } | ||
306 | option.classList.add('container__suggestion'); | ||
307 | option.onmouseover = () => { | ||
308 | Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) | ||
309 | option.classList.add('focus') | ||
310 | } | ||
311 | option.onmouseout = () => { | ||
312 | option.classList.remove('focus') | ||
313 | } | ||
314 | option.onclick = () => { | ||
315 | const newFocus = { ...selection.focus, col: 0 } | ||
316 | const newAnchor = { ...selection.anchor, col: text.length } | ||
317 | tinyEditor.paste(suggestion.replace, newFocus, newAnchor) | ||
318 | suggestionsEle.style.display = 'none'; | ||
319 | option.classList.remove('focus') | ||
320 | }; | ||
321 | suggestionsEle.appendChild(option); | ||
322 | }); | ||
323 | |||
324 | const rect = currentLine.getBoundingClientRect(); | ||
325 | suggestionsEle.style.top = `${rect.top + rect.height + 12}px`; | ||
326 | suggestionsEle.style.left = `${rect.right}px`; | ||
327 | suggestionsEle.style.maxWidth = `calc(${window.innerWidth}px - ${rect.right}px - 2rem)`; | ||
328 | } | ||
329 | // }}} | ||
330 | // Event: suggests for current selection {{{ | ||
331 | tinyEditor.addEventListener('selection', selection => { | ||
332 | // To trigger click event on suggestions list, don't set suggestion list invisible | ||
333 | if (suggestionsEle.querySelector('.container__suggestion.focus:hover') !== null) { | ||
334 | return | ||
335 | } else { | ||
336 | suggestionsEle.style.display = 'none'; | ||
337 | } | ||
338 | |||
339 | // Check selection is inside editor contents | ||
340 | const node = selection?.anchor?.node | ||
341 | if (!node) return | ||
342 | |||
343 | // Get HTML element for current selection | ||
344 | const element = node instanceof HTMLElement | ||
345 | ? node | ||
346 | : node.parentNode | ||
347 | element.setAttribute('spellcheck', 'false') | ||
348 | |||
349 | // Do not show suggestion by attribute | ||
350 | if (suggestionsEle.getAttribute('data-keep-close') === 'true') { | ||
351 | suggestionsEle.setAttribute('data-keep-close', 'false') | ||
352 | return | ||
353 | } | ||
354 | |||
355 | // Show suggestions for map code block | ||
356 | if (insideCodeblockForMap(element)) addSuggestions(element, selection) | ||
357 | }); | ||
358 | // }}} | ||
359 | // Key events for suggestions {{{ | ||
360 | mdeElement.addEventListener('keydown', (e) => { | ||
361 | // Only the following keys are used | ||
362 | const keyForSuggestions = ['Tab', 'Enter', 'Escape'].includes(e.key) | ||
363 | if (!keyForSuggestions || suggestionsEle.style.display === 'none') return; | ||
364 | |||
365 | // Directly add a newline when no suggestion is selected | ||
366 | const currentSuggestion = suggestionsEle.querySelector('.container__suggestion.focus') | ||
367 | if (!currentSuggestion && e.key === 'Enter') return | ||
368 | |||
369 | // Override default behavior | ||
370 | e.preventDefault(); | ||
371 | |||
372 | // Suggestion when pressing Tab or Shift + Tab | ||
373 | const nextSuggestion = currentSuggestion?.nextSibling ?? suggestionsEle.querySelector('.container__suggestion:first-child') | ||
374 | const previousSuggestion = currentSuggestion?.previousSibling ?? suggestionsEle.querySelector('.container__suggestion:last-child') | ||
375 | const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion | ||
376 | |||
377 | // Current editor selection state | ||
378 | const selection = tinyEditor.getSelection(true) | ||
379 | switch (e.key) { | ||
380 | case 'Tab': | ||
381 | Array.from(suggestionsEle.children).forEach(s => s.classList.remove('focus')) | ||
382 | focusSuggestion.classList.add('focus') | ||
383 | focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) | ||
384 | break; | ||
385 | case 'Enter': | ||
386 | currentSuggestion.onclick() | ||
387 | suggestionsEle.style.display = 'none'; | ||
388 | break; | ||
389 | case 'Escape': | ||
390 | suggestionsEle.style.display = 'none'; | ||
391 | // Prevent trigger selection event again | ||
392 | suggestionsEle.setAttribute('data-keep-close', 'true') | ||
393 | setTimeout(() => tinyEditor.setSelection(selection), 100) | ||
394 | break; | ||
395 | } | ||
396 | }); | ||
397 | // }}} | ||
398 | // }}} | ||
399 | |||
400 | // vim: sw=2 ts=2 foldmethod=marker foldmarker={{{,}}} | ||