blob: ca537fff85d51ec1e343db5d1a8d29e439034176 [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'dart:html';
import 'dart:js_util' as js_util;
void init() {
final document = window.document;
var searchBox = document.getElementById('search-box') as InputElement?;
var searchBody = document.getElementById('search-body') as InputElement?;
var searchSidebar =
document.getElementById('search-sidebar') as InputElement?;
void disableSearch() {
print('Could not activate search functionality.');
searchBox?.placeholder = 'Failed to initialize search';
searchBody?.placeholder = 'Failed to initialize search';
searchSidebar?.placeholder = 'Failed to initialize search';
}
var body = document.querySelector('body')!;
// If dartdoc did not add a base-href tag, we will need to add the relative
// path ourselves.
var htmlBase = '';
if (body.attributes['data-using-base-href'] == 'false') {
// Dartdoc stores the htmlBase in 'body[data-base-href]'.
htmlBase = body.attributes['data-base-href'] ?? '';
}
window.fetch('${htmlBase}index.json').then((response) async {
int code = js_util.getProperty(response, 'status');
if (code == 404) {
disableSearch();
return;
}
var textPromise = js_util.callMethod<Object>(response, 'text', []);
var text = await promiseToFuture<String>(textPromise);
var jsonIndex = (jsonDecode(text) as List).cast<Map<String, dynamic>>();
final index = jsonIndex.map((entry) => IndexItem.fromMap(entry)).toList();
// Initialize all three search fields.
if (searchBox != null) {
initializeSearch(searchBox, index, htmlBase);
}
if (searchBody != null) {
initializeSearch(searchBody, index, htmlBase);
}
if (searchSidebar != null) {
initializeSearch(searchSidebar, index, htmlBase);
}
});
}
const weights = {
'library': 2,
'class': 2,
'mixin': 3,
'extension': 3,
'typedef': 3,
'method': 4,
'accessor': 4,
'operator': 4,
'constant': 4,
'property': 4,
'constructor': 4,
};
List<IndexItem> findMatches(List<IndexItem> index, String query) {
if (query.isEmpty) {
return [];
}
var allMatches = <SearchMatch>[];
for (var element in index) {
void score(int value) {
value -= (element.overriddenDepth ?? 0) * 10;
var weightFactor = weights[element.type] ?? 4;
allMatches.add(SearchMatch(element, value / weightFactor));
}
var name = element.name;
var qualifiedName = element.qualifiedName;
var lowerName = name.toLowerCase();
var lowerQualifiedName = qualifiedName.toLowerCase();
var lowerQuery = query.toLowerCase();
if (name == query || qualifiedName == query || name == 'dart:$query') {
score(2000);
} else if (lowerName == 'dart:$lowerQuery') {
score(1800);
} else if (lowerName == lowerQuery || lowerQualifiedName == lowerQuery) {
score(1700);
} else if (query.length > 1) {
if (name.startsWith(query) || qualifiedName.startsWith(query)) {
score(750);
} else if (lowerName.startsWith(lowerQuery) ||
lowerQualifiedName.startsWith(lowerQuery)) {
score(650);
} else if (name.contains(query) || qualifiedName.contains(query)) {
score(500);
} else if (lowerName.contains(lowerQuery) ||
lowerQualifiedName.contains(query)) {
score(400);
}
}
}
allMatches.sort((SearchMatch a, SearchMatch b) {
var x = (b.score - a.score).round();
if (x == 0) {
return a.element.name.length - b.element.name.length;
}
return x;
});
return allMatches.map((match) => match.element).toList();
}
const minLength = 1;
const suggestionLimit = 10;
void initializeSearch(
InputElement input,
List<IndexItem> index,
String htmlBase,
) {
input.disabled = false;
input.setAttribute('placeholder', 'Search API Docs');
// Handle grabbing focus when the users types / outside of the input
document.addEventListener('keypress', (Event event) {
if (event is! KeyEvent) {
return;
}
if (event.code == 'Slash' && document.activeElement is! InputElement) {
event.preventDefault();
input.focus();
}
});
// Prepare elements
var wrapper = document.createElement('div');
wrapper.classes.add('tt-wrapper');
input.replaceWith(wrapper);
var inputHint = document.createElement('input') as InputElement;
inputHint.setAttribute('type', 'text');
inputHint.setAttribute('autocomplete', 'off');
inputHint.setAttribute('readonly', 'true');
inputHint.setAttribute('spellcheck', 'false');
inputHint.setAttribute('tabindex', '-1');
inputHint.classes
..add('typeahead')
..add('tt-hint');
wrapper.append(inputHint);
input.setAttribute('autocomplete', 'off');
input.setAttribute('spellcheck', 'false');
input.classes.add('tt-input');
wrapper.append(input);
var listBox = document.createElement('div');
listBox.setAttribute('role', 'listbox');
listBox.setAttribute('aria-expanded', 'false');
listBox.style.display = 'none';
listBox.classes.add('tt-menu');
var presentation = document.createElement('div');
presentation.classes.add('tt-elements');
listBox.append(presentation);
wrapper.append(listBox);
// Set up various search functionality.
String highlight(String text, String query) {
final sanitizedText = const HtmlEscape().convert(query);
return text.replaceAll(
query, "<strong class='tt-highlight'>$sanitizedText</strong>");
}
Element createSuggestion(String query, IndexItem match) {
var suggestion = document.createElement('div');
suggestion.setAttribute('data-href', match.href ?? '');
suggestion.classes.add('tt-suggestion');
var suggestionTitle = document.createElement('span');
suggestionTitle.classes.add('tt-suggestion-title');
suggestionTitle.innerHtml =
highlight('${match.name} ${match.type.toLowerCase()}', query);
suggestion.append(suggestionTitle);
if (match.enclosedBy != null) {
var fromLib = document.createElement('div');
fromLib.classes.add('search-from-lib');
fromLib.innerHtml = 'from ${highlight(match.enclosedBy!.name, query)}';
suggestion.append(fromLib);
}
suggestion.addEventListener('mousedown', (event) {
event.preventDefault();
});
suggestion.addEventListener('click', (event) {
if (match.href != null) {
window.location.assign('$htmlBase${match.href}');
event.preventDefault();
}
});
return suggestion;
}
String? storedValue;
var actualValue = '';
String? hint;
var suggestionElements = <Element>[];
var suggestionsInfo = <IndexItem>[];
int? selectedElement;
void setHint(String? value) {
hint = value;
inputHint.value = value ?? '';
}
void showSuggestions() {
if (presentation.hasChildNodes()) {
listBox.style.display = 'block';
listBox.setAttribute('aria-expanded', 'true');
}
}
void hideSuggestions() {
listBox.style.display = 'none';
listBox.setAttribute('aria-expanded', 'false');
}
void updateSuggestions(String query, List<IndexItem> suggestions) {
suggestionsInfo = [];
suggestionElements = [];
presentation.text = '';
if (suggestions.length < minLength) {
setHint(null);
hideSuggestions();
return;
}
for (final suggestion in suggestions) {
var element = createSuggestion(query, suggestion);
suggestionElements.add(element);
presentation.append(element);
}
suggestionsInfo = suggestions;
setHint(query + suggestions[0].name.substring(query.length));
selectedElement = null;
showSuggestions();
}
void handle(String? newValue, [bool forceUpdate = false]) {
if (actualValue == newValue && !forceUpdate) {
return;
}
if (newValue == null || newValue.isEmpty) {
updateSuggestions('', []);
return;
}
var suggestions = findMatches(index, newValue);
if (suggestions.length > suggestionLimit) {
suggestions = suggestions.sublist(0, suggestionLimit);
}
actualValue = newValue;
updateSuggestions(newValue, suggestions);
}
// Hook up events
input.addEventListener('focus', (Event event) {
handle(input.value, true);
});
input.addEventListener('blur', (Event event) {
selectedElement = null;
if (storedValue != null) {
input.value = storedValue;
storedValue = null;
}
hideSuggestions();
setHint(null);
});
input.addEventListener('input', (event) {
handle(input.value);
});
input.addEventListener('keydown', (Event event) {
if (suggestionElements.isEmpty) {
return;
}
if (event is! KeyEvent) {
return;
}
if (event.code == 'Enter') {
var selectingElement = selectedElement ?? 0;
var href = suggestionElements[selectingElement].dataset['href'];
if (href != null) {
window.location.assign('$htmlBase$href');
}
return;
}
if (event.code == 'Tab') {
if (selectedElement == null) {
// The user wants to fill the field with the hint
if (hint != null) {
input.value = hint;
handle(hint);
event.preventDefault();
}
} else {
// The user wants to fill the input field with their currently selected suggestion
handle(suggestionsInfo[selectedElement!].name);
storedValue = null;
selectedElement = null;
event.preventDefault();
}
return;
}
var lastIndex = suggestionElements.length - 1;
var previousSelectedElement = selectedElement;
if (event.code == 'ArrowUp') {
if (selectedElement == null) {
selectedElement = lastIndex;
} else if (selectedElement == 0) {
selectedElement = null;
} else {
selectedElement = (selectedElement! - 1);
}
} else if (event.code == 'ArrowDown') {
if (selectedElement == null) {
selectedElement = 0;
} else if (selectedElement == lastIndex) {
selectedElement = null;
} else {
selectedElement = (selectedElement! + 1);
}
} else {
if (storedValue != null) {
storedValue = null;
handle(input.value);
}
return;
}
if (previousSelectedElement != null) {
suggestionElements[previousSelectedElement].classes.remove('tt-cursor');
}
if (selectedElement != null) {
var selected = suggestionElements[selectedElement!];
selected.classes.add('tt-cursor');
// Guarantee the selected element is visible
if (selectedElement == 0) {
listBox.scrollTop = 0;
} else if (selectedElement == lastIndex) {
listBox.scrollTop = listBox.scrollHeight;
} else {
var offsetTop = selected.offsetTop;
var parentOffsetHeight = listBox.offsetHeight;
if (offsetTop < parentOffsetHeight ||
parentOffsetHeight < (offsetTop + selected.offsetHeight)) {
selected.scrollIntoView();
}
}
// Store the actual input value to display their currently selected item.
storedValue ??= input.value;
input.value = suggestionsInfo[selectedElement!].name;
setHint('');
} else if (storedValue != null && previousSelectedElement != null) {
// They are moving back to the input field, so return the stored value.
input.value = storedValue;
setHint(storedValue! +
suggestionsInfo[0].name.substring(storedValue!.length));
storedValue = null;
}
event.preventDefault();
});
}
class SearchMatch {
final IndexItem element;
final double score;
SearchMatch(this.element, this.score);
}
class IndexItem {
final String name;
final String qualifiedName;
final String type;
final String? href;
final int? overriddenDepth;
final EnclosedBy? enclosedBy;
IndexItem._({
required this.name,
required this.qualifiedName,
required this.type,
this.href,
this.overriddenDepth,
this.enclosedBy,
});
// "name":"dartdoc",
// "qualifiedName":"dartdoc",
// "href":"dartdoc/dartdoc-library.html",
// "type":"library",
// "overriddenDepth":0,
// "packageName":"dartdoc"
// ["enclosedBy":{"name":"Accessor","type":"class"}]
factory IndexItem.fromMap(Map<String, dynamic> data) {
// Note that this map also contains 'packageName', but we're not currently
// using that info.
EnclosedBy? enclosedBy;
if (data['enclosedBy'] != null) {
final map = data['enclosedBy'] as Map<String, dynamic>;
enclosedBy = EnclosedBy._(name: map['name'], type: map['type']);
}
return IndexItem._(
name: data['name'],
qualifiedName: data['qualifiedName'],
href: data['href'],
type: data['type'],
overriddenDepth: data['overriddenDepth'],
enclosedBy: enclosedBy,
);
}
}
class EnclosedBy {
final String name;
final String type;
// ["enclosedBy":{"name":"Accessor","type":"class"}]
EnclosedBy._({
required this.name,
required this.type,
});
}