1/* DokuWiki MoaiEditor Match_headers.js file 2 Version : 0.5 (May 5, 2026) 3 Author : MoaiTools <info@moaitools.org> 4 License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */ 5 6MoaiEditor.MatchHeaders = class { 7 8 constructor(outer) { 9 10 // Arguments 11 this.outer = outer; 12 13 // Variables 14 this.nodes = []; // Array of header nodes in the html {i, text, type, handle, matchline} 15 this.candidates = []; // Array of candidate lines {i, text, type, linenum} 16 } 17 // ┌───────────────────────────────────┐ 18 // │ Public │ 19 // └───────────────────────────────────┘ 20 21 findAll () { 22 23 24 // Find all header matches 25 for (let node of this.nodes) { 26 // Find the line 27 const line = this.findMatch(node); 28 // Update the header array (for header clicks and table of contents) 29 node.matchline = line; 30 // Append to the matches array (for synchonized scrolling) 31 if (line !== null) 32 this.outer.matches.add (node.type, node.handle, line, line, 'header'); 33 } 34 } 35 // ──────────────────────────────────── 36 findMatch (node) { 37 38 // Check for similar headers 39 var matches = []; 40 for (let candidate of this.candidates) 41 if (this.same(node, candidate)) 42 matches.push(candidate); 43 // If there are no matches return null 44 if (matches.length == 0) 45 return null; 46 // If there is just one match, return the line number 47 if (matches.length == 1) 48 return matches[0].linenum; 49 // If there is more than one match, check if one of them has the same neighbors 50 var maxScore = -1; 51 var bestMatch = null; 52 for (let candidate of matches) { 53 const score = this.neighborsScore(node, candidate); 54 if (score > maxScore) { 55 maxScore = score; 56 bestMatch = candidate; 57 } 58 } 59 return bestMatch.linenum; 60 } 61 // ──────────────────────────────────── 62 parseText (lines) { 63 this.candidates = []; 64 var i = 0; 65 var l = -1; 66 // Iterarate every line of text 67 for (let line of lines) { 68 l += 1; 69 // Continue if it's not a header line 70 const header = this.isHeader(line); 71 if (!header) 72 continue; 73 // Otherwise store the header 74 this.candidates.push({ 75 i : i, 76 type : header.type, 77 text : header.text, 78 linenum : l, 79 }); 80 // Increment index 81 i += 1; 82 } 83 } 84 // ┌───────────────────────────────────┐ 85 // │ Private │ 86 // └───────────────────────────────────┘ 87 88 neighborsScore (node, candidate) { 89 90 var d = 1; 91 var score = 0; 92 var idx_node = node.i; 93 var idx_text = candidate.i; 94 while (true) { 95 // Left neighbors 96 var leftNodeNeighbor = this.getNeighbor(this.nodes, idx_node-d); 97 var leftTextNeighbor = this.getNeighbor(this.candidates, idx_text-d); 98 if (this.same(leftNodeNeighbor, leftTextNeighbor)) 99 score += 1; 100 // Right neighbors 101 var rightNodeNeighbor = this.getNeighbor(this.nodes, idx_node+d); 102 var rightTextNeighbor = this.getNeighbor(this.candidates, idx_text+d); 103 if (this.same(rightNodeNeighbor, rightTextNeighbor)) 104 score += 1; 105 // Exit condition 106 if (score % 2 != 0 || leftNodeNeighbor === null || rightNodeNeighbor === null) 107 break; 108 // Increment distance 109 d += 1; 110 } 111 return score/(2*d); 112 } 113 // ──────────────────────────────────── 114 same (node, candidate) { 115 if (node === null && candidate === null) 116 return true; 117 if (node === null || candidate === null) 118 return false; 119 if (node.type != candidate.type) 120 return false; 121 if (node.text != candidate.text) 122 return false; 123 return true; 124 } 125 // ──────────────────────────────────── 126 getNeighbor (array, idx) { 127 if (idx < 0 || idx > array.length-1) 128 return null; 129 return array[idx]; 130 } 131 // ──────────────────────────────────── 132 isHeader (line) { 133 134 // Fail if the line does not include '==' 135 if (line.search("==") == -1) 136 return null; 137 // Fail if the line starts with two or more spaces 138 if (line.substr(0,2) == ' ') 139 return null; 140 // Fail if the trimmed line does not end in '==' 141 if (line.trim().slice(-2) != '==') 142 return null; 143 // Search all sequences of two or more '=' 144 let regex = /(={2,})/gd; 145 let matches = [...line.matchAll(regex)]; 146 // Fail if we have less than two sequences 147 if (matches.length < 2) 148 return null; 149 // Extract the content of the header (inner part) 150 var first = matches[0]; 151 var last = matches.at(-1); 152 var pos_start = first.indices[0][1]; 153 var pos_end = last.index; 154 var content = line.substring(pos_start, pos_end); 155 var text = content.trim(); 156 // Fail if the content is empty (or whitespace) 157 if (text.length == 0) 158 return null; 159 // Determine the type of header (h1, h2, etc) 160 var len = first.indices[0][1] - first.indices[0][0]; 161 var type; 162 if (len < 6) 163 type = ['H5','H4','H3','H2'][len-2]; 164 else 165 type = 'H1'; 166 // Return the data 167 return { 168 type : type, 169 text : text, 170 content : content, 171 contentStart : pos_start, 172 contentEnd : pos_end 173 }; 174 } 175}; // End Class 176