Path: blob/main/src/format/dashboard/format-dashboard-layout.ts
6451 views
/*1* format-dashboard-fill.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { Document, Element } from "../../core/deno-dom.ts";7import { isValueBox } from "./format-dashboard-valuebox.ts";8import { asCssSize } from "../../core/css.ts";9import {10kCardClass,11kDashboardGridSkip,12kDontMutateTags,13kFillAttr,14kLayoutFill,15kLayoutFlow,16Layout,17} from "./format-dashboard-shared.ts";18import { isFlowCard } from "./format-dashboard-card.ts";1920// Container type classes21const kRowsClass = "rows";22const kColumnsClass = "columns";2324// Explicit size attributes25const kHeightAttr = "data-height";26const kWidthAttr = "data-width";2728// bslib classes29const kBsLibGridClass = "bslib-grid";30const kHtmlFillItemClass = "html-fill-item";31const kHtmlFillContainerClass = "html-fill-container";3233const kHiddenClass = "hidden";3435interface FillDescriptor {36tags: [{37name: string;38ignoreClasses: string[];39}];40classes: string[];41}4243const kNeverFillClasses = [44"value-box-grid",45"value-box-area",46"value-box-title",47"value-box-value",48"nav-tabs",49"card-header",50"card-footer",51"callout",52"h1",53"h2",54"h3",55"h4",56"h5",57"h6",58"input-panel",59"toolbar",60"flow",61];6263// Configuration for skipping elements when applying container classes64// (we skip applying container classes to the following):65const kFillContentElements: FillDescriptor = {66tags: [{67name: "div",68ignoreClasses: kNeverFillClasses,69}],70classes: [71"card",72"card-body",73"tab-pane",74"tab-content",75"tabset",76"bslib-grid",77"bslib-grid-item",78"sidebar-content",79"main",80"bslib-sidebar-layout",81"cell-output-display",82"quarto-float",83],84};8586const kFillContainerElements: FillDescriptor = {87tags: kFillContentElements.tags,88classes: [89...kFillContentElements.classes,90"bslib-page-fill",91],92};9394const kFillDontRecurseInsideClasses = ["sidebar", "toolbar"];9596// Process row Elements (computing the grid heights for the97// row and applying bslib style classes)98export function processRows(doc: Document) {99// Adjust the appearance of row elements100const rowNodes = doc.querySelectorAll(`div.${kRowsClass}`);101if (rowNodes !== null) {102for (const rowNode of rowNodes) {103const rowEl = rowNode as Element;104// Decorate the row element105rowEl.classList.add(kBsLibGridClass);106rowEl.classList.remove(kRowsClass);107108// Compute the layouts for ths rows in this rowEl109const rowLayouts = computeRowLayouts(rowEl);110111// Compute the percent conversion factor112const fillFr = computeFillFr(rowLayouts);113114// Create the grid-template-rows value based upon the layouts115const rowGridSizes = rowLayouts.map((layout) => {116return toGridSize(layout, fillFr);117});118const gridTemplRowsVal = `${rowGridSizes.join(" ")}`;119120// Apply the grid styles121const currentStyle = rowEl.getAttribute("style");122const template =123`display: grid; grid-template-rows: ${gridTemplRowsVal}; grid-auto-columns: minmax(0, 1fr);`;124rowEl.setAttribute(125"style",126currentStyle === null ? template : `${currentStyle}\n${template}`,127);128}129}130}131132// Process column elements133export function processColumns(doc: Document) {134// Adjust the appearance of column element135const colNodes = doc.querySelectorAll(`div.${kColumnsClass}`);136if (colNodes !== null) {137for (const colNode of colNodes) {138const colEl = colNode as Element;139140// Decorate the column141colEl.classList.add(kBsLibGridClass);142colEl.classList.remove(kColumnsClass);143144// Compute the column sizes145const colLayouts = computeColumnLayouts(colEl);146147// Compute the percent conversion factor148const fillFr = computeFillFr(colLayouts);149150// Create the grid-template-rows value based upon the layouts151const gridTemplColVal = `${152colLayouts.map((layout) => {153return toGridSize(layout, fillFr);154}).join(" ")155}`;156157// Apply the grid styles158const currentStyle = colEl.getAttribute("style");159const template =160`display: grid; grid-template-columns: ${gridTemplColVal};\ngrid-auto-rows: minmax(0, 1fr);`;161colEl.setAttribute(162"style",163currentStyle === null ? template : `${currentStyle}\n${template}`,164);165}166}167}168169function computeColumnLayouts(colEl: Element) {170const layouts: Layout[] = [];171for (const childEl of colEl.children) {172if (173childEl.classList.contains(kHiddenClass) ||174childEl.classList.contains(kDashboardGridSkip)175) {176// Skip this, it is hidden177} else {178const explicitWidth = childEl.getAttribute(kWidthAttr);179if (explicitWidth !== null) {180childEl.removeAttribute(kWidthAttr);181layouts.push(explicitWidth);182} else {183layouts.push(kLayoutFill);184}185}186}187return layouts;188}189190// TODO: We could improve this by pre-computing the row layouts191// and sharing them so we aren't re-recursing through the document192// rows to compute heights193function computeRowLayouts(rowEl: Element) {194// Capture the parent's fill setting. This will be used195// to cascade to the child, when needed196const parentFillRaw = rowEl.getAttribute(kFillAttr);197const parentLayout = parentFillRaw !== null ? asLayout(parentFillRaw) : null;198199// Build a set of layouts for this row by looking at the children of200// the row201const layouts: Layout[] = [];202for (const childEl of rowEl.children) {203// If the child has an explicitly set height, just use that204const explicitHeight = childEl.getAttribute(kHeightAttr);205if (206childEl.classList.contains(kHiddenClass) ||207childEl.classList.contains(kDashboardGridSkip)208) {209// Skip this, it is hidden210} else if (explicitHeight !== null) {211childEl.removeAttribute(kHeightAttr);212layouts.push(explicitHeight);213} else {214// The child height isn't explicitly set, figure out the layout215const fill = childEl.getAttribute(kFillAttr);216if (fill !== null) {217// That child has either an explicitly set `fill` or `flow` layout218// attribute, so just use that explicit value219layouts.push(asLayout(fill));220} else {221// This is `auto` mode - no explicit size information is222// being provided, so we need to figure out what size223// this child would like224if (childEl.classList.contains(kRowsClass)) {225// This child is a row, so process that row and use it's computed226// layout227// If any children are fill children, then this layout is a fill layout228const rowLayouts = computeRowLayouts(childEl);229if (rowLayouts.some((layout) => layout === kLayoutFill)) {230layouts.push(kLayoutFill);231} else {232layouts.push(kLayoutFlow);233}234} else if (childEl.classList.contains(kColumnsClass)) {235// This child is a column, allow it to provide a layout236// based upon its own contents237const layout = rowLayoutForColumn(childEl, parentLayout);238layouts.push(layout);239} else if (childEl.classList.contains(kCardClass)) {240const isFlow = isFlowCard(childEl);241layouts.push(isFlow ? kLayoutFlow : kLayoutFill);242} else {243if (parentLayout !== null) {244// This isn't a row or column, if possible, just use245// the parent layout. Otherwise, just make it fill246layouts.push(parentLayout);247} else {248// Just make a fill249layouts.push(kLayoutFill);250}251}252}253}254}255return layouts;256}257258function toGridSize(layout: Layout, fillFr: number) {259if (layout === kLayoutFill) {260// Use the fillFr units (which have been calculated)261return `minmax(${kMinSizeRow}, ${fillFr}fr)`;262} else if (layout === kLayoutFlow) {263return `minmax(${kMinSizeRow}, max-content)`;264} else {265if (layout.endsWith("px")) {266// Explicit pixels should specify the exact size267return layout;268} else if (layout.match(kEndsWithNumber)) {269// Not including units means pixels270return `${layout}px`;271} else if (layout.endsWith("%")) {272// Convert percentages to fr units (just strip the percent and use fr)273const percentRaw = parseFloat(layout.slice(0, -1));274const layoutSize = `minmax(${kMinSizeRow}, ${percentRaw}fr)`;275return layoutSize;276} else {277// It has units, pass it through as is278return `minmax(${kMinSizeRow}, ${asCssSize(layout)})`;279}280}281}282const kEndsWithNumber = /[0-9]$/;283const kMinSizeRow = "3em";284285function computeFillFr(layouts: Layout[]) {286const percents: number[] = [];287let unallocatedFills = 0;288for (const layout of layouts) {289if (layout === kLayoutFill) {290unallocatedFills++;291} else if (layout.endsWith("%")) {292const unitless = layout.slice(0, -1);293percents.push(parseFloat(unitless));294}295}296297const allocatedPercent = percents.reduce((prev, current) => {298return prev + current;299}, 0);300301// By default, we'll just use a 1 fr baseline302// If the user has provided some percentage based303// measures, we'll use those to compute a new baseline304// fr (which is scaled to use the remain unallocated percentage)305let fillFr = 1;306if (allocatedPercent > 0) {307if (allocatedPercent < 100) {308fillFr = (100 - allocatedPercent) / unallocatedFills;309} else {310fillFr = percents[percents.length - 1];311}312}313return fillFr;314}315316// Coerce the layout to value valid317function asLayout(fill: string): Layout {318if (fill !== "false") {319return kLayoutFill;320} else {321return kLayoutFlow;322}323}324325type FlowLayoutDetector = (el: Element) => boolean;326327const kFlowLayoutDetectors: FlowLayoutDetector[] = [328isValueBox,329isFlowCard,330];331332function suggestsFlowLayout(el: Element) {333return kFlowLayoutDetectors.some((detector) => {334return detector(el);335});336}337338// Suggest a layout for an element339function suggestLayout(el: Element) {340const explicitFill = el.getAttribute(kFillAttr);341if (explicitFill !== null) {342return explicitFill !== "false" ? kLayoutFill : kLayoutFlow;343} else {344if (suggestsFlowLayout(el)) {345return kLayoutFlow;346} else {347return kLayoutFill;348}349}350}351352// Suggest a layout for a column (using a default value)353function rowLayoutForColumn(colEl: Element, defaultLayout: Layout | null) {354const layouts: Layout[] = [];355for (const childEl of colEl.children) {356layouts.push(suggestLayout(childEl));357}358return layouts.some((layout) => layout === kLayoutFill)359? defaultLayout ? defaultLayout : kLayoutFill360: kLayoutFlow;361}362363// Recursively applies fill classes, skipping elements that364// should be skipped365export const recursiveApplyFillClasses = (el: Element) => {366applyFillItemClasses(el);367applyFillContainerClasses(el);368for (const childEl of el.children) {369const recurse = !kFillDontRecurseInsideClasses.some((cls) => {370return el.classList.contains(cls);371});372373if (recurse) {374recursiveApplyFillClasses(childEl);375}376}377};378379const shouldApplyClasses = (el: Element, fillDescriptor: FillDescriptor) => {380if (kDontMutateTags.includes(el.tagName.toUpperCase())) {381return false;382}383384// Classes to ignore no matter what385if (386kNeverFillClasses.some((neverFillClass) => {387return el.classList.contains(neverFillClass);388})389) {390return false;391}392393// TODO: This is sort of hacked in right here, but could394// likely be place somewhere else better.395if (el.tagName === "DIV" && el.children.length > 0) {396// If this has only flow children then leave the class off397let hasFillChild = false;398for (const childNode of el.childNodes) {399const childEl = childNode as Element;400if (!childEl.classList?.contains(kLayoutFlow)) {401hasFillChild = true;402break;403}404}405return hasFillChild;406}407408const fillForClass = fillDescriptor.classes.some((clz) => {409if (el.classList.contains(clz)) {410return true;411}412});413if (fillForClass) {414return true;415}416417const fillForTagDesc = fillDescriptor.tags.some((tagDesc) => {418return tagDesc.name.toLowerCase() === el.tagName.toLowerCase() &&419!tagDesc.ignoreClasses.some((clz) => {420return el.classList.contains(clz);421});422});423if (fillForTagDesc) {424return true;425}426return false;427};428429export const applyFillItemClasses = (el: Element) => {430if (shouldApplyClasses(el, kFillContentElements)) {431el.classList.add(kHtmlFillItemClass);432}433};434435const applyFillContainerClasses = (el: Element) => {436if (shouldApplyClasses(el, kFillContainerElements)) {437el.classList.add(kHtmlFillContainerClass);438}439};440441442