chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// Create chat-widget div
|
||||
var chatWidgetDiv = document.createElement('div');
|
||||
chatWidgetDiv.className = 'chat-widget';
|
||||
chatWidgetDiv.innerHTML = `
|
||||
<button id="openChatBtn">Ask AI</button>
|
||||
`;
|
||||
document.body.appendChild(chatWidgetDiv);
|
||||
|
||||
// Create chat-popup div
|
||||
const date = new Date();
|
||||
var chatPopupDiv = document.createElement('div');
|
||||
chatPopupDiv.className = 'chat-popup';
|
||||
chatPopupDiv.id = 'chatPopup';
|
||||
chatPopupDiv.innerHTML = `
|
||||
<div class="chatHeader">
|
||||
<div class="header-wrapper">
|
||||
<h3 class="assistant-title">Ray Docs AI - Ask a question</h3>
|
||||
</div>
|
||||
<button id="closeChatBtn" class="btn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="chatContainer" class="chatContentContainer">
|
||||
<div id="result">
|
||||
Please note that the results of this bot are automated and may be incorrect or contain inappropriate information.
|
||||
<div id="anchor"></div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<textarea id="searchBar" class="input" rows="3" placeholder="Do not include any personal or confidential information."></textarea>
|
||||
<button id="searchBtn" class="btn btn-primary">Ask AI</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chatFooter text-right p-2">
|
||||
© Copyright ${date.getFullYear()}, The Ray Team.
|
||||
</div>
|
||||
`;
|
||||
chatPopupDiv.messages = [];
|
||||
document.body.appendChild(chatPopupDiv);
|
||||
|
||||
const anchorDiv = document.getElementById('anchor');
|
||||
const chatContainerDiv = document.getElementById('chatContainer');
|
||||
|
||||
const blurDiv = document.createElement('div');
|
||||
blurDiv.id = 'blurDiv';
|
||||
blurDiv.classList.add('blurDiv-hidden');
|
||||
document.body.appendChild(blurDiv);
|
||||
|
||||
// blur background when chat popup is open
|
||||
document.getElementById('openChatBtn').addEventListener('click', function () {
|
||||
document.querySelector('.search-button__wrapper').classList.remove('show');
|
||||
document.getElementById('chatPopup').style.display = 'block';
|
||||
blurDiv.classList.remove('blurDiv-hidden');
|
||||
});
|
||||
|
||||
// un-blur background when chat popup is closed
|
||||
document.getElementById('closeChatBtn').addEventListener('click', function () {
|
||||
document.getElementById('chatPopup').style.display = 'none';
|
||||
blurDiv.classList.add('blurDiv-hidden');
|
||||
});
|
||||
|
||||
// set code highlighting options
|
||||
marked.setOptions({
|
||||
renderer: new marked.Renderer(),
|
||||
highlight: function (code) {
|
||||
return hljs.highlight('python', code).value;
|
||||
},
|
||||
});
|
||||
|
||||
function highlightCode() {
|
||||
document.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightBlock(block);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCopyButtons(resultDiv) {
|
||||
let preElements = resultDiv.querySelectorAll('pre');
|
||||
preElements.forEach((preElement, index) => {
|
||||
preElement.style.position = 'relative';
|
||||
|
||||
let uniqueId = `button-id-${index}`;
|
||||
preElement.id = uniqueId;
|
||||
|
||||
// Set the proper attributes to the button to make is a sphinx copy button
|
||||
let copyButton = document.createElement('button');
|
||||
copyButton.className = 'copybtn o-tooltip--left';
|
||||
copyButton.setAttribute('data-tooltip', 'Copy');
|
||||
copyButton.setAttribute('data-clipboard-target', `#${uniqueId}`);
|
||||
copyButton.style.position = 'absolute';
|
||||
copyButton.style.top = '10px';
|
||||
copyButton.style.right = '10px';
|
||||
copyButton.style.opacity = 'inherit';
|
||||
|
||||
let imgElement = document.createElement('img');
|
||||
imgElement.src = window.data.copyIconSrc;
|
||||
imgElement.alt = 'Copy to clipboard';
|
||||
|
||||
copyButton.appendChild(imgElement);
|
||||
preElement.appendChild(copyButton);
|
||||
});
|
||||
}
|
||||
|
||||
const searchBar = document.getElementById('searchBar');
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
|
||||
function rayAssistant(event) {
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
||||
const searchTerm = searchBar.value;
|
||||
// handle empty input
|
||||
if (!searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'click' ||
|
||||
(event.type === 'keydown' && event.key === 'Enter')
|
||||
) {
|
||||
// for improved UX, we want to equate a carriage return as hitting the send button and prevent default behavior of entering a new line
|
||||
if (event.type === 'keydown' && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
// clear search bar value and change placeholder to prompt user to ask follow up question
|
||||
searchBar.value = '';
|
||||
searchBar.placeholder = 'Ask follow up question here';
|
||||
|
||||
// Add query to the list of messages
|
||||
chatPopupDiv.messages.push({role: 'user', content: searchTerm});
|
||||
|
||||
let msgBlock = document.createElement('div');
|
||||
|
||||
resultDiv.insertBefore(msgBlock, anchorDiv);
|
||||
|
||||
let divider = document.createElement('hr');
|
||||
msgBlock.appendChild(divider);
|
||||
|
||||
let msgHeader = document.createElement('div');
|
||||
msgHeader.style.fontWeight = 'bold';
|
||||
// capitalize first letter of question header
|
||||
msgHeader.innerHTML =
|
||||
searchTerm.charAt(0).toUpperCase() + searchTerm.slice(1);
|
||||
msgBlock.appendChild(msgHeader);
|
||||
|
||||
async function readStream() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://ray-assistant-public-bxauk.cld-kvedzwag2qa8i5bj.s.anyscaleuserdata.com/chat',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({messages: chatPopupDiv.messages}),
|
||||
}
|
||||
);
|
||||
const reader = response.body.getReader();
|
||||
let decoder = new TextDecoder('utf-8');
|
||||
let msgContent = document.createElement('div');
|
||||
msgBlock.appendChild(msgContent);
|
||||
msgContent.innerHTML = '';
|
||||
|
||||
let collectChunks = '';
|
||||
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
renderCopyButtons(resultDiv);
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, {stream: true});
|
||||
collectChunks += chunk;
|
||||
let html = marked.parse(collectChunks);
|
||||
html = DOMPurify.sanitize(html);
|
||||
msgContent.innerHTML = html;
|
||||
highlightCode();
|
||||
}
|
||||
chatPopupDiv.messages.push({role: 'assistant', content: collectChunks});
|
||||
// we want to autoscroll to the bottom of the chat container after the answer is streamed in
|
||||
chatContainerDiv.scrollTo(0, chatContainerDiv.scrollHeight);
|
||||
} catch (error) {
|
||||
console.error('Fetch API failed:', error);
|
||||
}
|
||||
}
|
||||
readStream().then((res) => console.log(res));
|
||||
}
|
||||
}
|
||||
|
||||
searchBtn.addEventListener('click', rayAssistant);
|
||||
searchBar.addEventListener('keydown', rayAssistant);
|
||||
@@ -0,0 +1,83 @@
|
||||
// Handlers for CSAT events
|
||||
|
||||
/**
|
||||
* Upvote or downvote the documentation via Google Analytics.
|
||||
*
|
||||
* @param {string} vote 'Yes' or 'No' vote to send as feedback
|
||||
*/
|
||||
function sendVote(vote) {
|
||||
if (typeof window.dataLayer === 'undefined' || !Array.isArray(window.dataLayer)) {
|
||||
console.warn('Google Tag Manager dataLayer not available - CSAT vote not tracked');
|
||||
return;
|
||||
}
|
||||
|
||||
window.dataLayer.push({
|
||||
event: 'csat_vote',
|
||||
vote_type: vote,
|
||||
category: 'CSAT',
|
||||
page_location: window.location.href,
|
||||
page_title: document.title,
|
||||
value: vote === 'Yes' ? 1 : 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Documentation feedback via Google Analytics
|
||||
* @param {string} text Text to send as feedback
|
||||
*/
|
||||
function sendFeedback(text) {
|
||||
if (typeof window.dataLayer === 'undefined' || !Array.isArray(window.dataLayer)) {
|
||||
console.warn('Google Tag Manager dataLayer not available - CSAT feedback not tracked');
|
||||
return;
|
||||
}
|
||||
|
||||
window.dataLayer.push({
|
||||
event: 'csat_feedback',
|
||||
feedback_text: text.substring(0, 500),
|
||||
category: 'CSAT',
|
||||
page_location: window.location.href,
|
||||
page_title: document.title,
|
||||
feedback_length: text.length
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const yesButton = document.getElementById("csat-yes");
|
||||
const noButton = document.getElementById("csat-no");
|
||||
const yesIcon = document.getElementById("csat-yes-icon");
|
||||
const noIcon = document.getElementById("csat-no-icon");
|
||||
const text = document.getElementById("csat-textarea");
|
||||
const submitButton = document.getElementById("csat-submit");
|
||||
const csatGroup = document.getElementById("csat-textarea-group");
|
||||
const csatFeedbackReceived = document.getElementById("csat-feedback-received");
|
||||
const csatInputs = document.getElementById("csat-inputs")
|
||||
|
||||
yesButton.addEventListener("click", () => {
|
||||
text.placeholder = "We're glad you found it helpful. If you have any additional feedback, please let us know.";
|
||||
csatGroup.classList.remove("csat-hidden");
|
||||
yesIcon.classList.remove("csat-hidden");
|
||||
noIcon.classList.add("csat-hidden");
|
||||
yesButton.classList.add("csat-button-active");
|
||||
noButton.classList.remove("csat-button-active");
|
||||
submitButton.scrollIntoView();
|
||||
sendVote('Yes');
|
||||
})
|
||||
|
||||
noButton.addEventListener("click", () => {
|
||||
text.placeholder = "We value your feedback. Please let us know how we can improve our documentation.";
|
||||
csatGroup.classList.remove("csat-hidden");
|
||||
yesIcon.classList.add("csat-hidden");
|
||||
noIcon.classList.remove("csat-hidden");
|
||||
yesButton.classList.remove("csat-button-active");
|
||||
noButton.classList.add("csat-button-active");
|
||||
submitButton.scrollIntoView();
|
||||
sendVote('No');
|
||||
})
|
||||
|
||||
submitButton.addEventListener("click", () => {
|
||||
csatGroup.classList.add("csat-hidden");
|
||||
csatInputs.classList.add("csat-hidden");
|
||||
csatFeedbackReceived.classList.remove("csat-hidden");
|
||||
sendFeedback(text.value);
|
||||
}, {once: true})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
let new_termynals = [];
|
||||
|
||||
function createTermynals() {
|
||||
const containers = document.getElementsByClassName("termynal");
|
||||
Array.from(containers).forEach(addTermynal);
|
||||
}
|
||||
|
||||
function addTermynal(container) {
|
||||
const t = new Termynal(container, {
|
||||
noInit: true,
|
||||
});
|
||||
new_termynals.push(t);
|
||||
}
|
||||
|
||||
// Initialize Termynals that are visible on the page. Once initialized, remove
|
||||
// the Termynal from terminals that remain to be loaded.
|
||||
function loadVisibleTermynals() {
|
||||
new_termynals = new_termynals.filter(termynal => {
|
||||
if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) {
|
||||
termynal.init();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Store the state of the page in the browser's local storage.
|
||||
// For now this includes just the sidebar scroll position.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const sidebar = document.getElementById("main-sidebar")
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (sidebar) {
|
||||
localStorage.setItem("scroll", sidebar.scrollTop)
|
||||
}
|
||||
})
|
||||
|
||||
const storedScrollPosition = localStorage.getItem("scroll")
|
||||
if (storedScrollPosition) {
|
||||
if (sidebar) {
|
||||
sidebar.scrollTop = storedScrollPosition;
|
||||
}
|
||||
localStorage.removeItem("scroll");
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// Send GA events any time a code block is copied
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
let codeButtons = document.querySelectorAll(".copybtn");
|
||||
for (let i = 0; i < codeButtons.length; i++) {
|
||||
const button = codeButtons[i];
|
||||
button.addEventListener("click", function() {
|
||||
if (typeof window.dataLayer === 'undefined' || !Array.isArray(window.dataLayer)) {
|
||||
console.warn('Google Tag Manager dataLayer not available - code copy not tracked');
|
||||
return;
|
||||
}
|
||||
|
||||
window.dataLayer.push({
|
||||
event: "code_copy_click",
|
||||
category: "ray_docs_copy_code",
|
||||
page_location: window.location.href,
|
||||
page_title: document.title,
|
||||
button_target: button.getAttribute("data-clipboard-target") || "unknown",
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
let anyscaleButton = document.getElementById("try-anyscale")
|
||||
if (anyscaleButton) {
|
||||
anyscaleButton.onclick = () => {
|
||||
if (typeof window.dataLayer === 'undefined' || !Array.isArray(window.dataLayer)) {
|
||||
console.warn('Google Tag Manager dataLayer not available - try anyscale click not tracked');
|
||||
return;
|
||||
}
|
||||
|
||||
window.dataLayer.push({
|
||||
event: "try_anyscale_click",
|
||||
category: "TryAnyscale",
|
||||
page_location: window.location.href,
|
||||
page_title: document.title,
|
||||
link_url: "https://www.anyscale.com",
|
||||
value: 1,
|
||||
});
|
||||
window.open('https://www.anyscale.com', '_blank');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", loadVisibleTermynals);
|
||||
createTermynals();
|
||||
loadVisibleTermynals();
|
||||
@@ -0,0 +1,24 @@
|
||||
// Dismissable banner functionality
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const banner = document.querySelector('.bd-header-announcement');
|
||||
const closeButton = document.getElementById('close-banner');
|
||||
const bannerKey = 'ray-docs-banner-dismissed';
|
||||
|
||||
// Check if banner was previously dismissed
|
||||
if (localStorage.getItem(bannerKey) === 'true') {
|
||||
if (banner) {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add click handler for close button
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', function () {
|
||||
if (banner) {
|
||||
banner.style.display = 'none';
|
||||
localStorage.setItem(bannerKey, 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Get the status (checked/unchecked) for each filter.
|
||||
*
|
||||
* @returns {Object} Arrays of the name and status of each filter, grouped together into filter
|
||||
* groups.
|
||||
*/
|
||||
function getFilterStatuses() {
|
||||
const useCases = Array.from(
|
||||
document.querySelectorAll('#use-case-dropdown .checkbox-container'),
|
||||
).map((label) => {
|
||||
return {
|
||||
name: label.textContent.toLowerCase(),
|
||||
isChecked: label.querySelector('input').checked,
|
||||
};
|
||||
});
|
||||
const libraries = Array.from(
|
||||
document.querySelectorAll('#library-dropdown .checkbox-container'),
|
||||
).map((label) => {
|
||||
return {
|
||||
name: label.textContent.toLowerCase(),
|
||||
isChecked: label.querySelector('input').checked,
|
||||
};
|
||||
});
|
||||
const frameworks = Array.from(
|
||||
document.querySelectorAll('#framework-dropdown .checkbox-container'),
|
||||
).map((label) => {
|
||||
return {
|
||||
name: label.textContent.toLowerCase(),
|
||||
isChecked: label.querySelector('input').checked,
|
||||
};
|
||||
});
|
||||
const contributor = Array.from(
|
||||
document.querySelectorAll('#all-examples-dropdown .checkbox-container'),
|
||||
).map((label) => {
|
||||
const inputElement = label.querySelector('input');
|
||||
return {
|
||||
name: inputElement.id.replace('-checkbox', ''),
|
||||
isChecked: inputElement.checked,
|
||||
};
|
||||
});
|
||||
return {
|
||||
useCases,
|
||||
libraries,
|
||||
frameworks,
|
||||
contributor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the tags of the given example panel match the requested filters.
|
||||
*
|
||||
* @param {any} tags Tags of the example panel
|
||||
* @param {any} filters Filter statuses for all the filter groups; this should be the output of
|
||||
* getFilterStatuses.
|
||||
* @returns {bool} True if the example panel matches the filters, or not.
|
||||
*/
|
||||
function panelMatchesFilters(tags, filters) {
|
||||
return Object.entries(filters).every(([group, groupTags]) => {
|
||||
// If there is no selection, consider the panel to be matched
|
||||
if (groupTags.filter(({isChecked}) => isChecked).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If "Any" is checked, consider the panel to be matched
|
||||
if (
|
||||
groupTags.filter(({name, isChecked}) => name === 'any' && isChecked)
|
||||
.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise show the panel if any checked item matches the tags of the panel
|
||||
return groupTags
|
||||
.filter(({isChecked}) => isChecked)
|
||||
.some(({name}) => tags.includes(name));
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply the currently selected filters to the example gallery, showing only the relevant examples. */
|
||||
function applyFilter() {
|
||||
const noMatchesElement = document.getElementById('no-matches');
|
||||
const panels = document.querySelectorAll('.example');
|
||||
const filters = getFilterStatuses();
|
||||
const searchTerm = document
|
||||
.getElementById('examples-search-input')
|
||||
.value.toLowerCase();
|
||||
|
||||
// Show all panels before hiding the ones that need to be hidden.
|
||||
panels.forEach((panel) => panel.classList.remove('hidden'));
|
||||
|
||||
// Check the title and tags of each example panel. If the tags match and the search term matches,
|
||||
// show the panel.
|
||||
panels.forEach((panel) => {
|
||||
const title = panel
|
||||
.querySelector('.example-title')
|
||||
.textContent.toLowerCase();
|
||||
const tags = panel
|
||||
.querySelector('.example-tags')
|
||||
.textContent.toLowerCase()
|
||||
.concat();
|
||||
const other_keywords = panel
|
||||
.querySelector('.example-other-keywords')
|
||||
.textContent.toLowerCase();
|
||||
const keywords = `${tags} ${other_keywords}`;
|
||||
const matchesSearch =
|
||||
title.includes(searchTerm) || keywords.includes(searchTerm);
|
||||
const matchesTags = panelMatchesFilters(keywords, filters);
|
||||
|
||||
// Hide panels that have no match
|
||||
if (matchesSearch && matchesTags) {
|
||||
panel.classList.remove('hidden');
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// If none are shown, show the "no matches" graphic.
|
||||
if (document.querySelectorAll('.example:not(.hidden)').length === 0) {
|
||||
noMatchesElement.classList.remove('hidden');
|
||||
} else {
|
||||
noMatchesElement.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Set the URL to match the active filters using query parameters.
|
||||
const selectedTags = Object.entries(filters).map(([group, groupTags]) => {
|
||||
return {
|
||||
group,
|
||||
selected: groupTags
|
||||
.filter(({isChecked}) => isChecked)
|
||||
.map(({name}) => name),
|
||||
};
|
||||
});
|
||||
|
||||
const queryParam = selectedTags
|
||||
.filter(({group, selected}) => selected.length > 0)
|
||||
.map(({group, selected}) => `${group}=${selected.join(',')}`)
|
||||
.join('&');
|
||||
|
||||
history.replaceState(
|
||||
null,
|
||||
null,
|
||||
queryParam.length === 0 ? location.pathname : `?${queryParam}`,
|
||||
);
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
// Listen for filter checkbox clicks.
|
||||
document.querySelectorAll('.filter-checkbox').forEach((tag) => {
|
||||
tag.addEventListener('click', () => applyFilter());
|
||||
});
|
||||
|
||||
// Add event listener for keypresses in the search bar.
|
||||
document
|
||||
.getElementById('examples-search-input')
|
||||
.addEventListener('keyup', (event) => {
|
||||
event.preventDefault();
|
||||
applyFilter();
|
||||
});
|
||||
|
||||
// Add the ability to provide URL query parameters to filter examples on page load.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.size > 0) {
|
||||
urlParams.forEach((params) => {
|
||||
params.split(',').forEach((param) => {
|
||||
const element = document.getElementById(`${param}-checkbox`);
|
||||
if (element) {
|
||||
element.checked = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the filter in case there are URL query parameters.
|
||||
applyFilter();
|
||||
|
||||
const dropdowns = Array.from(
|
||||
document.querySelectorAll('.dropdown-content'),
|
||||
).map((dropdown) => {
|
||||
return {
|
||||
dropdown,
|
||||
input: dropdown.parentNode.querySelector('input'),
|
||||
inputContainer: dropdown.parentNode,
|
||||
};
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
let targetEl = event.target; // clicked element
|
||||
|
||||
do {
|
||||
const unclicked = dropdowns.filter(({dropdown, inputContainer}) => {
|
||||
return !(targetEl == dropdown || targetEl == inputContainer);
|
||||
});
|
||||
if (unclicked.length !== dropdowns.length) {
|
||||
// There has been a click inside one of the dropdowns. Close unclicked dropdowns and return.
|
||||
unclicked.forEach(({input}) => {
|
||||
input.checked = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Go up the DOM.
|
||||
targetEl = targetEl.parentNode;
|
||||
} while (targetEl);
|
||||
|
||||
// This is a click outside. Close all dropdowns.
|
||||
dropdowns.forEach(({dropdown, input}) => {
|
||||
input.checked = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
function animateTabs() {
|
||||
const tabs = Array.from(document.getElementById('v-pills-tab').children);
|
||||
const contentTabs = Array.from(
|
||||
document.getElementById('v-pills-tabContent').children,
|
||||
);
|
||||
|
||||
tabs.forEach((item, index) => {
|
||||
item.onclick = () => {
|
||||
tabs.forEach((tab, i) => {
|
||||
if (i === index) {
|
||||
item.classList.add('active');
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
});
|
||||
contentTabs.forEach((tab, i) => {
|
||||
if (i === index) {
|
||||
tab.classList.add('active', 'show');
|
||||
} else {
|
||||
tab.classList.remove('active', 'show');
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateHighlight() {
|
||||
const {theme} = document.documentElement.dataset;
|
||||
['dark', 'light'].forEach((title) => {
|
||||
const stylesheet = document.querySelector(`link[title="${title}"]`);
|
||||
if (title === theme) {
|
||||
stylesheet.removeAttribute('disabled');
|
||||
} else {
|
||||
stylesheet.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setHighlightListener() {
|
||||
const observer = new MutationObserver((mutations) => updateHighlight());
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', animateTabs);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
hljs.highlightAll();
|
||||
updateHighlight();
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', setHighlightListener);
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* termynal.js
|
||||
* A lightweight, modern and extensible animated terminal window, using
|
||||
* async/await.
|
||||
*
|
||||
* @author Ines Montani <ines@ines.io>
|
||||
* @version 0.0.1
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** Generate a terminal widget. */
|
||||
class Termynal {
|
||||
/**
|
||||
* Construct the widget's settings.
|
||||
* @param {(string|Node)=} container - Query selector or container element.
|
||||
* @param {{noInit: boolean}} options - Custom settings.
|
||||
* @param {string} options.prefix - Prefix to use for data attributes.
|
||||
* @param {number} options.startDelay - Delay before animation, in ms.
|
||||
* @param {number} options.typeDelay - Delay between each typed character, in ms.
|
||||
* @param {number} options.lineDelay - Delay between each line, in ms.
|
||||
* @param {number} options.progressLength - Number of characters displayed as progress bar.
|
||||
* @param {string} options.progressChar – Character to use for progress bar, defaults to █.
|
||||
* @param {number} options.progressPercent - Max percent of progress.
|
||||
* @param {string} options.cursor – Character to use for cursor, defaults to ▋.
|
||||
* @param {Object[]} lineData - Dynamically loaded line data objects.
|
||||
* @param {boolean} options.noInit - Don't initialise the animation.
|
||||
*/
|
||||
constructor(container = '#termynal', options = {}) {
|
||||
this.container = (typeof container === 'string') ? document.querySelector(container) : container;
|
||||
this.pfx = `data-${options.prefix || 'ty'}`;
|
||||
this.startDelay = options.startDelay
|
||||
|| parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600;
|
||||
this.typeDelay = options.typeDelay
|
||||
|| parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90;
|
||||
this.lineDelay = options.lineDelay
|
||||
|| parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500;
|
||||
this.progressLength = options.progressLength
|
||||
|| parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40;
|
||||
this.progressChar = options.progressChar
|
||||
|| this.container.getAttribute(`${this.pfx}-progressChar`) || '█';
|
||||
this.progressPercent = options.progressPercent
|
||||
|| parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100;
|
||||
this.cursor = options.cursor
|
||||
|| this.container.getAttribute(`${this.pfx}-cursor`) || '▋';
|
||||
this.lineData = this.lineDataToElements(options.lineData || []);
|
||||
this.loadLines()
|
||||
if (!options.noInit) this.init()
|
||||
}
|
||||
|
||||
|
||||
loadLines() {
|
||||
// Load all the lines and create the container so that the size is fixed
|
||||
// Otherwise it would be changing and the user viewport would be constantly
|
||||
// moving as she/he scrolls
|
||||
// Appends dynamically loaded lines to existing line elements.
|
||||
this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData);
|
||||
for (let line of this.lines) {
|
||||
line.style.visibility = 'hidden'
|
||||
this.container.appendChild(line)
|
||||
}
|
||||
const restart = this.generateRestart()
|
||||
restart.style.visibility = 'hidden'
|
||||
this.container.appendChild(restart)
|
||||
this.container.setAttribute('data-termynal', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the widget, get lines, clear container and start animation.
|
||||
*/
|
||||
init() {
|
||||
// Appends dynamically loaded lines to existing line elements.
|
||||
//this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData);
|
||||
/**
|
||||
* Calculates width and height of Termynal container.
|
||||
* If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS.
|
||||
*/
|
||||
const containerStyle = getComputedStyle(this.container);
|
||||
this.container.style.width = containerStyle.width !== '0px' ?
|
||||
containerStyle.width : undefined;
|
||||
this.container.style.minHeight = containerStyle.height !== '0px' ?
|
||||
containerStyle.height : undefined;
|
||||
|
||||
this.container.setAttribute('data-termynal', '');
|
||||
this.container.innerHTML = '';
|
||||
for (let line of this.lines) {
|
||||
line.style.visibility = 'visible'
|
||||
}
|
||||
this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the animation and rener the lines depending on their data attributes.
|
||||
*/
|
||||
async start() {
|
||||
await this._wait(this.startDelay);
|
||||
|
||||
for (let line of this.lines) {
|
||||
const type = line.getAttribute(this.pfx);
|
||||
const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay;
|
||||
|
||||
if (type == 'input') {
|
||||
line.setAttribute(`${this.pfx}-cursor`, this.cursor);
|
||||
await this.type(line);
|
||||
await this._wait(delay);
|
||||
}
|
||||
|
||||
else if (type == 'progress') {
|
||||
await this.progress(line);
|
||||
await this._wait(delay);
|
||||
}
|
||||
|
||||
else {
|
||||
this.container.appendChild(line);
|
||||
await this._wait(delay);
|
||||
}
|
||||
|
||||
line.removeAttribute(`${this.pfx}-cursor`);
|
||||
}
|
||||
this.addRestart()
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a typed line.
|
||||
* @param {Node} line - The line element to render.
|
||||
*/
|
||||
async type(line) {
|
||||
const chars = [...line.textContent];
|
||||
const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay;
|
||||
line.textContent = '';
|
||||
this.container.appendChild(line);
|
||||
|
||||
for (let char of chars) {
|
||||
await this._wait(delay);
|
||||
line.textContent += char;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a progress bar.
|
||||
* @param {Node} line - The line element to render.
|
||||
*/
|
||||
async progress(line) {
|
||||
const progressLength = line.getAttribute(`${this.pfx}-progressLength`)
|
||||
|| this.progressLength;
|
||||
const progressChar = line.getAttribute(`${this.pfx}-progressChar`)
|
||||
|| this.progressChar;
|
||||
const chars = progressChar.repeat(progressLength);
|
||||
const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`)
|
||||
|| this.progressPercent;
|
||||
line.textContent = '';
|
||||
this.container.appendChild(line);
|
||||
|
||||
for (let i = 1; i < chars.length + 1; i++) {
|
||||
await this._wait(this.typeDelay);
|
||||
const percent = Math.round(i / chars.length * 100);
|
||||
line.textContent = `${chars.slice(0, i)} ${percent}%`;
|
||||
if (percent > progressPercent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for animation delays, called with `await`.
|
||||
* @param {number} time - Timeout, in ms.
|
||||
*/
|
||||
_wait(time) {
|
||||
return new Promise(resolve => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts line data objects into line elements.
|
||||
*
|
||||
* @param {Object[]} lineData - Dynamically loaded lines.
|
||||
* @param {Object} line - Line data object.
|
||||
* @returns {Element[]} - Array of line elements.
|
||||
*/
|
||||
lineDataToElements(lineData) {
|
||||
return lineData.map(line => {
|
||||
let div = document.createElement('div');
|
||||
div.innerHTML = `<span ${this._attributes(line)}>${line.value || ''}</span>`;
|
||||
|
||||
return div.firstElementChild;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for generating attributes string.
|
||||
*
|
||||
* @param {Object} line - Line data object.
|
||||
* @returns {string} - String of attributes.
|
||||
*/
|
||||
_attributes(line) {
|
||||
let attrs = '';
|
||||
for (let prop in line) {
|
||||
attrs += this.pfx;
|
||||
|
||||
if (prop === 'type') {
|
||||
attrs += `="${line[prop]}" `
|
||||
} else if (prop !== 'value') {
|
||||
attrs += `-${prop}="${line[prop]}" `
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
// Taken from Typer Tiangolo
|
||||
generateRestart() {
|
||||
const restart = document.createElement('a')
|
||||
restart.onclick = (e) => {
|
||||
e.preventDefault()
|
||||
this.container.innerHTML = ''
|
||||
this.init()
|
||||
}
|
||||
restart.href = '#'
|
||||
restart.setAttribute('data-terminal-control', '')
|
||||
restart.innerHTML = "restart ↻"
|
||||
return restart
|
||||
}
|
||||
|
||||
addRestart() {
|
||||
const restart = this.generateRestart()
|
||||
this.container.appendChild(restart)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user