UNPKG

vfrag

Version:

Paginate a document by breaking one or more containers vertically into multiple fragments.

320 lines (262 loc) 9.52 kB
import * as util from "./util.js"; import fragmentElement from "./fragmentElement.js"; export const DEFAULT_SHIFTABLES = "figure:not(.dont-shift), .shiftable"; /** * Return an array of child nodes (or parts thereof) that fit within the target height. * @sideeffect May split exactly one text node. * @sideeffect May split certain block elements * @param {number} target_content_height * @param {Element} container * @returns {object} */ export default async function consumeUntil (target_content_height, container, options = {}) { options.shiftables ??= DEFAULT_SHIFTABLES; options.startAtIndex ??= 0; const nodes = new util.NodeStack(container); let container_style = util.getStyle(container); let lh = container_style.line_height; // Allow exceeding target height by this much (to account for rounding errors etc) let tolerance = (options.tolerance ?? .4) * lh; // Reason for stopping let breaker; if (container_style.text_wrap === "balance" || container_style.text_wrap === "pretty") { container.style.textWrap = "initial"; } let i = options.startAtIndex; for (; i < container.childNodes.length; i++) { let child = container.childNodes[i]; if (options.stopAt) { if (i >= options.stopAt || typeof options.stopAt === "function" && options.stopAt(child, i, container)) { // Stop at index or callback breaker = "stop-at"; break; } } if (!util.affectsLayout(child)) { // Comment nodes, empty text nodes, positioned or hidden elements etc. nodes.pushWeak(child); continue; } let heading = options.headings.get(child); if (heading) { let level = util.getHeadingLevel(heading); for (let i = 0; i < options.openHeadings.length; i++) { let heading = options.openHeadings[i]; if (util.getHeadingLevel(heading) >= level) { options.openHeadings.splice(i--, 1); } } options.openHeadings.push(heading); } let style = util.getStyle(child); if (style) { if (i > 0 && style.break_before === "always") { breaker = "break-before-always"; break; } else if (style.__float === "bottom") { target_content_height -= child.getBoundingClientRect().height; nodes.push(child); continue; } else if (style.float !== "none") { // Don’t bother taking measurements with floated elements nodes.pushWeak(child); continue; } } let loaded = util.ready(child); let asyncTimer = options.asyncTimer ??= util.timer(); if (loaded instanceof Promise) { asyncTimer.start(); await loaded; asyncTimer.pause(); } // Does it fit whole? let fitsWhole = false; nodes.push(child); let height_with_child = nodes.height; if (height_with_child < target_content_height + tolerance) { fitsWhole = true; } else { // Adding this child node would exceed the target height, abort mission! nodes.pop(); } if (fitsWhole) { if (style?.break_after === "always") { breaker = "break-after-always"; break; } else if (style?.break_after === "avoid") { // Convert to weak node nodes.weak[nodes.length - 1] = true; } } else if (util.isFragmentable(child, options)) { if (child.nodeType === Node.TEXT_NODE) { // Handle fragmenting text nodes: find the maximum offset that fits within the target height let maxOffset = util.findMaxOffset(child, nodes.range, target_content_height); let text = child.textContent; if (maxOffset > 0) { // adjust so we're not breaking words halfway let breakAt = container_style.white_space_collapse === "preserve" ? /\r?\n/g : /\P{Letter}/vg; while (maxOffset > 0 && !breakAt.test(text[maxOffset])) { maxOffset--; } } if (maxOffset <= 0) { // Can't break this, move the whole thing to the next fragment breaker = "text-full"; } else { if (maxOffset < text.length) { child.splitText(maxOffset); breaker = "fragmentation"; } else { // If we went down this path even though the text node fits whole, // we certainly can't fit more! breaker = "text-full"; } nodes.push(child); } break; } else { // element let remaining_height = target_content_height - nodes.height + tolerance; let empty_lines = remaining_height / lh; if (empty_lines >= style.orphans) { let child_height = child.getBoundingClientRect().height; let child_lines = child_height / lh; let is_large_enough = child_lines >= style.orphans + style.widows - .01; if (is_large_enough) { child.normalize(); let max_fragment_height = Math.min(remaining_height, child_height - style.widows * lh); let consumeOptions = { ...options, startAtIndex: 0, }; let consumed = await consumeUntil(max_fragment_height, child, consumeOptions); if (consumed.nodes.length > 0) { // Why not just depend on the height calculation? // Because some types of fragmentation produce fragments that have a certain minimum height anyway, // e.g. fragmenting <details> produces another <summary> too let remaining = [...child.childNodes].slice(consumed.nodes.length); if (consumed.nodes.lengthStrong > 0 && remaining.filter(util.affectsLayout).length > 0) { let fragment = fragmentElement(child, consumed); nodes.push(fragment); breaker = "fragmentation"; break; } } } } } // If we've reached the point of fragmenting a node, we definitely can't fit more breaker = "no-space"; break; } else if (nodes.height === 0 || nodes.lengthStrong === 0) { // This is an item that is larger than the available space by itself and can't be fragmented // This is usually the first child, but not always, e.g. it may be preceded by an element with break-after: avoid; such as a heading. // Take it because it has to go somewhere but don’t try to fit anything else nodes.push(child); console.warn("Overly large element:", child, `(${ child.getBoundingClientRect().height } > ${ target_content_height })`); breaker = "oversized"; break; } else if (util.isShiftable(child, options)) { // This element can be shifted up/down, i.e. doesn’t depend on the content flow // We only shift when it’s not the first (layout-affecting) node in the page (which is taken care of by the previous condition) // Should we shift it up or down? Let’s examine both and see what produces better results. // We cannot shift it up beyond its heading, or another shiftable let heading = options.openHeadings.at(-1); let headingLevel = util.getHeadingLevel(heading); let minIndex = nodes.findLastIndex((n, i) => n === heading || n.matches?.(options.shiftables)); let height = child.getBoundingClientRect().height; // Try shifting up first let up = {}; // This is where we'd need to shift up to have enough space let heightIndex = nodes.indexOfHeight(target_content_height - height); up.go = heightIndex >= minIndex && heightIndex < i; let consumeOptions = { ...options, startAtIndex: i + 1, // We cannot shift beyond a heading with level <= of heading or another shiftable stopAt: n => util.getHeadingLevel(n) >= headingLevel || util.isShiftable(n, options), }; if (up.go) { // We can shift it up up.index = Math.max(0, minIndex, heightIndex); up.emptySpace = target_content_height - nodes.heightAt(up.index) - height; if (up.emptySpace > 1) { // We may still need to fragment something up.consumed = await consumeUntil(up.emptySpace, container, consumeOptions); } } // Now try shifting down let down = {}; down.emptySpace = target_content_height - nodes.height; down.consumed = await consumeUntil(down.emptySpace, container, consumeOptions); down.go = down.consumed.nodes.length > 0; if (down.go) { down.emptySpace -= down.consumed.nodes.height; } if (!(up.go || down.go)) { // Shifting is not an option breaker = "no-shift"; break; } // Is shifting up better? let shift = up.go && (!down.go || up.emptySpace < down.emptySpace) ? up : down; let shiftNodes = Number(child.dataset.shift || 0); if (shift === up) { let firstNode = nodes[0]; shiftNodes -= (nodes.length - up.index); while (nodes.length > up.index) { nodes.pop(); } if (nodes.last) { nodes.last.after(child); } else { // Shifting up would make it the first node on the page // Insert it before the last node we removed firstNode.before(child); } nodes.push(child); } else { // Shift down (i.e. to next page) down.consumed.nodes.last.after(child); } if (shift.consumed?.nodes.length > 0) { shiftNodes += shift.consumed.nodes.length; nodes.append(shift.consumed.nodes); breaker = shift.consumed.breaker; } else { breaker = "shift"; } child.dataset.shift = shiftNodes; break; } } if (i === container.childNodes.length) { breaker = "end"; } else { nodes.popWeak(); } if (container.style.textWrap === "initial") { // Restore original value. // ASSUMPTION text-wrap was not specified as an inline style. container.style.textWrap = ""; } let remaining_height = target_content_height - nodes.height; let empty = Math.max(0, remaining_height); let emptyLines = empty / lh; let openHeadings = options.openHeadings?.slice() ?? []; return { nodes, empty, emptyLines, breaker, openHeadings }; }