MediaWiki:Common.js: Difference between revisions
Jump to navigation
Jump to search
Content deleted Content added
No edit summary |
No edit summary |
||
| (9 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* Any JavaScript here will be loaded for all users on every page load. */ |
/* Any JavaScript here will be loaded for all users on every page load. */ |
||
/* ------------------------------------------------------------------------- |
|||
/* Add live search to hardware benchmark tables */ |
|||
* Shared helpers |
|||
* ------------------------------------------------------------------------- */ |
|||
function tuflowNormaliseSearchText(value) { |
|||
return String(value || '') |
|||
.toLocaleLowerCase() |
|||
.replace(/\s+/g, ' ') |
|||
.trim(); |
|||
} |
|||
/* |
|||
* Parse a query into OR groups containing AND terms. |
|||
* |
|||
* Examples: |
|||
* raster terrain |
|||
* raster AND terrain |
|||
* QuickOSM OR HCMGIS |
|||
* "repository install" AND raster |
|||
*/ |
|||
function tuflowParseSearchQuery(rawQuery) { |
|||
var query = String(rawQuery || '').trim(); |
|||
if (!query) { |
|||
return []; |
|||
} |
|||
return query.split(/\s+OR\s+/i).map(function (group) { |
|||
var terms = []; |
|||
var pattern = /"([^"]+)"|(\S+)/g; |
|||
var match; |
|||
while ((match = pattern.exec(group)) !== null) { |
|||
var term = match[1] || match[2]; |
|||
if (!/^AND$/i.test(term)) { |
|||
term = tuflowNormaliseSearchText(term); |
|||
if (term) { |
|||
terms.push(term); |
|||
} |
|||
} |
|||
} |
|||
return terms; |
|||
}).filter(function (group) { |
|||
return group.length > 0; |
|||
}); |
|||
} |
|||
function tuflowMatchesSearch(text, rawQuery) { |
|||
var normalisedText = tuflowNormaliseSearchText(text); |
|||
var groups = tuflowParseSearchQuery(rawQuery); |
|||
if (!groups.length) { |
|||
return true; |
|||
} |
|||
return groups.some(function (terms) { |
|||
return terms.every(function (term) { |
|||
return normalisedText.includes(term); |
|||
}); |
|||
}); |
|||
} |
|||
function tuflowGetSearchTerms(rawQuery) { |
|||
var terms = []; |
|||
tuflowParseSearchQuery(rawQuery).forEach(function (group) { |
|||
group.forEach(function (term) { |
|||
if (terms.indexOf(term) === -1) { |
|||
terms.push(term); |
|||
} |
|||
}); |
|||
}); |
|||
return terms.sort(function (a, b) { |
|||
return b.length - a.length; |
|||
}); |
|||
} |
|||
function tuflowEscapeRegExp(value) { |
|||
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|||
} |
|||
function tuflowClearHighlights(root) { |
|||
if (!root) { |
|||
return; |
|||
} |
|||
Array.from(root.querySelectorAll('mark.search-highlight')).forEach(function (mark) { |
|||
var textNode = document.createTextNode(mark.textContent); |
|||
var parent = mark.parentNode; |
|||
if (!parent) { |
|||
return; |
|||
} |
|||
parent.replaceChild(textNode, mark); |
|||
parent.normalize(); |
|||
}); |
|||
} |
|||
function tuflowHighlightMatches(root, rawQuery) { |
|||
if (!root || !rawQuery) { |
|||
return; |
|||
} |
|||
tuflowClearHighlights(root); |
|||
var terms = tuflowGetSearchTerms(rawQuery); |
|||
if (!terms.length) { |
|||
return; |
|||
} |
|||
/* |
|||
* Longer terms are checked first so that phrases such as |
|||
* "repository install" are highlighted before "repository". |
|||
*/ |
|||
terms.sort(function (a, b) { |
|||
return b.length - a.length; |
|||
}); |
|||
var escapedTerms = terms.map(function (term) { |
|||
return tuflowEscapeRegExp(term); |
|||
}); |
|||
var testPattern = new RegExp( |
|||
escapedTerms.join('|'), |
|||
'i' |
|||
); |
|||
var replacePattern = new RegExp( |
|||
escapedTerms.join('|'), |
|||
'ig' |
|||
); |
|||
var walker = document.createTreeWalker( |
|||
root, |
|||
NodeFilter.SHOW_TEXT, |
|||
{ |
|||
acceptNode: function (node) { |
|||
if (!node.nodeValue || !node.nodeValue.trim()) { |
|||
return NodeFilter.FILTER_REJECT; |
|||
} |
|||
if ( |
|||
node.parentNode && |
|||
[ |
|||
'SCRIPT', |
|||
'STYLE', |
|||
'MARK', |
|||
'TEXTAREA', |
|||
'INPUT', |
|||
'BUTTON' |
|||
].indexOf(node.parentNode.nodeName) !== -1 |
|||
) { |
|||
return NodeFilter.FILTER_REJECT; |
|||
} |
|||
return testPattern.test(node.nodeValue) |
|||
? NodeFilter.FILTER_ACCEPT |
|||
: NodeFilter.FILTER_REJECT; |
|||
} |
|||
} |
|||
); |
|||
var textNodes = []; |
|||
var currentNode; |
|||
while ((currentNode = walker.nextNode())) { |
|||
textNodes.push(currentNode); |
|||
} |
|||
textNodes.forEach(function (node) { |
|||
var text = node.nodeValue; |
|||
var fragment = document.createDocumentFragment(); |
|||
var lastIndex = 0; |
|||
text.replace( |
|||
replacePattern, |
|||
function (match, offset) { |
|||
if (offset > lastIndex) { |
|||
fragment.appendChild( |
|||
document.createTextNode( |
|||
text.slice(lastIndex, offset) |
|||
) |
|||
); |
|||
} |
|||
var normalisedMatch = |
|||
tuflowNormaliseSearchText(match); |
|||
var termIndex = terms.findIndex(function (term) { |
|||
return term === normalisedMatch; |
|||
}); |
|||
/* |
|||
* Cycle through five highlight colours. |
|||
*/ |
|||
var colourNumber = |
|||
(Math.max(termIndex, 0) % 5) + 1; |
|||
var mark = document.createElement('mark'); |
|||
mark.className = |
|||
'search-highlight search-highlight-' + |
|||
colourNumber; |
|||
mark.textContent = match; |
|||
fragment.appendChild(mark); |
|||
lastIndex = offset + match.length; |
|||
return match; |
|||
} |
|||
); |
|||
if (lastIndex < text.length) { |
|||
fragment.appendChild( |
|||
document.createTextNode( |
|||
text.slice(lastIndex) |
|||
) |
|||
); |
|||
} |
|||
if (node.parentNode) { |
|||
node.parentNode.replaceChild(fragment, node); |
|||
} |
|||
}); |
|||
} |
|||
/* ------------------------------------------------------------------------- |
|||
* Hardware benchmark table search |
|||
* ------------------------------------------------------------------------- */ |
|||
mw.hook('wikipage.content').add(function ($content) { |
mw.hook('wikipage.content').add(function ($content) { |
||
'use strict'; |
'use strict'; |
||
var action = mw.config.get('wgAction'); |
|||
/* Do not run inside edit or preview screens */ |
|||
if (action === 'edit' || action === 'submit') { |
|||
return; |
|||
} |
|||
$content.find('.benchmark-results-table').each(function () { |
$content.find('.benchmark-results-table').each(function () { |
||
| Line 14: | Line 258: | ||
table.dataset.searchInitialised = 'true'; |
table.dataset.searchInitialised = 'true'; |
||
/* Create a wrapper that follows the table's natural width */ |
|||
var tableBlock = document.createElement('div'); |
var tableBlock = document.createElement('div'); |
||
tableBlock.className = 'benchmark-table-block'; |
tableBlock.className = 'benchmark-table-block'; |
||
| Line 32: | Line 275: | ||
input.placeholder = |
input.placeholder = |
||
'Search processor, system name, RAM, or benchmark result'; |
'Search processor, system name, RAM, or benchmark result'; |
||
input.setAttribute( |
input.setAttribute('aria-label', 'Search benchmark results'); |
||
'aria-label', |
|||
'Search benchmark results' |
|||
); |
|||
var clearButton = document.createElement('button'); |
var clearButton = document.createElement('button'); |
||
| Line 54: | Line 294: | ||
tableBlock.insertBefore(controls, table); |
tableBlock.insertBefore(controls, table); |
||
var rows = Array.from( |
var rows = Array.from(table.querySelectorAll('tr')).filter(function (row) { |
||
table.querySelectorAll('tr') |
|||
).filter(function (row) { |
|||
return row.querySelectorAll('td').length > 0; |
return row.querySelectorAll('td').length > 0; |
||
}); |
}); |
||
function normaliseText(value) { |
|||
return value |
|||
.toLocaleLowerCase() |
|||
.replace(/\s+/g, ' ') |
|||
.trim(); |
|||
} |
|||
function filterRows() { |
function filterRows() { |
||
var |
var rawQuery = input.value.trim(); |
||
var visibleCount = 0; |
var visibleCount = 0; |
||
rows.forEach(function (row) { |
rows.forEach(function (row) { |
||
tuflowClearHighlights(row); |
|||
query === '' || |
|||
var matches = tuflowMatchesSearch( |
|||
row.textContent, |
|||
rawQuery |
|||
); |
|||
row.hidden = !matches; |
row.hidden = !matches; |
||
| Line 80: | Line 314: | ||
if (matches) { |
if (matches) { |
||
visibleCount += 1; |
visibleCount += 1; |
||
if (rawQuery !== '') { |
|||
tuflowHighlightMatches(row, rawQuery); |
|||
} |
|||
} |
} |
||
}); |
}); |
||
resultCount.textContent = |
resultCount.textContent = rawQuery |
||
? visibleCount + |
? visibleCount + ' matching result' + (visibleCount === 1 ? '' : 's') |
||
' matching result' + |
|||
(visibleCount === 1 ? '' : 's') |
|||
: rows.length + ' benchmark results'; |
: rows.length + ' benchmark results'; |
||
clearButton.hidden = |
clearButton.hidden = rawQuery === ''; |
||
} |
} |
||
| Line 104: | Line 340: | ||
}); |
}); |
||
/* Add live search to Example Model Catalogue tables */ |
|||
/* ------------------------------------------------------------------------- |
|||
* Category-aware Example Model Catalogue search |
|||
* ------------------------------------------------------------------------- */ |
|||
mw.hook('wikipage.content').add(function ($content) { |
mw.hook('wikipage.content').add(function ($content) { |
||
'use strict'; |
'use strict'; |
||
var action = mw.config.get('wgAction'); |
|||
/* Allow preview testing, but not the initial edit screen */ |
|||
if (action === 'edit') { |
|||
return; |
|||
} |
|||
$content.find('.example-model-results-table').each(function () { |
$content.find('.example-model-results-table').each(function () { |
||
| Line 118: | Line 365: | ||
/* |
/* |
||
* |
* Keep the search controls outside the responsive table wrapper. |
||
* |
* This prevents the search field from being clipped on desktop. |
||
*/ |
*/ |
||
var tableWrapper = table.closest('.example-model-table-wrapper'); |
|||
var tableBlock = document.createElement('div'); |
var tableBlock = document.createElement('div'); |
||
tableBlock.className = |
tableBlock.className = |
||
'benchmark-table-block example-model-table-block'; |
|||
tableBlock.style.display = 'block'; |
|||
tableBlock.style.width = '100%'; |
|||
tableBlock.style.maxWidth = '100%'; |
|||
tableBlock.style.marginLeft = '0'; |
|||
tableBlock.style.marginRight = '0'; |
|||
if (tableWrapper && tableWrapper.parentNode) { |
|||
tableWrapper.parentNode.insertBefore( |
|||
tableBlock, |
|||
tableWrapper |
|||
); |
|||
tableBlock.appendChild(tableWrapper); |
|||
} else { |
|||
table.parentNode.insertBefore( |
|||
tableBlock, |
|||
table |
|||
); |
|||
tableBlock.appendChild(table); |
|||
} |
|||
table.style.boxSizing = 'border-box'; |
|||
table.style.width = '100%'; |
|||
table.style.minWidth = '100%'; |
|||
var controls = document.createElement('div'); |
|||
controls.className = |
|||
'benchmark-table-controls example-model-table-controls'; |
|||
controls.style.boxSizing = 'border-box'; |
|||
var searchWrapper = document.createElement('div'); |
|||
searchWrapper.className = 'benchmark-table-search'; |
|||
var input = document.createElement('input'); |
|||
input.type = 'search'; |
|||
input.className = 'benchmark-search-input'; |
|||
input.placeholder = |
|||
'Search model category, description, or model name'; |
|||
input.setAttribute( |
|||
'aria-label', |
|||
'Search Example Model Catalogue' |
|||
); |
|||
var clearButton = document.createElement('button'); |
|||
clearButton.type = 'button'; |
|||
clearButton.className = 'benchmark-search-clear'; |
|||
clearButton.textContent = 'Clear'; |
|||
var resultCount = document.createElement('div'); |
|||
resultCount.className = 'benchmark-result-count'; |
|||
resultCount.setAttribute('aria-live', 'polite'); |
|||
searchWrapper.appendChild(input); |
|||
searchWrapper.appendChild(clearButton); |
|||
controls.appendChild(searchWrapper); |
|||
controls.appendChild(resultCount); |
|||
if (tableWrapper) { |
|||
tableBlock.insertBefore( |
|||
controls, |
|||
tableWrapper |
|||
); |
|||
} else { |
|||
tableBlock.insertBefore( |
|||
controls, |
|||
table |
|||
); |
|||
} |
|||
/* |
|||
* Catalogue logical columns: |
|||
* 0: Major category |
|||
* 1: Subcategory |
|||
* 2: Description |
|||
* 3: Model name |
|||
*/ |
|||
var originalRows = Array.from( |
|||
table.querySelectorAll('tr') |
|||
).filter(function (row) { |
|||
return row.querySelectorAll('td').length > 0; |
|||
}); |
|||
if (!originalRows.length) { |
|||
controls.remove(); |
|||
return; |
|||
} |
|||
var rowParent = originalRows[0].parentNode; |
|||
var activeRowspans = []; |
|||
var records = []; |
|||
function getCellText(cell) { |
|||
return cell |
|||
? tuflowNormaliseSearchText(cell.textContent) |
|||
: ''; |
|||
} |
|||
originalRows.forEach(function (row, rowIndex) { |
|||
var logicalCells = []; |
|||
var physicalCells = Array.from(row.cells); |
|||
var columnIndex = 0; |
|||
activeRowspans.forEach(function (active, index) { |
|||
if (active && active.endRow >= rowIndex) { |
|||
logicalCells[index] = active.cell; |
|||
} |
|||
}); |
|||
physicalCells.forEach(function (cell) { |
|||
while (logicalCells[columnIndex]) { |
|||
columnIndex += 1; |
|||
} |
|||
var colspan = Math.max(cell.colSpan || 1, 1); |
|||
var rowspan = Math.max(cell.rowSpan || 1, 1); |
|||
for ( |
|||
var offset = 0; |
|||
offset < colspan; |
|||
offset += 1 |
|||
) { |
|||
var logicalColumn = columnIndex + offset; |
|||
logicalCells[logicalColumn] = cell; |
|||
if (rowspan > 1) { |
|||
activeRowspans[logicalColumn] = { |
|||
cell: cell, |
|||
endRow: rowIndex + rowspan - 1 |
|||
}; |
|||
} |
|||
} |
|||
columnIndex += colspan; |
|||
}); |
|||
var categoryCell = logicalCells[0] || null; |
|||
var subcategoryCell = logicalCells[1] || null; |
|||
var descriptionCell = logicalCells[2] || null; |
|||
var modelNameCell = logicalCells[3] || null; |
|||
var categorySpansBoth = |
|||
categoryCell && |
|||
subcategoryCell && |
|||
categoryCell === subcategoryCell; |
|||
var category = getCellText(categoryCell); |
|||
var subcategory = categorySpansBoth |
|||
? '' |
|||
: getCellText(subcategoryCell); |
|||
var description = getCellText(descriptionCell); |
|||
var modelName = getCellText(modelNameCell); |
|||
records.push({ |
|||
originalRow: row, |
|||
categoryCell: categoryCell, |
|||
subcategoryCell: categorySpansBoth |
|||
? null |
|||
: subcategoryCell, |
|||
descriptionCell: descriptionCell, |
|||
modelNameCell: modelNameCell, |
|||
category: category, |
|||
subcategory: subcategory, |
|||
categoryKey: |
|||
category || '__uncategorised__', |
|||
subcategoryKey: |
|||
subcategory || '__no_subcategory__', |
|||
searchableText: tuflowNormaliseSearchText( |
|||
[ |
|||
category, |
|||
subcategory, |
|||
description, |
|||
modelName |
|||
].join(' ') |
|||
) |
|||
}); |
|||
}); |
|||
function cloneCell(cell) { |
|||
var clone; |
|||
if (!cell) { |
|||
return document.createElement('td'); |
|||
} |
|||
clone = cell.cloneNode(true); |
|||
clone.removeAttribute('rowspan'); |
|||
clone.removeAttribute('colspan'); |
|||
return clone; |
|||
} |
|||
function removeDisplayedDataRows() { |
|||
Array.from( |
|||
table.querySelectorAll('tr') |
|||
).forEach(function (row) { |
|||
if (row.querySelectorAll('td').length > 0) { |
|||
row.remove(); |
|||
} |
|||
}); |
|||
} |
|||
function restoreOriginalRows() { |
|||
removeDisplayedDataRows(); |
|||
records.forEach(function (record) { |
|||
tuflowClearHighlights(record.originalRow); |
|||
rowParent.appendChild(record.originalRow); |
|||
}); |
|||
} |
|||
function getRunLength( |
|||
items, |
|||
startIndex, |
|||
keyFunction |
|||
) { |
|||
var startingKey = |
|||
keyFunction(items[startIndex]); |
|||
var count = 1; |
|||
var index; |
|||
for ( |
|||
index = startIndex + 1; |
|||
index < items.length; |
|||
index += 1 |
|||
) { |
|||
if ( |
|||
keyFunction(items[index]) !== startingKey |
|||
) { |
|||
break; |
|||
} |
|||
count += 1; |
|||
} |
|||
return count; |
|||
} |
|||
function renderFilteredRows(matches, query) { |
|||
removeDisplayedDataRows(); |
|||
matches.forEach(function (record, index) { |
|||
var previous = |
|||
index > 0 ? matches[index - 1] : null; |
|||
var categoryStarts = |
|||
!previous || |
|||
previous.categoryKey !== |
|||
record.categoryKey; |
|||
var subcategoryStarts = |
|||
categoryStarts || |
|||
previous.subcategoryKey !== |
|||
record.subcategoryKey; |
|||
var row = document.createElement('tr'); |
|||
if (categoryStarts) { |
|||
var categoryCell = |
|||
cloneCell(record.categoryCell); |
|||
var categoryRowspan = getRunLength( |
|||
matches, |
|||
index, |
|||
function (item) { |
|||
return item.categoryKey; |
|||
} |
|||
); |
|||
categoryCell.rowSpan = categoryRowspan; |
|||
if (!record.subcategory) { |
|||
categoryCell.colSpan = 2; |
|||
} |
|||
row.appendChild(categoryCell); |
|||
} |
|||
if ( |
|||
record.subcategory && |
|||
subcategoryStarts |
|||
) { |
|||
var subcategoryCell = |
|||
cloneCell(record.subcategoryCell); |
|||
var subcategoryRowspan = getRunLength( |
|||
matches, |
|||
index, |
|||
function (item) { |
|||
return ( |
|||
item.categoryKey + |
|||
'::' + |
|||
item.subcategoryKey |
|||
); |
|||
} |
|||
); |
|||
subcategoryCell.rowSpan = |
|||
subcategoryRowspan; |
|||
row.appendChild(subcategoryCell); |
|||
} |
|||
row.appendChild( |
|||
cloneCell(record.descriptionCell) |
|||
); |
|||
row.appendChild( |
|||
cloneCell(record.modelNameCell) |
|||
); |
|||
if (query) { |
|||
tuflowHighlightMatches(row, query); |
|||
} |
|||
rowParent.appendChild(row); |
|||
}); |
|||
} |
|||
function filterRows() { |
|||
var rawQuery = input.value.trim(); |
|||
var matches; |
|||
if (!rawQuery) { |
|||
restoreOriginalRows(); |
|||
resultCount.textContent = |
|||
records.length + ' example models'; |
|||
clearButton.hidden = true; |
|||
return; |
|||
} |
|||
matches = records.filter(function (record) { |
|||
return tuflowMatchesSearch( |
|||
record.searchableText, |
|||
rawQuery |
|||
); |
|||
}); |
|||
renderFilteredRows(matches, rawQuery); |
|||
resultCount.textContent = |
|||
matches.length + |
|||
' matching model' + |
|||
(matches.length === 1 ? '' : 's'); |
|||
clearButton.hidden = false; |
|||
} |
|||
input.addEventListener('input', filterRows); |
|||
clearButton.addEventListener( |
|||
'click', |
|||
function () { |
|||
input.value = ''; |
|||
filterRows(); |
|||
input.focus(); |
|||
} |
|||
); |
|||
filterRows(); |
|||
}); |
|||
}); |
|||
/* ------------------------------------------------------------------------- |
|||
* QGIS Useful Plugins table search |
|||
* ------------------------------------------------------------------------- */ |
|||
mw.hook('wikipage.content').add(function ($content) { |
|||
'use strict'; |
|||
var action = mw.config.get('wgAction'); |
|||
/* Allow preview testing, but not the initial edit screen */ |
|||
if (action === 'edit') { |
|||
return; |
|||
} |
|||
$content.find('.qgis-plugin-results-table').each(function () { |
|||
var table = this; |
|||
if (table.dataset.pluginSearchInitialised === 'true') { |
|||
return; |
|||
} |
|||
table.dataset.pluginSearchInitialised = 'true'; |
|||
var tableBlock = document.createElement('div'); |
|||
tableBlock.className = |
|||
'benchmark-table-block qgis-plugin-table-block'; |
|||
tableBlock.style.display = 'block'; |
|||
tableBlock.style.width = '100%'; |
|||
tableBlock.style.maxWidth = '100%'; |
|||
tableBlock.style.marginLeft = '0'; |
|||
tableBlock.style.marginRight = '0'; |
|||
table.parentNode.insertBefore(tableBlock, table); |
table.parentNode.insertBefore(tableBlock, table); |
||
tableBlock.appendChild(table); |
tableBlock.appendChild(table); |
||
table.style.boxSizing = 'border-box'; |
|||
table.style.width = '100%'; |
|||
table.style.minWidth = '100%'; |
|||
var controls = document.createElement('div'); |
var controls = document.createElement('div'); |
||
controls.className = 'benchmark-table-controls'; |
controls.className = 'benchmark-table-controls'; |
||
controls.style.boxSizing = 'border-box'; |
|||
var searchWrapper = document.createElement('div'); |
var searchWrapper = document.createElement('div'); |
||
| Line 137: | Line 792: | ||
input.className = 'benchmark-search-input'; |
input.className = 'benchmark-search-input'; |
||
input.placeholder = |
input.placeholder = |
||
'Search |
'Search plugin name, description, or installation source'; |
||
input.setAttribute( |
input.setAttribute( |
||
'aria-label', |
'aria-label', |
||
'Search |
'Search QGIS plugins' |
||
); |
); |
||
| Line 167: | Line 822: | ||
function normaliseText(value) { |
function normaliseText(value) { |
||
return value |
return String(value || '') |
||
.toLocaleLowerCase() |
.toLocaleLowerCase() |
||
.replace(/\s+/g, ' ') |
.replace(/\s+/g, ' ') |
||
.trim(); |
.trim(); |
||
} |
|||
function clearHighlights(root) { |
|||
Array.from( |
|||
root.querySelectorAll('mark.search-highlight') |
|||
).forEach(function (mark) { |
|||
var parent = mark.parentNode; |
|||
if (!parent) { |
|||
return; |
|||
} |
|||
parent.replaceChild( |
|||
document.createTextNode(mark.textContent), |
|||
mark |
|||
); |
|||
parent.normalize(); |
|||
}); |
|||
} |
|||
function escapeRegExp(value) { |
|||
return String(value).replace( |
|||
/[.*+?^${}()|[\]\\]/g, |
|||
'\\$&' |
|||
); |
|||
} |
|||
function highlightMatches(root, query) { |
|||
var pattern; |
|||
var walker; |
|||
var matchingNodes = []; |
|||
var node; |
|||
if (!query) { |
|||
return; |
|||
} |
|||
pattern = new RegExp( |
|||
escapeRegExp(query), |
|||
'ig' |
|||
); |
|||
walker = document.createTreeWalker( |
|||
root, |
|||
NodeFilter.SHOW_TEXT, |
|||
{ |
|||
acceptNode: function (textNode) { |
|||
if ( |
|||
!textNode.nodeValue || |
|||
!textNode.nodeValue.trim() |
|||
) { |
|||
return NodeFilter.FILTER_REJECT; |
|||
} |
|||
if ( |
|||
textNode.parentNode && |
|||
textNode.parentNode.closest( |
|||
'mark, script, style, input, button' |
|||
) |
|||
) { |
|||
return NodeFilter.FILTER_REJECT; |
|||
} |
|||
return pattern.test(textNode.nodeValue) |
|||
? NodeFilter.FILTER_ACCEPT |
|||
: NodeFilter.FILTER_REJECT; |
|||
} |
|||
} |
|||
); |
|||
while ((node = walker.nextNode())) { |
|||
matchingNodes.push(node); |
|||
} |
|||
matchingNodes.forEach(function (textNode) { |
|||
var text = textNode.nodeValue; |
|||
var fragment = |
|||
document.createDocumentFragment(); |
|||
var lastIndex = 0; |
|||
text.replace( |
|||
pattern, |
|||
function (match, offset) { |
|||
if (offset > lastIndex) { |
|||
fragment.appendChild( |
|||
document.createTextNode( |
|||
text.slice( |
|||
lastIndex, |
|||
offset |
|||
) |
|||
) |
|||
); |
|||
} |
|||
var mark = |
|||
document.createElement('mark'); |
|||
mark.className = 'search-highlight'; |
|||
mark.textContent = match; |
|||
fragment.appendChild(mark); |
|||
lastIndex = offset + match.length; |
|||
return match; |
|||
} |
|||
); |
|||
if (lastIndex < text.length) { |
|||
fragment.appendChild( |
|||
document.createTextNode( |
|||
text.slice(lastIndex) |
|||
) |
|||
); |
|||
} |
|||
textNode.parentNode.replaceChild( |
|||
fragment, |
|||
textNode |
|||
); |
|||
}); |
|||
} |
} |
||
function filterRows() { |
function filterRows() { |
||
var |
var rawQuery = input.value.trim(); |
||
var visibleCount = 0; |
var visibleCount = 0; |
||
rows.forEach(function (row) { |
rows.forEach(function (row) { |
||
tuflowClearHighlights(row); |
|||
query === '' || |
|||
var matches = tuflowMatchesSearch( |
|||
row.textContent, |
|||
rawQuery |
|||
); |
|||
row.hidden = !matches; |
row.hidden = !matches; |
||
| Line 186: | Line 965: | ||
if (matches) { |
if (matches) { |
||
visibleCount += 1; |
visibleCount += 1; |
||
if (rawQuery) { |
|||
tuflowHighlightMatches(row, rawQuery); |
|||
} |
|||
} |
} |
||
}); |
}); |
||
resultCount.textContent = |
resultCount.textContent = rawQuery |
||
? visibleCount + |
? visibleCount + |
||
' matching |
' matching plugin' + |
||
(visibleCount === 1 ? '' : 's') |
(visibleCount === 1 ? '' : 's') |
||
: rows.length + ' |
: rows.length + ' QGIS plugins'; |
||
clearButton.hidden = |
clearButton.hidden = rawQuery === ''; |
||
} |
} |
||
input.addEventListener('input', filterRows); |
input.addEventListener('input', filterRows); |
||
clearButton.addEventListener( |
clearButton.addEventListener( |
||
'click', |
|||
function () { |
|||
input. |
input.value = ''; |
||
filterRows(); |
|||
input.focus(); |
|||
} |
|||
); |
|||
filterRows(); |
filterRows(); |
||
Revision as of 15:00, 16 July 2026
/* Any JavaScript here will be loaded for all users on every page load. */
/* -------------------------------------------------------------------------
* Shared helpers
* ------------------------------------------------------------------------- */
function tuflowNormaliseSearchText(value) {
return String(value || '')
.toLocaleLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
/*
* Parse a query into OR groups containing AND terms.
*
* Examples:
* raster terrain
* raster AND terrain
* QuickOSM OR HCMGIS
* "repository install" AND raster
*/
function tuflowParseSearchQuery(rawQuery) {
var query = String(rawQuery || '').trim();
if (!query) {
return [];
}
return query.split(/\s+OR\s+/i).map(function (group) {
var terms = [];
var pattern = /"([^"]+)"|(\S+)/g;
var match;
while ((match = pattern.exec(group)) !== null) {
var term = match[1] || match[2];
if (!/^AND$/i.test(term)) {
term = tuflowNormaliseSearchText(term);
if (term) {
terms.push(term);
}
}
}
return terms;
}).filter(function (group) {
return group.length > 0;
});
}
function tuflowMatchesSearch(text, rawQuery) {
var normalisedText = tuflowNormaliseSearchText(text);
var groups = tuflowParseSearchQuery(rawQuery);
if (!groups.length) {
return true;
}
return groups.some(function (terms) {
return terms.every(function (term) {
return normalisedText.includes(term);
});
});
}
function tuflowGetSearchTerms(rawQuery) {
var terms = [];
tuflowParseSearchQuery(rawQuery).forEach(function (group) {
group.forEach(function (term) {
if (terms.indexOf(term) === -1) {
terms.push(term);
}
});
});
return terms.sort(function (a, b) {
return b.length - a.length;
});
}
function tuflowEscapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function tuflowClearHighlights(root) {
if (!root) {
return;
}
Array.from(root.querySelectorAll('mark.search-highlight')).forEach(function (mark) {
var textNode = document.createTextNode(mark.textContent);
var parent = mark.parentNode;
if (!parent) {
return;
}
parent.replaceChild(textNode, mark);
parent.normalize();
});
}
function tuflowHighlightMatches(root, rawQuery) {
if (!root || !rawQuery) {
return;
}
tuflowClearHighlights(root);
var terms = tuflowGetSearchTerms(rawQuery);
if (!terms.length) {
return;
}
/*
* Longer terms are checked first so that phrases such as
* "repository install" are highlighted before "repository".
*/
terms.sort(function (a, b) {
return b.length - a.length;
});
var escapedTerms = terms.map(function (term) {
return tuflowEscapeRegExp(term);
});
var testPattern = new RegExp(
escapedTerms.join('|'),
'i'
);
var replacePattern = new RegExp(
escapedTerms.join('|'),
'ig'
);
var walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (node) {
if (!node.nodeValue || !node.nodeValue.trim()) {
return NodeFilter.FILTER_REJECT;
}
if (
node.parentNode &&
[
'SCRIPT',
'STYLE',
'MARK',
'TEXTAREA',
'INPUT',
'BUTTON'
].indexOf(node.parentNode.nodeName) !== -1
) {
return NodeFilter.FILTER_REJECT;
}
return testPattern.test(node.nodeValue)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
}
);
var textNodes = [];
var currentNode;
while ((currentNode = walker.nextNode())) {
textNodes.push(currentNode);
}
textNodes.forEach(function (node) {
var text = node.nodeValue;
var fragment = document.createDocumentFragment();
var lastIndex = 0;
text.replace(
replacePattern,
function (match, offset) {
if (offset > lastIndex) {
fragment.appendChild(
document.createTextNode(
text.slice(lastIndex, offset)
)
);
}
var normalisedMatch =
tuflowNormaliseSearchText(match);
var termIndex = terms.findIndex(function (term) {
return term === normalisedMatch;
});
/*
* Cycle through five highlight colours.
*/
var colourNumber =
(Math.max(termIndex, 0) % 5) + 1;
var mark = document.createElement('mark');
mark.className =
'search-highlight search-highlight-' +
colourNumber;
mark.textContent = match;
fragment.appendChild(mark);
lastIndex = offset + match.length;
return match;
}
);
if (lastIndex < text.length) {
fragment.appendChild(
document.createTextNode(
text.slice(lastIndex)
)
);
}
if (node.parentNode) {
node.parentNode.replaceChild(fragment, node);
}
});
}
/* -------------------------------------------------------------------------
* Hardware benchmark table search
* ------------------------------------------------------------------------- */
mw.hook('wikipage.content').add(function ($content) {
'use strict';
var action = mw.config.get('wgAction');
/* Do not run inside edit or preview screens */
if (action === 'edit' || action === 'submit') {
return;
}
$content.find('.benchmark-results-table').each(function () {
var table = this;
if (table.dataset.searchInitialised === 'true') {
return;
}
table.dataset.searchInitialised = 'true';
var tableBlock = document.createElement('div');
tableBlock.className = 'benchmark-table-block';
table.parentNode.insertBefore(tableBlock, table);
tableBlock.appendChild(table);
var controls = document.createElement('div');
controls.className = 'benchmark-table-controls';
var searchWrapper = document.createElement('div');
searchWrapper.className = 'benchmark-table-search';
var input = document.createElement('input');
input.type = 'search';
input.className = 'benchmark-search-input';
input.placeholder =
'Search processor, system name, RAM, or benchmark result';
input.setAttribute('aria-label', 'Search benchmark results');
var clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'benchmark-search-clear';
clearButton.textContent = 'Clear';
var resultCount = document.createElement('div');
resultCount.className = 'benchmark-result-count';
resultCount.setAttribute('aria-live', 'polite');
searchWrapper.appendChild(input);
searchWrapper.appendChild(clearButton);
controls.appendChild(searchWrapper);
controls.appendChild(resultCount);
tableBlock.insertBefore(controls, table);
var rows = Array.from(table.querySelectorAll('tr')).filter(function (row) {
return row.querySelectorAll('td').length > 0;
});
function filterRows() {
var rawQuery = input.value.trim();
var visibleCount = 0;
rows.forEach(function (row) {
tuflowClearHighlights(row);
var matches = tuflowMatchesSearch(
row.textContent,
rawQuery
);
row.hidden = !matches;
if (matches) {
visibleCount += 1;
if (rawQuery !== '') {
tuflowHighlightMatches(row, rawQuery);
}
}
});
resultCount.textContent = rawQuery
? visibleCount + ' matching result' + (visibleCount === 1 ? '' : 's')
: rows.length + ' benchmark results';
clearButton.hidden = rawQuery === '';
}
input.addEventListener('input', filterRows);
clearButton.addEventListener('click', function () {
input.value = '';
filterRows();
input.focus();
});
filterRows();
});
});
/* -------------------------------------------------------------------------
* Category-aware Example Model Catalogue search
* ------------------------------------------------------------------------- */
mw.hook('wikipage.content').add(function ($content) {
'use strict';
var action = mw.config.get('wgAction');
/* Allow preview testing, but not the initial edit screen */
if (action === 'edit') {
return;
}
$content.find('.example-model-results-table').each(function () {
var table = this;
if (table.dataset.exampleSearchInitialised === 'true') {
return;
}
table.dataset.exampleSearchInitialised = 'true';
/*
* Keep the search controls outside the responsive table wrapper.
* This prevents the search field from being clipped on desktop.
*/
var tableWrapper = table.closest('.example-model-table-wrapper');
var tableBlock = document.createElement('div');
tableBlock.className =
'benchmark-table-block example-model-table-block';
tableBlock.style.display = 'block';
tableBlock.style.width = '100%';
tableBlock.style.maxWidth = '100%';
tableBlock.style.marginLeft = '0';
tableBlock.style.marginRight = '0';
if (tableWrapper && tableWrapper.parentNode) {
tableWrapper.parentNode.insertBefore(
tableBlock,
tableWrapper
);
tableBlock.appendChild(tableWrapper);
} else {
table.parentNode.insertBefore(
tableBlock,
table
);
tableBlock.appendChild(table);
}
table.style.boxSizing = 'border-box';
table.style.width = '100%';
table.style.minWidth = '100%';
var controls = document.createElement('div');
controls.className =
'benchmark-table-controls example-model-table-controls';
controls.style.boxSizing = 'border-box';
var searchWrapper = document.createElement('div');
searchWrapper.className = 'benchmark-table-search';
var input = document.createElement('input');
input.type = 'search';
input.className = 'benchmark-search-input';
input.placeholder =
'Search model category, description, or model name';
input.setAttribute(
'aria-label',
'Search Example Model Catalogue'
);
var clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'benchmark-search-clear';
clearButton.textContent = 'Clear';
var resultCount = document.createElement('div');
resultCount.className = 'benchmark-result-count';
resultCount.setAttribute('aria-live', 'polite');
searchWrapper.appendChild(input);
searchWrapper.appendChild(clearButton);
controls.appendChild(searchWrapper);
controls.appendChild(resultCount);
if (tableWrapper) {
tableBlock.insertBefore(
controls,
tableWrapper
);
} else {
tableBlock.insertBefore(
controls,
table
);
}
/*
* Catalogue logical columns:
* 0: Major category
* 1: Subcategory
* 2: Description
* 3: Model name
*/
var originalRows = Array.from(
table.querySelectorAll('tr')
).filter(function (row) {
return row.querySelectorAll('td').length > 0;
});
if (!originalRows.length) {
controls.remove();
return;
}
var rowParent = originalRows[0].parentNode;
var activeRowspans = [];
var records = [];
function getCellText(cell) {
return cell
? tuflowNormaliseSearchText(cell.textContent)
: '';
}
originalRows.forEach(function (row, rowIndex) {
var logicalCells = [];
var physicalCells = Array.from(row.cells);
var columnIndex = 0;
activeRowspans.forEach(function (active, index) {
if (active && active.endRow >= rowIndex) {
logicalCells[index] = active.cell;
}
});
physicalCells.forEach(function (cell) {
while (logicalCells[columnIndex]) {
columnIndex += 1;
}
var colspan = Math.max(cell.colSpan || 1, 1);
var rowspan = Math.max(cell.rowSpan || 1, 1);
for (
var offset = 0;
offset < colspan;
offset += 1
) {
var logicalColumn = columnIndex + offset;
logicalCells[logicalColumn] = cell;
if (rowspan > 1) {
activeRowspans[logicalColumn] = {
cell: cell,
endRow: rowIndex + rowspan - 1
};
}
}
columnIndex += colspan;
});
var categoryCell = logicalCells[0] || null;
var subcategoryCell = logicalCells[1] || null;
var descriptionCell = logicalCells[2] || null;
var modelNameCell = logicalCells[3] || null;
var categorySpansBoth =
categoryCell &&
subcategoryCell &&
categoryCell === subcategoryCell;
var category = getCellText(categoryCell);
var subcategory = categorySpansBoth
? ''
: getCellText(subcategoryCell);
var description = getCellText(descriptionCell);
var modelName = getCellText(modelNameCell);
records.push({
originalRow: row,
categoryCell: categoryCell,
subcategoryCell: categorySpansBoth
? null
: subcategoryCell,
descriptionCell: descriptionCell,
modelNameCell: modelNameCell,
category: category,
subcategory: subcategory,
categoryKey:
category || '__uncategorised__',
subcategoryKey:
subcategory || '__no_subcategory__',
searchableText: tuflowNormaliseSearchText(
[
category,
subcategory,
description,
modelName
].join(' ')
)
});
});
function cloneCell(cell) {
var clone;
if (!cell) {
return document.createElement('td');
}
clone = cell.cloneNode(true);
clone.removeAttribute('rowspan');
clone.removeAttribute('colspan');
return clone;
}
function removeDisplayedDataRows() {
Array.from(
table.querySelectorAll('tr')
).forEach(function (row) {
if (row.querySelectorAll('td').length > 0) {
row.remove();
}
});
}
function restoreOriginalRows() {
removeDisplayedDataRows();
records.forEach(function (record) {
tuflowClearHighlights(record.originalRow);
rowParent.appendChild(record.originalRow);
});
}
function getRunLength(
items,
startIndex,
keyFunction
) {
var startingKey =
keyFunction(items[startIndex]);
var count = 1;
var index;
for (
index = startIndex + 1;
index < items.length;
index += 1
) {
if (
keyFunction(items[index]) !== startingKey
) {
break;
}
count += 1;
}
return count;
}
function renderFilteredRows(matches, query) {
removeDisplayedDataRows();
matches.forEach(function (record, index) {
var previous =
index > 0 ? matches[index - 1] : null;
var categoryStarts =
!previous ||
previous.categoryKey !==
record.categoryKey;
var subcategoryStarts =
categoryStarts ||
previous.subcategoryKey !==
record.subcategoryKey;
var row = document.createElement('tr');
if (categoryStarts) {
var categoryCell =
cloneCell(record.categoryCell);
var categoryRowspan = getRunLength(
matches,
index,
function (item) {
return item.categoryKey;
}
);
categoryCell.rowSpan = categoryRowspan;
if (!record.subcategory) {
categoryCell.colSpan = 2;
}
row.appendChild(categoryCell);
}
if (
record.subcategory &&
subcategoryStarts
) {
var subcategoryCell =
cloneCell(record.subcategoryCell);
var subcategoryRowspan = getRunLength(
matches,
index,
function (item) {
return (
item.categoryKey +
'::' +
item.subcategoryKey
);
}
);
subcategoryCell.rowSpan =
subcategoryRowspan;
row.appendChild(subcategoryCell);
}
row.appendChild(
cloneCell(record.descriptionCell)
);
row.appendChild(
cloneCell(record.modelNameCell)
);
if (query) {
tuflowHighlightMatches(row, query);
}
rowParent.appendChild(row);
});
}
function filterRows() {
var rawQuery = input.value.trim();
var matches;
if (!rawQuery) {
restoreOriginalRows();
resultCount.textContent =
records.length + ' example models';
clearButton.hidden = true;
return;
}
matches = records.filter(function (record) {
return tuflowMatchesSearch(
record.searchableText,
rawQuery
);
});
renderFilteredRows(matches, rawQuery);
resultCount.textContent =
matches.length +
' matching model' +
(matches.length === 1 ? '' : 's');
clearButton.hidden = false;
}
input.addEventListener('input', filterRows);
clearButton.addEventListener(
'click',
function () {
input.value = '';
filterRows();
input.focus();
}
);
filterRows();
});
});
/* -------------------------------------------------------------------------
* QGIS Useful Plugins table search
* ------------------------------------------------------------------------- */
mw.hook('wikipage.content').add(function ($content) {
'use strict';
var action = mw.config.get('wgAction');
/* Allow preview testing, but not the initial edit screen */
if (action === 'edit') {
return;
}
$content.find('.qgis-plugin-results-table').each(function () {
var table = this;
if (table.dataset.pluginSearchInitialised === 'true') {
return;
}
table.dataset.pluginSearchInitialised = 'true';
var tableBlock = document.createElement('div');
tableBlock.className =
'benchmark-table-block qgis-plugin-table-block';
tableBlock.style.display = 'block';
tableBlock.style.width = '100%';
tableBlock.style.maxWidth = '100%';
tableBlock.style.marginLeft = '0';
tableBlock.style.marginRight = '0';
table.parentNode.insertBefore(tableBlock, table);
tableBlock.appendChild(table);
table.style.boxSizing = 'border-box';
table.style.width = '100%';
table.style.minWidth = '100%';
var controls = document.createElement('div');
controls.className = 'benchmark-table-controls';
controls.style.boxSizing = 'border-box';
var searchWrapper = document.createElement('div');
searchWrapper.className = 'benchmark-table-search';
var input = document.createElement('input');
input.type = 'search';
input.className = 'benchmark-search-input';
input.placeholder =
'Search plugin name, description, or installation source';
input.setAttribute(
'aria-label',
'Search QGIS plugins'
);
var clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'benchmark-search-clear';
clearButton.textContent = 'Clear';
var resultCount = document.createElement('div');
resultCount.className = 'benchmark-result-count';
resultCount.setAttribute('aria-live', 'polite');
searchWrapper.appendChild(input);
searchWrapper.appendChild(clearButton);
controls.appendChild(searchWrapper);
controls.appendChild(resultCount);
tableBlock.insertBefore(controls, table);
var rows = Array.from(
table.querySelectorAll('tr')
).filter(function (row) {
return row.querySelectorAll('td').length > 0;
});
function normaliseText(value) {
return String(value || '')
.toLocaleLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
function clearHighlights(root) {
Array.from(
root.querySelectorAll('mark.search-highlight')
).forEach(function (mark) {
var parent = mark.parentNode;
if (!parent) {
return;
}
parent.replaceChild(
document.createTextNode(mark.textContent),
mark
);
parent.normalize();
});
}
function escapeRegExp(value) {
return String(value).replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
}
function highlightMatches(root, query) {
var pattern;
var walker;
var matchingNodes = [];
var node;
if (!query) {
return;
}
pattern = new RegExp(
escapeRegExp(query),
'ig'
);
walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: function (textNode) {
if (
!textNode.nodeValue ||
!textNode.nodeValue.trim()
) {
return NodeFilter.FILTER_REJECT;
}
if (
textNode.parentNode &&
textNode.parentNode.closest(
'mark, script, style, input, button'
)
) {
return NodeFilter.FILTER_REJECT;
}
return pattern.test(textNode.nodeValue)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
}
);
while ((node = walker.nextNode())) {
matchingNodes.push(node);
}
matchingNodes.forEach(function (textNode) {
var text = textNode.nodeValue;
var fragment =
document.createDocumentFragment();
var lastIndex = 0;
text.replace(
pattern,
function (match, offset) {
if (offset > lastIndex) {
fragment.appendChild(
document.createTextNode(
text.slice(
lastIndex,
offset
)
)
);
}
var mark =
document.createElement('mark');
mark.className = 'search-highlight';
mark.textContent = match;
fragment.appendChild(mark);
lastIndex = offset + match.length;
return match;
}
);
if (lastIndex < text.length) {
fragment.appendChild(
document.createTextNode(
text.slice(lastIndex)
)
);
}
textNode.parentNode.replaceChild(
fragment,
textNode
);
});
}
function filterRows() {
var rawQuery = input.value.trim();
var visibleCount = 0;
rows.forEach(function (row) {
tuflowClearHighlights(row);
var matches = tuflowMatchesSearch(
row.textContent,
rawQuery
);
row.hidden = !matches;
if (matches) {
visibleCount += 1;
if (rawQuery) {
tuflowHighlightMatches(row, rawQuery);
}
}
});
resultCount.textContent = rawQuery
? visibleCount +
' matching plugin' +
(visibleCount === 1 ? '' : 's')
: rows.length + ' QGIS plugins';
clearButton.hidden = rawQuery === '';
}
input.addEventListener('input', filterRows);
clearButton.addEventListener(
'click',
function () {
input.value = '';
filterRows();
input.focus();
}
);
filterRows();
});
});