Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/format/dashboard/format-dashboard-layout.ts
6451 views
1
/*
2
* format-dashboard-fill.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { Document, Element } from "../../core/deno-dom.ts";
8
import { isValueBox } from "./format-dashboard-valuebox.ts";
9
import { asCssSize } from "../../core/css.ts";
10
import {
11
kCardClass,
12
kDashboardGridSkip,
13
kDontMutateTags,
14
kFillAttr,
15
kLayoutFill,
16
kLayoutFlow,
17
Layout,
18
} from "./format-dashboard-shared.ts";
19
import { isFlowCard } from "./format-dashboard-card.ts";
20
21
// Container type classes
22
const kRowsClass = "rows";
23
const kColumnsClass = "columns";
24
25
// Explicit size attributes
26
const kHeightAttr = "data-height";
27
const kWidthAttr = "data-width";
28
29
// bslib classes
30
const kBsLibGridClass = "bslib-grid";
31
const kHtmlFillItemClass = "html-fill-item";
32
const kHtmlFillContainerClass = "html-fill-container";
33
34
const kHiddenClass = "hidden";
35
36
interface FillDescriptor {
37
tags: [{
38
name: string;
39
ignoreClasses: string[];
40
}];
41
classes: string[];
42
}
43
44
const kNeverFillClasses = [
45
"value-box-grid",
46
"value-box-area",
47
"value-box-title",
48
"value-box-value",
49
"nav-tabs",
50
"card-header",
51
"card-footer",
52
"callout",
53
"h1",
54
"h2",
55
"h3",
56
"h4",
57
"h5",
58
"h6",
59
"input-panel",
60
"toolbar",
61
"flow",
62
];
63
64
// Configuration for skipping elements when applying container classes
65
// (we skip applying container classes to the following):
66
const kFillContentElements: FillDescriptor = {
67
tags: [{
68
name: "div",
69
ignoreClasses: kNeverFillClasses,
70
}],
71
classes: [
72
"card",
73
"card-body",
74
"tab-pane",
75
"tab-content",
76
"tabset",
77
"bslib-grid",
78
"bslib-grid-item",
79
"sidebar-content",
80
"main",
81
"bslib-sidebar-layout",
82
"cell-output-display",
83
"quarto-float",
84
],
85
};
86
87
const kFillContainerElements: FillDescriptor = {
88
tags: kFillContentElements.tags,
89
classes: [
90
...kFillContentElements.classes,
91
"bslib-page-fill",
92
],
93
};
94
95
const kFillDontRecurseInsideClasses = ["sidebar", "toolbar"];
96
97
// Process row Elements (computing the grid heights for the
98
// row and applying bslib style classes)
99
export function processRows(doc: Document) {
100
// Adjust the appearance of row elements
101
const rowNodes = doc.querySelectorAll(`div.${kRowsClass}`);
102
if (rowNodes !== null) {
103
for (const rowNode of rowNodes) {
104
const rowEl = rowNode as Element;
105
// Decorate the row element
106
rowEl.classList.add(kBsLibGridClass);
107
rowEl.classList.remove(kRowsClass);
108
109
// Compute the layouts for ths rows in this rowEl
110
const rowLayouts = computeRowLayouts(rowEl);
111
112
// Compute the percent conversion factor
113
const fillFr = computeFillFr(rowLayouts);
114
115
// Create the grid-template-rows value based upon the layouts
116
const rowGridSizes = rowLayouts.map((layout) => {
117
return toGridSize(layout, fillFr);
118
});
119
const gridTemplRowsVal = `${rowGridSizes.join(" ")}`;
120
121
// Apply the grid styles
122
const currentStyle = rowEl.getAttribute("style");
123
const template =
124
`display: grid; grid-template-rows: ${gridTemplRowsVal}; grid-auto-columns: minmax(0, 1fr);`;
125
rowEl.setAttribute(
126
"style",
127
currentStyle === null ? template : `${currentStyle}\n${template}`,
128
);
129
}
130
}
131
}
132
133
// Process column elements
134
export function processColumns(doc: Document) {
135
// Adjust the appearance of column element
136
const colNodes = doc.querySelectorAll(`div.${kColumnsClass}`);
137
if (colNodes !== null) {
138
for (const colNode of colNodes) {
139
const colEl = colNode as Element;
140
141
// Decorate the column
142
colEl.classList.add(kBsLibGridClass);
143
colEl.classList.remove(kColumnsClass);
144
145
// Compute the column sizes
146
const colLayouts = computeColumnLayouts(colEl);
147
148
// Compute the percent conversion factor
149
const fillFr = computeFillFr(colLayouts);
150
151
// Create the grid-template-rows value based upon the layouts
152
const gridTemplColVal = `${
153
colLayouts.map((layout) => {
154
return toGridSize(layout, fillFr);
155
}).join(" ")
156
}`;
157
158
// Apply the grid styles
159
const currentStyle = colEl.getAttribute("style");
160
const template =
161
`display: grid; grid-template-columns: ${gridTemplColVal};\ngrid-auto-rows: minmax(0, 1fr);`;
162
colEl.setAttribute(
163
"style",
164
currentStyle === null ? template : `${currentStyle}\n${template}`,
165
);
166
}
167
}
168
}
169
170
function computeColumnLayouts(colEl: Element) {
171
const layouts: Layout[] = [];
172
for (const childEl of colEl.children) {
173
if (
174
childEl.classList.contains(kHiddenClass) ||
175
childEl.classList.contains(kDashboardGridSkip)
176
) {
177
// Skip this, it is hidden
178
} else {
179
const explicitWidth = childEl.getAttribute(kWidthAttr);
180
if (explicitWidth !== null) {
181
childEl.removeAttribute(kWidthAttr);
182
layouts.push(explicitWidth);
183
} else {
184
layouts.push(kLayoutFill);
185
}
186
}
187
}
188
return layouts;
189
}
190
191
// TODO: We could improve this by pre-computing the row layouts
192
// and sharing them so we aren't re-recursing through the document
193
// rows to compute heights
194
function computeRowLayouts(rowEl: Element) {
195
// Capture the parent's fill setting. This will be used
196
// to cascade to the child, when needed
197
const parentFillRaw = rowEl.getAttribute(kFillAttr);
198
const parentLayout = parentFillRaw !== null ? asLayout(parentFillRaw) : null;
199
200
// Build a set of layouts for this row by looking at the children of
201
// the row
202
const layouts: Layout[] = [];
203
for (const childEl of rowEl.children) {
204
// If the child has an explicitly set height, just use that
205
const explicitHeight = childEl.getAttribute(kHeightAttr);
206
if (
207
childEl.classList.contains(kHiddenClass) ||
208
childEl.classList.contains(kDashboardGridSkip)
209
) {
210
// Skip this, it is hidden
211
} else if (explicitHeight !== null) {
212
childEl.removeAttribute(kHeightAttr);
213
layouts.push(explicitHeight);
214
} else {
215
// The child height isn't explicitly set, figure out the layout
216
const fill = childEl.getAttribute(kFillAttr);
217
if (fill !== null) {
218
// That child has either an explicitly set `fill` or `flow` layout
219
// attribute, so just use that explicit value
220
layouts.push(asLayout(fill));
221
} else {
222
// This is `auto` mode - no explicit size information is
223
// being provided, so we need to figure out what size
224
// this child would like
225
if (childEl.classList.contains(kRowsClass)) {
226
// This child is a row, so process that row and use it's computed
227
// layout
228
// If any children are fill children, then this layout is a fill layout
229
const rowLayouts = computeRowLayouts(childEl);
230
if (rowLayouts.some((layout) => layout === kLayoutFill)) {
231
layouts.push(kLayoutFill);
232
} else {
233
layouts.push(kLayoutFlow);
234
}
235
} else if (childEl.classList.contains(kColumnsClass)) {
236
// This child is a column, allow it to provide a layout
237
// based upon its own contents
238
const layout = rowLayoutForColumn(childEl, parentLayout);
239
layouts.push(layout);
240
} else if (childEl.classList.contains(kCardClass)) {
241
const isFlow = isFlowCard(childEl);
242
layouts.push(isFlow ? kLayoutFlow : kLayoutFill);
243
} else {
244
if (parentLayout !== null) {
245
// This isn't a row or column, if possible, just use
246
// the parent layout. Otherwise, just make it fill
247
layouts.push(parentLayout);
248
} else {
249
// Just make a fill
250
layouts.push(kLayoutFill);
251
}
252
}
253
}
254
}
255
}
256
return layouts;
257
}
258
259
function toGridSize(layout: Layout, fillFr: number) {
260
if (layout === kLayoutFill) {
261
// Use the fillFr units (which have been calculated)
262
return `minmax(${kMinSizeRow}, ${fillFr}fr)`;
263
} else if (layout === kLayoutFlow) {
264
return `minmax(${kMinSizeRow}, max-content)`;
265
} else {
266
if (layout.endsWith("px")) {
267
// Explicit pixels should specify the exact size
268
return layout;
269
} else if (layout.match(kEndsWithNumber)) {
270
// Not including units means pixels
271
return `${layout}px`;
272
} else if (layout.endsWith("%")) {
273
// Convert percentages to fr units (just strip the percent and use fr)
274
const percentRaw = parseFloat(layout.slice(0, -1));
275
const layoutSize = `minmax(${kMinSizeRow}, ${percentRaw}fr)`;
276
return layoutSize;
277
} else {
278
// It has units, pass it through as is
279
return `minmax(${kMinSizeRow}, ${asCssSize(layout)})`;
280
}
281
}
282
}
283
const kEndsWithNumber = /[0-9]$/;
284
const kMinSizeRow = "3em";
285
286
function computeFillFr(layouts: Layout[]) {
287
const percents: number[] = [];
288
let unallocatedFills = 0;
289
for (const layout of layouts) {
290
if (layout === kLayoutFill) {
291
unallocatedFills++;
292
} else if (layout.endsWith("%")) {
293
const unitless = layout.slice(0, -1);
294
percents.push(parseFloat(unitless));
295
}
296
}
297
298
const allocatedPercent = percents.reduce((prev, current) => {
299
return prev + current;
300
}, 0);
301
302
// By default, we'll just use a 1 fr baseline
303
// If the user has provided some percentage based
304
// measures, we'll use those to compute a new baseline
305
// fr (which is scaled to use the remain unallocated percentage)
306
let fillFr = 1;
307
if (allocatedPercent > 0) {
308
if (allocatedPercent < 100) {
309
fillFr = (100 - allocatedPercent) / unallocatedFills;
310
} else {
311
fillFr = percents[percents.length - 1];
312
}
313
}
314
return fillFr;
315
}
316
317
// Coerce the layout to value valid
318
function asLayout(fill: string): Layout {
319
if (fill !== "false") {
320
return kLayoutFill;
321
} else {
322
return kLayoutFlow;
323
}
324
}
325
326
type FlowLayoutDetector = (el: Element) => boolean;
327
328
const kFlowLayoutDetectors: FlowLayoutDetector[] = [
329
isValueBox,
330
isFlowCard,
331
];
332
333
function suggestsFlowLayout(el: Element) {
334
return kFlowLayoutDetectors.some((detector) => {
335
return detector(el);
336
});
337
}
338
339
// Suggest a layout for an element
340
function suggestLayout(el: Element) {
341
const explicitFill = el.getAttribute(kFillAttr);
342
if (explicitFill !== null) {
343
return explicitFill !== "false" ? kLayoutFill : kLayoutFlow;
344
} else {
345
if (suggestsFlowLayout(el)) {
346
return kLayoutFlow;
347
} else {
348
return kLayoutFill;
349
}
350
}
351
}
352
353
// Suggest a layout for a column (using a default value)
354
function rowLayoutForColumn(colEl: Element, defaultLayout: Layout | null) {
355
const layouts: Layout[] = [];
356
for (const childEl of colEl.children) {
357
layouts.push(suggestLayout(childEl));
358
}
359
return layouts.some((layout) => layout === kLayoutFill)
360
? defaultLayout ? defaultLayout : kLayoutFill
361
: kLayoutFlow;
362
}
363
364
// Recursively applies fill classes, skipping elements that
365
// should be skipped
366
export const recursiveApplyFillClasses = (el: Element) => {
367
applyFillItemClasses(el);
368
applyFillContainerClasses(el);
369
for (const childEl of el.children) {
370
const recurse = !kFillDontRecurseInsideClasses.some((cls) => {
371
return el.classList.contains(cls);
372
});
373
374
if (recurse) {
375
recursiveApplyFillClasses(childEl);
376
}
377
}
378
};
379
380
const shouldApplyClasses = (el: Element, fillDescriptor: FillDescriptor) => {
381
if (kDontMutateTags.includes(el.tagName.toUpperCase())) {
382
return false;
383
}
384
385
// Classes to ignore no matter what
386
if (
387
kNeverFillClasses.some((neverFillClass) => {
388
return el.classList.contains(neverFillClass);
389
})
390
) {
391
return false;
392
}
393
394
// TODO: This is sort of hacked in right here, but could
395
// likely be place somewhere else better.
396
if (el.tagName === "DIV" && el.children.length > 0) {
397
// If this has only flow children then leave the class off
398
let hasFillChild = false;
399
for (const childNode of el.childNodes) {
400
const childEl = childNode as Element;
401
if (!childEl.classList?.contains(kLayoutFlow)) {
402
hasFillChild = true;
403
break;
404
}
405
}
406
return hasFillChild;
407
}
408
409
const fillForClass = fillDescriptor.classes.some((clz) => {
410
if (el.classList.contains(clz)) {
411
return true;
412
}
413
});
414
if (fillForClass) {
415
return true;
416
}
417
418
const fillForTagDesc = fillDescriptor.tags.some((tagDesc) => {
419
return tagDesc.name.toLowerCase() === el.tagName.toLowerCase() &&
420
!tagDesc.ignoreClasses.some((clz) => {
421
return el.classList.contains(clz);
422
});
423
});
424
if (fillForTagDesc) {
425
return true;
426
}
427
return false;
428
};
429
430
export const applyFillItemClasses = (el: Element) => {
431
if (shouldApplyClasses(el, kFillContentElements)) {
432
el.classList.add(kHtmlFillItemClass);
433
}
434
};
435
436
const applyFillContainerClasses = (el: Element) => {
437
if (shouldApplyClasses(el, kFillContainerElements)) {
438
el.classList.add(kHtmlFillContainerClass);
439
}
440
};
441
442