From 9c8f605b40d26565b2ddadfe9e05ced7c4e9f426 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 24 Sep 2024 16:08:16 -0700 Subject: [PATCH 1/4] feat: Add TOC filtering --- .../Views/Shared/_Layout.cshtml | 30 +- EssentialCSharp.Web/wwwroot/css/styles.css | 31 ++ EssentialCSharp.Web/wwwroot/js/site.js | 458 ++++++++++-------- 3 files changed, 288 insertions(+), 231 deletions(-) diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 5a8fe1ff..8a601b76 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..a9387b96 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,226 @@ 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); - } +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", + }); + }); - // 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 enableTocFilter = ref('none'); + const searchQuery = ref(''); + + const filteredTocData = computed(() => { + expandedTocs.clear(); + 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; + } + if (matches) { + expandedTocs.add(item.key); // Add matching item to expandedTocs + } + return matches; } - } - }); - // 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"); + function normalizeString(str) { + return str.replace(/[^\w\s]|_/g, "").replace(/\s+/g, " ").toLowerCase(); } - } - }); - - 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", - }); - }); - - return { - previousPageUrl, - nextPageUrl, - goToPrevious, - goToNext, - openSearch, - - snackbarMessage, - snackbarColor, - copyToClipboard, - - contentComingSoonList, - featuresComingSoonList, - completedFeaturesList, - - sidebarShown, - sidebarTab, - - smallScreen, - 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"); From 496159828c4f957bdc4fd8b67e17384d559015a2 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 24 Sep 2024 16:29:12 -0700 Subject: [PATCH 2/4] PR Feedback --- EssentialCSharp.Web/wwwroot/js/site.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index a9387b96..9ca2e642 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -6,6 +6,7 @@ import { markRaw, watch, computed, + watchEffect } from "vue"; import { useWindowSize } from "vue-window-size"; @@ -238,8 +239,8 @@ const app = createApp({ const searchQuery = ref(''); const filteredTocData = computed(() => { - expandedTocs.clear(); if (!searchQuery.value) { + expandedTocs.clear(); return tocData; } const query = normalizeString(searchQuery.value); @@ -252,12 +253,20 @@ const app = createApp({ const childMatches = item.items.some(child => filterItem(child, query)); matches = matches || childMatches; } - if (matches) { - expandedTocs.add(item.key); // Add matching item to expandedTocs - } return matches; } + watchEffect(() => { + expandedTocs.clear(); + const query = normalizeString(searchQuery.value); + tocData.forEach(item => { + if (filterItem(item, query)) { + expandedTocs.add(item.key); + console.log("Added ", item.key); + } + }); + }); + function normalizeString(str) { return str.replace(/[^\w\s]|_/g, "").replace(/\s+/g, " ").toLowerCase(); } From 2cc340d272c7d3dd28fb05b9de5b2ee1b29ff16d Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 24 Sep 2024 16:50:04 -0700 Subject: [PATCH 3/4] PR Feedback --- EssentialCSharp.Web/Views/Shared/_Layout.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 8a601b76..9e1b56a1 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -188,7 +188,7 @@
- + From 3a1d8b8b648a43040326f88f30d8f154a2783121 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 24 Sep 2024 17:28:35 -0700 Subject: [PATCH 4/4] fix: Remove remaining stateful mutations --- EssentialCSharp.Web/wwwroot/js/site.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index 9ca2e642..c76f5c54 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -6,7 +6,6 @@ import { markRaw, watch, computed, - watchEffect } from "vue"; import { useWindowSize } from "vue-window-size"; @@ -240,7 +239,6 @@ const app = createApp({ const filteredTocData = computed(() => { if (!searchQuery.value) { - expandedTocs.clear(); return tocData; } const query = normalizeString(searchQuery.value); @@ -256,15 +254,23 @@ const app = createApp({ return matches; } - watchEffect(() => { - expandedTocs.clear(); - const query = normalizeString(searchQuery.value); - tocData.forEach(item => { - if (filterItem(item, query)) { + 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); - console.log("Added ", item.key); } - }); + } + else { + expandedTocs.clear(); + const query = normalizeString(newQuery); + tocData.forEach(item => { + if (filterItem(item, query)) { + expandedTocs.add(item.key); + } + }); + } }); function normalizeString(str) {