diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 5a8fe1ff..9e1b56a1 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -145,7 +145,6 @@ @@ -298,8 +292,8 @@ } PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage) - NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) - TOC_DATA = @Json.Serialize(tocData) + NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) + TOC_DATA = @Json.Serialize(tocData) @* Recursive vue component template for rendering the table of contents. *@ diff --git a/EssentialCSharp.Web/wwwroot/css/styles.css b/EssentialCSharp.Web/wwwroot/css/styles.css index 8be63dbf..b0ba51c4 100644 --- a/EssentialCSharp.Web/wwwroot/css/styles.css +++ b/EssentialCSharp.Web/wwwroot/css/styles.css @@ -385,6 +385,37 @@ a:hover { } } +.filter-input-container { + display: flex; + align-items: center; + width: 100%; + padding: 0.5rem; + box-sizing: border-box; +} + +.filter-input { + flex: 1; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px 0 0 4px; + font-size: 1rem; +} + +.filter-btn { + padding: 0.5rem; + border: 1px solid #ccc; + border-left: none; + background-color: #007bff; + color: white; + border-radius: 0 4px 4px 0; + cursor: pointer; +} + + .filter-btn .fa-search { + font-size: 1rem; + } + + /* Table of Contents For Chapters */ .toc-menu { diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index 3d9851bd..c76f5c54 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -1,11 +1,11 @@ import { - createApp, - ref, - reactive, - onMounted, - markRaw, - watch, - computed, + createApp, + ref, + reactive, + onMounted, + markRaw, + watch, + computed, } from "vue"; import { useWindowSize } from "vue-window-size"; @@ -23,52 +23,52 @@ const tocData = markRaw(TOC_DATA); //Add new content or features here: const featuresComingSoonList = [ - { - title: "Client-side Compiler", - text: "Write, compile, and run code snippets right from your browser. Enjoy hands-on experience with the code as you go through the site.", - }, - { - title: "Interactive Code Listings", - text: "Edit, compile, and run the code listings found throughout Essential C#.", - }, - { - title: "Hyperlinking", - text: "Easily navigate to interesting and relevant sites as well as related sections in Essential C#.", - }, - { - title: "Table of Contents Filtering", - text: "The Table of Contents filter will let you narrow down the list of topics to help you quickly and easily find your destination.", - }, + { + title: "Client-side Compiler", + text: "Write, compile, and run code snippets right from your browser. Enjoy hands-on experience with the code as you go through the site.", + }, + { + title: "Interactive Code Listings", + text: "Edit, compile, and run the code listings found throughout Essential C#.", + }, + { + title: "Hyperlinking", + text: "Easily navigate to interesting and relevant sites as well as related sections in Essential C#.", + }, + { + title: "Table of Contents Filtering", + text: "The Table of Contents filter will let you narrow down the list of topics to help you quickly and easily find your destination.", + }, ]; const contentComingSoonList = [ - { - title: "Experimental attribute", - text: "New feature from C# 12.0.", - }, - { - title: "Source Generators", - text: "A newer .NET feature.", - }, - { - title: "C# 13.0 Features", - text: "Various new features coming in .C# 13.0", - }, + { + title: "Experimental attribute", + text: "New feature from C# 12.0.", + }, + { + title: "Source Generators", + text: "A newer .NET feature.", + }, + { + title: "C# 13.0 Features", + text: "Various new features coming in .C# 13.0", + }, ]; const completedFeaturesList = [ - { - title: "Copying Header Hyperlinks", - text: "Easily copy a header URL to link to a book section.", - }, - { - title: "Home Page", - text: "Add a home page that features a short description of the book and a high level mindmap.", - }, - { - title: "Keyboard Shortcuts", - text: "Quickly navigate through the book via keyboard shortcuts (right/left arrows, 'n', 'p').", - }, + { + title: "Copying Header Hyperlinks", + text: "Easily copy a header URL to link to a book section.", + }, + { + title: "Home Page", + text: "Add a home page that features a short description of the book and a high level mindmap.", + }, + { + title: "Keyboard Shortcuts", + text: "Quickly navigate through the book via keyboard shortcuts (right/left arrows, 'n', 'p').", + }, ]; /** @@ -78,194 +78,241 @@ const completedFeaturesList = [ * @returns {TocItem[] | undefined} path of items to the current page * */ function findCurrentPage(path, items) { - for (const item of items) { - const itemPath = [item, ...path]; - if ( - window.location.href.endsWith("/" + item.href) || - window.location.href.endsWith("/" + item.key) - ) { - return itemPath; - } + for (const item of items) { + const itemPath = [item, ...path]; + if ( + window.location.href.endsWith("/" + item.href) || + window.location.href.endsWith("/" + item.key) + ) { + return itemPath; + } - const recursivePath = findCurrentPage(itemPath, item.items); - if (recursivePath) return recursivePath; - } + const recursivePath = findCurrentPage(itemPath, item.items); + if (recursivePath) return recursivePath; + } } function openSearch() { - const el = document - .getElementById("docsearch") - .querySelector(".DocSearch-Button"); - el.click(); + const el = document + .getElementById("docsearch") + .querySelector(".DocSearch-Button"); + el.click(); } const smallScreenSize = 768; const removeHashPath = (path) => { - if (!path) { - return null; - } - let index = path.indexOf("#"); - index = index > 0 ? index : path.length; - return path.substring(0, index); + if (!path) { + return null; + } + let index = path.indexOf("#"); + index = index > 0 ? index : path.length; + return path.substring(0, index); }; // v-bind dont like the # in the url const nextPagePath = removeHashPath(NEXT_PAGE); const previousPagePath = removeHashPath(PREVIOUS_PAGE); -const app = createApp({ - setup() { - const { width: windowWidth } = useWindowSize(); - - const nextPageUrl = ref(nextPagePath); - const previousPageUrl = ref(previousPagePath); - - let snackbarTimeoutId = null; - const snackbarMessage = ref(); - const snackbarColor = ref(); - - function copyToClipboard(copyText) { - navigator.clipboard - .writeText(window.location.origin + "/" + copyText) - .then( - function () { - /* Success */ - snackbarColor.value = "white"; - snackbarMessage.value = "Copied url to clipboard!"; - }, - function (err) { - console.error("Could not copy text to clipboard: ", err); - snackbarColor.value = "red"; - snackbarMessage.value = - "Error: Could not copy text to clipboard: " + err; - } - ); - // Hide after 3 seconds - if (snackbarTimeoutId != null) { - clearTimeout(snackbarTimeoutId); - snackbarMessage.value = null; - } - snackbarTimeoutId = setTimeout( - () => (snackbarMessage.value = null), - 3000 - ); - } - - function goToPrevious() { - window.location.href = "/" + PREVIOUS_PAGE; - } - function goToNext() { - window.location.href = "/" + NEXT_PAGE; - } - - document.addEventListener("keydown", (e) => { - let selectionString = document.getSelection().toString(); - if (e.key == "ArrowRight") { - if (!selectionString) { - goToNext(); - } - } - - if (e.key == "ArrowLeft") { - if (!selectionString) { - goToPrevious(); - } - } - }); - - const sidebarShown = ref(false); - - const smallScreen = computed(() => { - return (windowWidth.value || 0) < smallScreenSize; - }); - - /** @type {import("vue").Ref<"toc" | "search">} */ - const sidebarTab = ref("toc"); - - const currentPage = findCurrentPage([], tocData) ?? []; - - const chapterParentPage = currentPage.find((parent) => parent.level === 0); - - const sectionTitle = ref(currentPage?.[0]?.title || "Essential C#"); - const expandedTocs = reactive(new Set()); - for (const item of currentPage) { - expandedTocs.add(item.key); - } - - // hide the sidebar when resizing to small screen - // to do: make it re emerge when going from small to big - watch(windowWidth, (newWidth, oldWidth) => { - //+ 50 so that the side bar diappears before the css media class changes the sidebar to take - // over the full screen - if (newWidth < smallScreenSize) { - sidebarShown.value = false; - } - // when making screen bigger reveal sidebar - else { - if (!sidebarShown.value) { - sidebarShown.value = true; - } - } - }); +const app = createApp({ + setup() { + const { width: windowWidth } = useWindowSize(); + + const nextPageUrl = ref(nextPagePath); + const previousPageUrl = ref(previousPagePath); + + let snackbarTimeoutId = null; + const snackbarMessage = ref(); + const snackbarColor = ref(); + + function copyToClipboard(copyText) { + navigator.clipboard + .writeText(window.location.origin + "/" + copyText) + .then( + function () { + /* Success */ + snackbarColor.value = "white"; + snackbarMessage.value = "Copied url to clipboard!"; + }, + function (err) { + console.error("Could not copy text to clipboard: ", err); + snackbarColor.value = "red"; + snackbarMessage.value = + "Error: Could not copy text to clipboard: " + err; + } + ); + // Hide after 3 seconds + if (snackbarTimeoutId != null) { + clearTimeout(snackbarTimeoutId); + snackbarMessage.value = null; + } + snackbarTimeoutId = setTimeout( + () => (snackbarMessage.value = null), + 3000 + ); + } + + function goToPrevious() { + window.location.href = "/" + PREVIOUS_PAGE; + } + function goToNext() { + window.location.href = "/" + NEXT_PAGE; + } + + document.addEventListener("keydown", (e) => { + let selectionString = document.getSelection().toString(); + if (e.key == "ArrowRight") { + if (!selectionString) { + goToNext(); + } + } + + if (e.key == "ArrowLeft") { + if (!selectionString) { + goToPrevious(); + } + } + }); + + const sidebarShown = ref(false); + + const smallScreen = computed(() => { + return (windowWidth.value || 0) < smallScreenSize; + }); + + /** @type {import("vue").Ref<"toc" | "search">} */ + const sidebarTab = ref("toc"); + + const currentPage = findCurrentPage([], tocData) ?? []; + + const chapterParentPage = currentPage.find((parent) => parent.level === 0); + + const sectionTitle = ref(currentPage?.[0]?.title || "Essential C#"); + const expandedTocs = reactive(new Set()); + for (const item of currentPage) { + expandedTocs.add(item.key); + } + + // hide the sidebar when resizing to small screen + // to do: make it re emerge when going from small to big + watch(windowWidth, (newWidth, oldWidth) => { + //+ 50 so that the side bar diappears before the css media class changes the sidebar to take + // over the full screen + if (newWidth < smallScreenSize) { + sidebarShown.value = false; + } + // when making screen bigger reveal sidebar + else { + if (!sidebarShown.value) { + sidebarShown.value = true; + } + } + }); + + // prevent scrolling of the page when the sidebar is visible on small screens + watch(sidebarShown, (newValue, oldValue) => { + if (windowWidth.value <= smallScreenSize) { + if (newValue) { + document.body.classList.add("noScrollOnSmallScreen"); + } else { + document.body.classList.remove("noScrollOnSmallScreen"); + } + } + }); + + onMounted(() => { + if (windowWidth.value > smallScreenSize) { + sidebarShown.value = true; + } + + // Scroll the current selected page in the TOC into view of the TOC. + [...document.querySelectorAll(".current-section")] + .reverse()[0] + ?.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); + }); - // prevent scrolling of the page when the sidebar is visible on small screens - watch(sidebarShown, (newValue, oldValue) => { - if (windowWidth.value <= smallScreenSize) { - if (newValue) { - document.body.classList.add("noScrollOnSmallScreen"); - } else { - document.body.classList.remove("noScrollOnSmallScreen"); + const enableTocFilter = ref('none'); + const searchQuery = ref(''); + + const filteredTocData = computed(() => { + if (!searchQuery.value) { + return tocData; + } + const query = normalizeString(searchQuery.value); + return tocData.filter(item => filterItem(item, query)); + }); + + function filterItem(item, query) { + let matches = normalizeString(item.title).includes(query); + if (item.items && item.items.length) { + const childMatches = item.items.some(child => filterItem(child, query)); + matches = matches || childMatches; + } + return matches; } - } - }); - - onMounted(() => { - if (windowWidth.value > smallScreenSize) { - sidebarShown.value = true; - } - // Scroll the current selected page in the TOC into view of the TOC. - [...document.querySelectorAll(".current-section")] - .reverse()[0] - ?.scrollIntoView({ - behavior: "auto", - block: "center", - inline: "center", + watch(searchQuery, (newQuery) => { + if (!newQuery) { + expandedTocs.clear(); + // If a search query is removed, open the TOC for the current page. + for (const item of currentPage) { + expandedTocs.add(item.key); + } + } + else { + expandedTocs.clear(); + const query = normalizeString(newQuery); + tocData.forEach(item => { + if (filterItem(item, query)) { + expandedTocs.add(item.key); + } + }); + } }); - }); - - return { - previousPageUrl, - nextPageUrl, - goToPrevious, - goToNext, - openSearch, - - snackbarMessage, - snackbarColor, - copyToClipboard, - contentComingSoonList, - featuresComingSoonList, - completedFeaturesList, - - sidebarShown, - sidebarTab, - - smallScreen, + function normalizeString(str) { + return str.replace(/[^\w\s]|_/g, "").replace(/\s+/g, " ").toLowerCase(); + } - sectionTitle, - tocData, - expandedTocs, - currentPage, - chapterParentPage, - }; - }, + return { + previousPageUrl, + nextPageUrl, + goToPrevious, + goToNext, + openSearch, + + snackbarMessage, + snackbarColor, + copyToClipboard, + + contentComingSoonList, + featuresComingSoonList, + completedFeaturesList, + + sidebarShown, + sidebarTab, + + smallScreen, + + sectionTitle, + tocData, + expandedTocs, + currentPage, + chapterParentPage, + + searchQuery, + filteredTocData, + enableTocFilter + }; + }, }); app.component("toc-tree", { - props: ["item", "expandedTocs", "currentPage"], - template: "#toc-tree", + props: ["item", "expandedTocs", "currentPage"], + template: "#toc-tree", }); app.mount("#app");