diff --git a/src/Layout.tsx b/src/Layout.tsx index 6693580..8942fd0 100644 --- a/src/Layout.tsx +++ b/src/Layout.tsx @@ -1,5 +1,7 @@ export { Layout } export { containerQueryMobile } +export { navWidthMin } +export { navWidthMax } import React from 'react' import { NavigationContent } from './navigation/Navigation' diff --git a/src/config/resolveHeadingsData.ts b/src/config/resolveHeadingsData.ts index d14c55b..4d4488b 100644 --- a/src/config/resolveHeadingsData.ts +++ b/src/config/resolveHeadingsData.ts @@ -14,7 +14,7 @@ import type { LinkData } from '../components' import type { Exports, PageContextOriginal } from './resolvePageContext' import pc from '@brillout/picocolors' import { parseTitle } from '../parseTitle' -import { determineColumnLayoutEntries } from '../renderer/getStyleColumnLayout' +import { determineColumnEntries } from '../renderer/determineColumnEntries' assert(!isBrowser()) type PageSectionResolved = { @@ -56,14 +56,12 @@ function resolveHeadingsData(pageContext: PageContextOriginal) { // TODO/refactor: remove navItems let navItems: NavItem[] let navItemsAll: NavItemAll[] - let columnLayouts: number[][] { const navItemsPageSections = pageSectionsResolved .filter((pageSection) => pageSection.pageSectionLevel === 2) .map(pageSectionToNavItem) navItemsAll = headingsResolved.map(headingToNavItem) - const res = determineColumnLayoutEntries(navItemsAll) - columnLayouts = res.columnLayouts + determineColumnEntries(navItemsAll) if (isDetachedPage) { navItems = [headingToNavItem(activeHeading), ...navItemsPageSections] } else { @@ -85,8 +83,6 @@ function resolveHeadingsData(pageContext: PageContextOriginal) { pageTitle, documentTitle, // TODO: don't pass to client-side - columnLayouts, - // TODO: don't pass to client-side activeCategory, } return pageContextAddendum diff --git a/src/navigation/Navigation.tsx b/src/navigation/Navigation.tsx index e38a2ea..6344b2e 100644 --- a/src/navigation/Navigation.tsx +++ b/src/navigation/Navigation.tsx @@ -12,6 +12,7 @@ import { usePageContext } from '../renderer/usePageContext' import '@docsearch/css' import '../global.d.ts' import { getViewportWidth } from '../utils/getViewportWidth' +import { navWidthMax, navWidthMin } from '../Layout' type NavItem = { level: number @@ -22,7 +23,7 @@ type NavItem = { menuModalFullWidth?: true } type NavItemAll = NavItem & { - isColumnLayoutElement?: true + isColumnEntry?: ColumnMap } function NavigationContent(props: { navItems: NavItem[] @@ -50,49 +51,51 @@ function NavigationContent(props: { } function NavigationColumnLayout(props: { navItemsWithComputed: NavItemComputed[] }) { - const navItemsColumnLayout = groupByColumnLayout(props.navItemsWithComputed) - const paddingBottom = 40 + let [viewportWidth, setViewportWidth] = useState() + const updateviewportwidth = () => setViewportWidth(getViewportWidth()) + useEffect(() => { + updateviewportwidth() + window.addEventListener('resize', updateviewportwidth, { passive: true }) + }) + + const navItemsByColumnLayouts = getNavItemsByColumnLayouts(props.navItemsWithComputed, viewportWidth) return ( <> - {navItemsColumnLayout.map(({ navItemsColumnEntries, isFullWidth }, i) => ( + {navItemsByColumnLayouts.map(({ columns, isFullWidth }, i) => (
-
- {navItemsColumnEntries.map((navItemColumnEntry, j) => ( -
-
- - {navItemColumnEntry.navItemChilds.map((navItem, k) => ( - + {columns.map((columnEntry, j) => ( +
+ {columnEntry.map((navItems, k) => ( +
+ {navItems.map((navItem, l) => ( + ))} - +
-
- ))} - -
+ ))} +
+ ))} +
))} @@ -164,33 +167,57 @@ function NavItemComponent({ } } -type NavItemsColumnEntry = NavItemComputed & { navItemChilds: NavItemComputed[] } -function groupByColumnLayout(navItems: NavItemComputed[]) { - const navItemsColumnLayout: { navItemsColumnEntries: NavItemsColumnEntry[]; isFullWidth: boolean }[] = [] - let navItemsColumnEntries: NavItemsColumnEntry[] = [] +type NavItemsByColumnLayout = { columns: NavItemComputed[][][]; isFullWidth: boolean } +function getNavItemsByColumnLayouts(navItems: NavItemComputed[], viewportWidth: number = 0): NavItemsByColumnLayout[] { + const navItemsByColumnEntries = getNavItemsByColumnEntries(navItems) + const numberOfColumnsMax = Math.floor(viewportWidth / navWidthMin) || 1 + const navItemsByColumnLayouts: NavItemsByColumnLayout[] = navItemsByColumnEntries.map( + ({ columnEntries, isFullWidth }) => { + const numberOfColumns = Math.min(numberOfColumnsMax, columnEntries.length) + const columns: NavItemComputed[][][] = [] + columnEntries.forEach((columnEntry) => { + const idx = numberOfColumns === 1 ? 0 : columnEntry.columnMap[numberOfColumns]! + assert(idx >= 0) + columns[idx] ??= [] + columns[idx].push(columnEntry.navItems) + }) + const navItemsByColumnLayout: NavItemsByColumnLayout = { columns, isFullWidth } + return navItemsByColumnLayout + }, + ) + return navItemsByColumnLayouts +} + +type NavItemsByColumnEntries = { columnEntries: ColumnEntry[]; isFullWidth: boolean }[] +type ColumnEntry = { navItems: NavItemComputed[]; columnMap: ColumnMap } +type ColumnMap = Record +function getNavItemsByColumnEntries(navItems: NavItemComputed[]): NavItemsByColumnEntries { + const navItemsByColumnEntries: NavItemsByColumnEntries = [] + let columnEntries: ColumnEntry[] = [] + let columnEntry: ColumnEntry let isFullWidth: boolean | undefined navItems.forEach((navItem) => { if (navItem.level === 1) { const isFullWidthPrevious = isFullWidth isFullWidth = !!navItem.menuModalFullWidth if (isFullWidthPrevious !== undefined && isFullWidthPrevious !== isFullWidth) { - navItemsColumnLayout.push({ navItemsColumnEntries, isFullWidth: isFullWidthPrevious }) - navItemsColumnEntries = [] + navItemsByColumnEntries.push({ columnEntries, isFullWidth: isFullWidthPrevious }) + columnEntries = [] } } assert(isFullWidth !== undefined) - if (navItem.isColumnLayoutElement) { + if (navItem.isColumnEntry) { assert(navItem.level === 1 || navItem.level === 4) - const navItemColumnEntry = { ...navItem, navItemChilds: [] } - navItemsColumnEntries.push(navItemColumnEntry) + columnEntry = { navItems: [navItem], columnMap: navItem.isColumnEntry } + columnEntries.push(columnEntry) } else { assert(navItem.level !== 1) - navItemsColumnEntries[navItemsColumnEntries.length - 1].navItemChilds.push(navItem) + columnEntry.navItems.push(navItem) } }) assert(isFullWidth !== undefined) - navItemsColumnLayout.push({ navItemsColumnEntries, isFullWidth }) - return navItemsColumnLayout + navItemsByColumnEntries.push({ columnEntries, isFullWidth }) + return navItemsByColumnEntries } type NavItemComputed = ReturnType[number] diff --git a/src/renderer/determineColumnEntries.ts b/src/renderer/determineColumnEntries.ts new file mode 100644 index 0000000..46429ec --- /dev/null +++ b/src/renderer/determineColumnEntries.ts @@ -0,0 +1,148 @@ +export { determineColumnEntries } + +// A CSS-only solution doesn't seem to exist. +// - https://github.com/brillout/docpress/blob/2e41d8b9df098ff8312b02f7e9d41a202548e2b9/src/renderer/getStyleColumnLayout.ts#L4-L26 + +import { type NavItemAll } from '../navigation/Navigation' +import { assert, assertUsage, isBrowser } from '../utils/server' +assert(!isBrowser()) + +type NavItemWithLength = NavItemAll & { numberOfHeadings: number | null } +function determineColumnEntries(navItems: NavItemAll[]): undefined { + const navItemsWithLength: NavItemWithLength[] = navItems.map((navItem) => ({ + ...navItem, + numberOfHeadings: navItem.level === 1 || navItem.level === 4 ? 0 : null, + })) + let navItemLevel1: NavItemWithLength | undefined + let navItemLevel4: NavItemWithLength | undefined + navItemsWithLength.forEach((navItem) => { + if (navItem.level === 1) { + navItemLevel1 = navItem + navItemLevel4 = undefined + return + } + if (navItem.level === 4) { + navItemLevel4 = navItem + return + } + const bumpNavItemLength = (navItem: NavItemWithLength) => { + assert(navItem.numberOfHeadings !== null) + navItem.numberOfHeadings++ + } + assert(navItemLevel1) + bumpNavItemLength(navItemLevel1) + if (navItemLevel4) { + bumpNavItemLength(navItemLevel4) + } + }) + + type ColumnEntry = { navItemLeader: NavItemAll; numberOfEntries: number } + const columnLayouts: ColumnEntry[][] = [] + let columnEntries: ColumnEntry[] = [] + let isFullWidth: boolean | undefined + navItemsWithLength.forEach((navItem, i) => { + let isFullWidthBegin = false + if (navItem.level === 1) { + const isFullWidthPrevious = isFullWidth + isFullWidth = !!navItem.menuModalFullWidth + if (isFullWidth) isFullWidthBegin = true + if (isFullWidthPrevious !== undefined && isFullWidthPrevious !== isFullWidth) { + columnLayouts.push(columnEntries) + columnEntries = [] + } + } + const navItemPrevious = navItemsWithLength[i - 1] + const navItemNext = navItemsWithLength[i + 1] + if ( + !isFullWidth ? navItem.level === 1 : (navItem.level === 4 && navItemPrevious!.level !== 1) || isFullWidthBegin + ) { + if (isFullWidth) { + assert(navItem.level === 4 || (navItem.level === 1 && isFullWidthBegin)) + } else { + assert(navItem.level === 1) + } + let { numberOfHeadings } = navItem + assert(numberOfHeadings !== null) + if (isFullWidthBegin) { + assert(navItem.level === 1) + assertUsage( + navItemNext && navItemNext.level === 4, + // We can lift this requirement, but it isn't trivial to implement. + 'level-1 headings with menuModalFullWidth need to be followed by a level-4 heading', + ) + assert(navItemNext.numberOfHeadings) + numberOfHeadings = navItemNext.numberOfHeadings + } + columnEntries.push({ navItemLeader: navItems[i], numberOfEntries: numberOfHeadings }) + } + }) + assert(columnEntries!) + columnLayouts.push(columnEntries) + + columnLayouts.forEach((columnEntries) => { + for (let numberOfColumns = columnEntries.length; numberOfColumns >= 1; numberOfColumns--) { + const columnsIdMap = determineColumns( + columnEntries.map((columnEntry) => columnEntry.numberOfEntries), + numberOfColumns, + ) + columnEntries.forEach((columnEntry, i) => { + columnEntry.navItemLeader.isColumnEntry ??= {} + columnEntry.navItemLeader.isColumnEntry[numberOfColumns] = columnsIdMap[i] + }) + } + }) +} + +function determineColumns(columnsUnmerged: number[], numberOfColumns: number): number[] { + assert(numberOfColumns <= columnsUnmerged.length) + const columnsMergingInit: ColumnMerging[] = columnsUnmerged.map((columnHeight, i) => ({ + columnIdsMerged: [i], + heightTotal: columnHeight, + })) + const columnsMerged = mergeColumns(columnsMergingInit, numberOfColumns) + const columnsIdMap: number[] = new Array(columnsUnmerged.length) + assert(columnsMerged.length === numberOfColumns) + columnsMerged.forEach((columnMerged, columnMergedId) => { + columnMerged.columnIdsMerged.forEach((columnId) => { + columnsIdMap[columnId] = columnMergedId + }) + }) + assert(columnsIdMap.length === columnsUnmerged.length) + + return columnsIdMap +} +type ColumnMerging = { columnIdsMerged: number[]; heightTotal: number } +function mergeColumns(columnsMerging: ColumnMerging[], numberOfColumns: number): ColumnMerging[] { + if (columnsMerging.length <= numberOfColumns) return columnsMerging + + let mergeCandidate: null | (ColumnMerging & { i: number }) = null + for (let i = 0; i <= columnsMerging.length - 2; i++) { + const column1 = columnsMerging[i + 0] + const column2 = columnsMerging[i + 1] + const heightTotal = column1.heightTotal + column2.heightTotal + if (!mergeCandidate || mergeCandidate.heightTotal > heightTotal) { + mergeCandidate = { + i, + columnIdsMerged: [ + // + ...column1.columnIdsMerged, + ...column2.columnIdsMerged, + ], + heightTotal, + } + } + } + assert(mergeCandidate) + + const { i } = mergeCandidate + assert(-1 < i && i < columnsMerging.length - 1) + const columnsMergingMod = [ + // + ...columnsMerging.slice(0, i), + mergeCandidate, + ...columnsMerging.slice(i + 2), + ] + + assert(columnsMergingMod.length === columnsMerging.length - 1) + return mergeColumns(columnsMergingMod, numberOfColumns) +} diff --git a/src/renderer/getStyleColumnLayout.ts b/src/renderer/getStyleColumnLayout.ts deleted file mode 100644 index a8598ee..0000000 --- a/src/renderer/getStyleColumnLayout.ts +++ /dev/null @@ -1,237 +0,0 @@ -export { getStyleColumnLayout } -export { determineColumnLayoutEntries } - -// A CSS-only solution doesn't seem to exist. -// - The CSS Column Layout (`column-count`) solution down below is hackish and not finished. -// - Maybe it doesn't even work in Safari. -// - Cannot use flexbox. -// - We cannot control wrapping: -// - https://stackoverflow.com/questions/45862033/forcing-a-wrap-on-column-flex-box-layout -// - https://stackoverflow.com/questions/45337454/make-flex-items-wrap-to-create-a-new-column -// - https://stackoverflow.com/questions/27119691/how-to-start-a-new-column-in-flex-column-wrap-layout -// - https://stackoverflow.com/questions/55742578/force-flexbox-to-wrap-after-specific-item-direction-column -// - https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout/Mastering_wrapping_of_flex_items -// - Cannot use grid layout. -// - Cannot use single row, as two elements cannot be put in the same cell: -// - https://stackoverflow.com/questions/45264354/is-it-possible-to-place-more-than-one-element-into-a-css-grid-cell-without-overl/49047281#49047281 -// - Trying with mutliple rows seems to be messy(/impractical?) given the table nature of CSS grid. -// - https://stackoverflow.com/questions/45791809/different-height-of-css-grid-cells -// - Couln't make it work with CSS `float`. -// - https://jsfiddle.net/brillout/3hrLk4am/5/ -// - Misc: -// - https://stackoverflow.com/questions/9683425/css-column-count-not-respected -// - https://stackoverflow.com/questions/25446921/get-flexbox-column-wrap-to-use-full-width-and-minimize-height -// - https://stackoverflow.com/questions/74873283/how-to-create-a-css-grid-with-3-columns-having-column-flow -// - https://stackoverflow.com/questions/50693793/3-columns-grid-top-to-bottom-using-grid-css -// - https://stackoverflow.com/questions/9119347/html-css-vertical-flow-layout-columnar-style-how-to-implement - -import { type NavItemAll } from '../navigation/Navigation' -import { css } from '../utils/css' -import { assert, assertUsage, isBrowser } from '../utils/server' -assert(!isBrowser()) -const columnWidthMin = 300 -const columnWidthMax = 350 - -type NavItemWithLength = NavItemAll & { numberOfHeadings: number | null } -function determineColumnLayoutEntries(navItems: NavItemAll[]): { columnLayouts: number[][] } { - const navItemsWithLength: NavItemWithLength[] = navItems.map((navItem) => ({ - ...navItem, - numberOfHeadings: navItem.level === 1 || navItem.level === 4 ? 0 : null, - })) - let navItemLevel1: NavItemWithLength | undefined - let navItemLevel4: NavItemWithLength | undefined - navItemsWithLength.forEach((navItem) => { - if (navItem.level === 1) { - navItemLevel1 = navItem - navItemLevel4 = undefined - return - } - if (navItem.level === 4) { - navItemLevel4 = navItem - return - } - const bumpNavItemLength = (navItem: NavItemWithLength) => { - assert(navItem.numberOfHeadings !== null) - navItem.numberOfHeadings++ - } - assert(navItemLevel1) - bumpNavItemLength(navItemLevel1) - if (navItemLevel4) { - bumpNavItemLength(navItemLevel4) - } - }) - - const columnLayouts: number[][] = [] - let columns: number[] = [] - let isFullWidth: boolean | undefined - navItemsWithLength.forEach((navItem, i) => { - let isFullWidthBegin = false - if (navItem.level === 1) { - const isFullWidthPrevious = isFullWidth - isFullWidth = !!navItem.menuModalFullWidth - if (isFullWidth) isFullWidthBegin = true - if (isFullWidthPrevious !== undefined && isFullWidthPrevious !== isFullWidth) { - columnLayouts.push(columns) - columns = [] - } - } - const navItemPrevious = navItemsWithLength[i - 1] - const navItemNext = navItemsWithLength[i + 1] - if ( - !isFullWidth ? navItem.level === 1 : (navItem.level === 4 && navItemPrevious!.level !== 1) || isFullWidthBegin - ) { - if (isFullWidth) { - assert(navItem.level === 4 || (navItem.level === 1 && isFullWidthBegin)) - } else { - assert(navItem.level === 1) - } - let { numberOfHeadings } = navItem - assert(numberOfHeadings !== null) - if (isFullWidthBegin) { - assert(navItem.level === 1) - assertUsage( - navItemNext && navItemNext.level === 4, - // We can lift this requirement, but it isn't trivial to implement. - 'level-1 headings with menuModalFullWidth need to be followed by a level-4 heading', - ) - assert(navItemNext.numberOfHeadings) - numberOfHeadings = navItemNext.numberOfHeadings - } - columns.push(numberOfHeadings) - navItems[i].isColumnLayoutElement = true - } - }) - columnLayouts.push(columns) - - return { columnLayouts } -} - -function getStyleColumnLayout(columnLayouts: number[][]): string { - let style = - '\n' + - css` -.column-layout-entry { - break-before: avoid; -} -` - style += '\n' - columnLayouts.forEach((columns, i) => { - for (let numberOfColumns = columns.length; numberOfColumns >= 1; numberOfColumns--) { - let styleGivenNumberOfColumns: string[] = [] - styleGivenNumberOfColumns.push( - css` -.column-layout-${i} { - column-count: ${numberOfColumns}; - max-width: min(100%, ${columnWidthMax * numberOfColumns}px); -} -`, - ) - const columnsIdMap = determineColumns(columns, numberOfColumns) - const columnBreakPoints = determineColumnBreakPoints(columnsIdMap) - columnBreakPoints.forEach((columnBreakPoint, j) => { - const columnBreakPointAfter = columnBreakPoints[j + 1] ?? false - if (!columnBreakPoint && !columnBreakPointAfter) return - // - `break-before: column` isn't supported by Firefox - // - `margin-bottom: 100%` trick only works in Firefox. - // - TODO: apply `margin-bottom: 100%;` only for firefox as it breaks the layout in Chrome - styleGivenNumberOfColumns.push( - css` -.column-layout-${i} .column-layout-entry:nth-child(${j + 1}) { - ${!columnBreakPoint ? '' : 'break-before: column; padding-top: 36px;'} - ${!columnBreakPointAfter || /* TODO */ true ? '' : 'margin-bottom: 100%;'} -} -`, - ) - }) - { - assert(styleGivenNumberOfColumns.length > 0) - const getMaxWidth = (columns: number) => (columns + 1) * columnWidthMin - 1 - const isFirst = numberOfColumns === 1 - const isLast = numberOfColumns === columns.length - const query = [ - !isFirst && `(min-width: ${getMaxWidth(numberOfColumns - 1) + 1}px)`, - !isLast && `(max-width: ${getMaxWidth(numberOfColumns)}px)`, - ] - .filter(Boolean) - .join(' and ') - if (query) { - styleGivenNumberOfColumns = [`@container ${query} {`, ...styleGivenNumberOfColumns, `}`] - } - } - style += styleGivenNumberOfColumns.join('\n') + '\n' - } - }) - return style -} - -function determineColumnBreakPoints(columnsIdMap: number[]): boolean[] { - assert(columnsIdMap[0] === 0) - let columnGroupedIdBefore = 0 - const columnBreakPoints = columnsIdMap.map((columnGroupedId) => { - assert( - [ - // - columnGroupedIdBefore, - columnGroupedIdBefore + 1, - ].includes(columnGroupedId), - ) - const val = columnGroupedId !== columnGroupedIdBefore - columnGroupedIdBefore = columnGroupedId - return val - }) - return columnBreakPoints -} - -function determineColumns(columnsUnmerged: number[], numberOfColumns: number): number[] { - assert(numberOfColumns <= columnsUnmerged.length) - const columnsMergingInit: ColumnMerging[] = columnsUnmerged.map((columnHeight, i) => ({ - columnIdsMerged: [i], - heightTotal: columnHeight, - })) - const columnsMerged = mergeColumns(columnsMergingInit, numberOfColumns) - const columnsIdMap: number[] = new Array(columnsUnmerged.length) - assert(columnsMerged.length === numberOfColumns) - columnsMerged.forEach((columnMerged, columnMergedId) => { - columnMerged.columnIdsMerged.forEach((columnId) => { - columnsIdMap[columnId] = columnMergedId - }) - }) - assert(columnsIdMap.length === columnsUnmerged.length) - - return columnsIdMap -} -type ColumnMerging = { columnIdsMerged: number[]; heightTotal: number } -function mergeColumns(columnsMerging: ColumnMerging[], numberOfColumns: number): ColumnMerging[] { - if (columnsMerging.length <= numberOfColumns) return columnsMerging - - let mergeCandidate: null | (ColumnMerging & { i: number }) = null - for (let i = 0; i <= columnsMerging.length - 2; i++) { - const column1 = columnsMerging[i + 0] - const column2 = columnsMerging[i + 1] - const heightTotal = column1.heightTotal + column2.heightTotal - if (!mergeCandidate || mergeCandidate.heightTotal > heightTotal) { - mergeCandidate = { - i, - columnIdsMerged: [ - // - ...column1.columnIdsMerged, - ...column2.columnIdsMerged, - ], - heightTotal, - } - } - } - assert(mergeCandidate) - - const { i } = mergeCandidate - assert(-1 < i && i < columnsMerging.length - 1) - const columnsMergingMod = [ - // - ...columnsMerging.slice(0, i), - mergeCandidate, - ...columnsMerging.slice(i + 2), - ] - - assert(columnsMergingMod.length === columnsMerging.length - 1) - return mergeColumns(columnsMergingMod, numberOfColumns) -} diff --git a/src/renderer/onRenderHtml.tsx b/src/renderer/onRenderHtml.tsx index 7b3413c..cfc0386 100644 --- a/src/renderer/onRenderHtml.tsx +++ b/src/renderer/onRenderHtml.tsx @@ -6,7 +6,6 @@ import { assert } from '../utils/server' import type { PageContextResolved } from '../config/resolvePageContext' import { getPageElement } from './getPageElement' import type { OnRenderHtmlAsync } from 'vike/types' -import { getStyleColumnLayout } from './getStyleColumnLayout' const onRenderHtml: OnRenderHtmlAsync = async ( pageContext, @@ -16,8 +15,6 @@ Promise>> => { const page = getPageElement(pageContext, pageContextResolved) - const styleMenuModalLayout = getStyleColumnLayout(pageContextResolved.columnLayouts) - const descriptionTag = pageContextResolved.isLandingPage ? dangerouslySkipEscape(``) : '' @@ -33,7 +30,6 @@ Promise>> => { ${descriptionTag} ${getOpenGraphTags(pageContext.urlPathname, pageContextResolved.documentTitle, pageContextResolved.meta)} -