dropflow
Version:
A small CSS2 document renderer built from specifications
465 lines (464 loc) • 14.6 kB
JavaScript
import { id, Logger } from './util.js';
export class RenderItem {
style;
constructor(style) {
this.style = style;
}
isBlockContainer() {
return false;
}
isRun() {
return false;
}
isInline() {
return false;
}
isBreak() {
return false;
}
isIfcInline() {
return false;
}
isBox() {
return false;
}
/**
* A layer is a stacking context root or an element that CSS 2.1 appendix E
* says to treat like one.
*/
isLayerRoot() {
return this.isBlockContainer() && this.isFloat() || this.isBox() && this.isPositioned();
}
/**
* Does this paint anything in the background layer? Borders, box-shadow, etc.
*/
hasBackground() {
return false;
}
/**
* Does this paint anything in the foreground layer? Text, images, etc.
*/
hasForeground() {
return false;
}
/**
* There is a background in some descendent that is part of the same paint
* layer (not necessarily in the subject). (See also isLayerRoot).
*
* A background is a background-color or anything CSS 2.1 appendix E groups
* with it.
*/
hasBackgroundInLayerRoot() {
return false;
}
/**
* There is a foreground in some descendent that is part of the same paint
* layer (not necessarily in the subject). (See also isLayerRoot).
*
* A foreground is a text run or anything CSS 2.1 appendix E groups with it
*/
hasForegroundInLayerRoot() {
return false;
}
/**
* There is a background somewhere beneath this node
*
* A background is a background-color or anything CSS 2.1 appendix E groups
* with it
*/
hasBackgroundInDescendent() {
return false;
}
/**
* There is a foreground somewhere beneath this node
*
* A foreground is a text run or anything CSS 2.1 appendix E groups with it
*/
hasForegroundInDescendent() {
return false;
}
log(options, log) {
const flush = !log;
log ||= new Logger();
if (this.isIfcInline()) {
options = { ...options };
options.paragraphText = this.text;
}
log.text(`${this.getLogSymbol()} `);
this.logName(log, options);
if (options?.containingBlocks && this.isBlockContainer()) {
log.text(` (cb: ${this.containingBlock?.box.id ?? '(null)'})`);
}
if (options?.css) {
const css = this.style[options.css];
log.text(` (${options.css}: ${css && JSON.stringify(css)})`);
}
if (options?.bits && this.isBox()) {
log.text(` (bf: ${this.stringifyBitfield()})`);
}
log.text('\n');
if (this.isBox() && this.children.length) {
log.pushIndent();
for (let i = 0; i < this.children.length; i++) {
this.children[i].log(options, log);
}
log.popIndent();
}
if (flush)
log.flush();
}
/**
* Typically the time to shape text and gather font metrics
*/
prelayout() {
// should be overridden
}
/**
* Typically the time to absolutize relative coordinates
*/
postlayoutPreorder() {
// should be overridden
}
/**
* Typically the time to snap pixels
*/
postlayoutPostorder() {
// should be overridden
}
}
export class Box extends RenderItem {
id;
children;
containingBlock;
/**
* General boolean bitfield shared by all box subclasses. The bits labeled
* with "has" say something about their content to allow for optimizations.
* They propagate through to parents of the same type, though some of them
* do so conditionally.
*/
bitfield;
/**
* Bitfield allocations. Box subclasses with different inheritance are allowed
* to overlap attribute bits or propagate target bits. It's easier to keep
* these all in one place than try to define them on the subclasses.
*/
static BITS = {
// 0..3: misc attributes for all box types:
isAnonymous: 1 << 0,
enableLogging: 1 << 1,
reserved1: 1 << 2, // this padding makes the logs easier to
reserved2: 1 << 3, // read (distinguish attrs from has bits)
// 4..7: propagation bits: Box <- Box
hasBackgroundInLayer: 1 << 4,
hasForegroundInLayer: 1 << 5,
hasBackgroundInDescendent: 1 << 6,
hasForegroundInDescendent: 1 << 7,
// 8..9: attributes for BlockContainer:
//
// Inline or block-level: we can't use the style for this since anonymously
// created block containers are block-level but their style is inline (the
// initial value). Potentially we could remove this and say that it's block
// level if it's anonymous.
//
// Other CSS rules that affect how a block container is treated during
// layout do not have this problem (position: absolute, display: inline-
// block) because anonymously created boxes cannot invoke those modes.
isInline: 1 << 8,
isBfcRoot: 1 << 9,
// 8..13: propagation bits: Inline <- Run
hasText: 1 << 8,
hasComplexText: 1 << 9,
hasSoftHyphen: 1 << 10,
hasNewlines: 1 << 11,
hasSoftWrap: 1 << 12,
hasCollapsibleWs: 1 << 13,
// 14..18: propagation bits: Inline <- Inline
hasInlines: 1 << 14,
hasPaintedInlines: 1 << 15,
hasPositionedInline: 1 << 16,
hasColoredInline: 1 << 17,
hasSizedInline: 1 << 18,
// 19: propagation bits: Inline <- Break
hasBreaks: 1 << 19,
// 20..21: propagation bits: Inline <- BlockContainer
hasFloats: 1 << 20,
hasInlineBlocks: 1 << 21,
// 22..32: if you take them, remove them from PROPAGATES_TO_INLINE_BITS
};
/**
* Use this, not BITS, for the ctor! BITS are ~private
*/
static ATTRS = {
isAnonymous: Box.BITS.isAnonymous,
enableLogging: Box.BITS.enableLogging,
};
static PROPAGATES_TO_INLINE_BITS = 0xffffff00;
static BITFIELD_END = 8;
constructor(style, children, attrs) {
super(style);
this.id = id();
this.children = children;
this.bitfield = attrs;
this.containingBlock = EmptyContainingBlock;
}
propagate(parent) {
if (!this.isLayerRoot()) {
if (this.hasBackground() || this.hasBackgroundInLayerRoot()) {
parent.bitfield |= Box.BITS.hasBackgroundInLayer;
}
if (this.hasForeground() || this.hasForegroundInLayerRoot()) {
parent.bitfield |= Box.BITS.hasForegroundInLayer;
}
}
if (this.hasBackground() || this.hasBackgroundInDescendent()) {
parent.bitfield |= Box.BITS.hasBackgroundInDescendent;
}
if (this.hasForeground() || this.hasForegroundInDescendent()) {
parent.bitfield |= Box.BITS.hasForegroundInDescendent;
}
}
isBox() {
return true;
}
isAnonymous() {
return Boolean(this.bitfield & Box.BITS.isAnonymous);
}
isPositioned() {
return this.style.position !== 'static';
}
isStackingContextRoot() {
return this.isPositioned() && this.style.zIndex !== 'auto';
}
hasBackgroundInLayerRoot() {
return Boolean(this.bitfield & Box.BITS.hasBackgroundInLayer);
}
hasForegroundInLayerRoot() {
return Boolean(this.bitfield & Box.BITS.hasForegroundInLayer);
}
hasBackgroundInDescendent() {
return Boolean(this.bitfield & Box.BITS.hasBackgroundInDescendent);
}
hasForegroundInDescendent() {
return Boolean(this.bitfield & Box.BITS.hasForegroundInDescendent);
}
getRelativeVerticalShift() {
const height = this.containingBlock.height;
let { top, bottom } = this.style;
if (top !== 'auto') {
if (typeof top !== 'number')
top = height * top.value / 100;
return top;
}
else if (bottom !== 'auto') {
if (typeof bottom !== 'number')
bottom = height * bottom.value / 100;
return -bottom;
}
else {
return 0;
}
}
getRelativeHorizontalShift() {
const { direction, width } = this.containingBlock;
let { right, left } = this.style;
if (left !== 'auto' && (right === 'auto' || direction === 'ltr')) {
if (typeof left !== 'number')
left = width * left.value / 100;
return left;
}
else if (right !== 'auto' && (left === 'auto' || direction === 'rtl')) {
if (typeof right !== 'number')
right = width * right.value / 100;
return -right;
}
else {
return 0;
}
}
logName(log, options) {
log.text('Box');
}
getLogSymbol() {
return '◼︎';
}
stringifyBitfield() {
const thirty2 = this.bitfield.toString(2);
let s = '';
for (let i = thirty2.length - 1; i >= 0; i--) {
s = thirty2[i] + s;
if (i > 0 && (s.length - 4) % 5 === 0)
s = '_' + s;
}
s = '0b' + s;
return s;
}
}
export class BoxArea {
parent;
box;
blockStart;
blockSize;
lineLeft;
inlineSize;
constructor(box, x, y, w, h) {
this.parent = null;
this.box = box;
this.blockStart = y || 0;
this.blockSize = h || 0;
this.lineLeft = x || 0;
this.inlineSize = w || 0;
}
clone() {
return new BoxArea(this.box, this.lineLeft, this.blockStart, this.inlineSize, this.blockSize);
}
get writingMode() {
return this.box.style.writingMode;
}
get direction() {
return this.box.style.direction;
}
get x() {
return this.lineLeft;
}
set x(x) {
this.lineLeft = x;
}
get y() {
return this.blockStart;
}
set y(y) {
this.blockStart = y;
}
get width() {
return this.inlineSize;
}
get height() {
return this.blockSize;
}
setParent(p) {
this.parent = p;
}
inlineSizeForPotentiallyOrthogonal(box) {
if (!this.parent)
return this.inlineSize; // root area
if (!this.box.isBlockContainer())
return this.inlineSize; // cannot be orthogonal
if ((this.box.writingModeAsParticipant === 'horizontal-tb') !==
(box.writingModeAsParticipant === 'horizontal-tb')) {
return this.blockSize;
}
else {
return this.inlineSize;
}
}
absolutify() {
let x, y, width, height;
if (!this.parent) {
throw new Error(`Cannot absolutify area for ${this.box.id}, parent was never set`);
}
if (this.parent.writingMode === 'vertical-lr') {
x = this.blockStart;
y = this.lineLeft;
width = this.blockSize;
height = this.inlineSize;
}
else if (this.parent.writingMode === 'vertical-rl') {
x = this.parent.width - this.blockStart - this.blockSize;
y = this.lineLeft;
width = this.blockSize;
height = this.inlineSize;
}
else { // 'horizontal-tb'
x = this.lineLeft;
y = this.blockStart;
width = this.inlineSize;
height = this.blockSize;
}
this.lineLeft = this.parent.x + x;
this.blockStart = this.parent.y + y;
this.inlineSize = width;
this.blockSize = height;
}
snapPixels() {
let width, height;
if (!this.parent) {
throw new Error(`Cannot absolutify area for ${this.box.id}, parent was never set`);
}
if (this.parent.writingMode === 'vertical-lr') {
width = this.blockSize;
height = this.inlineSize;
}
else if (this.parent.writingMode === 'vertical-rl') {
width = this.blockSize;
height = this.inlineSize;
}
else { // 'horizontal-tb'
width = this.inlineSize;
height = this.blockSize;
}
const x = this.lineLeft;
const y = this.blockStart;
this.lineLeft = Math.round(this.lineLeft);
this.blockStart = Math.round(this.blockStart);
this.inlineSize = Math.round(x + width) - this.lineLeft;
this.blockSize = Math.round(y + height) - this.blockStart;
}
repr(indent = 0) {
const { width: w, height: h, x, y } = this;
return ' '.repeat(indent) + `⚃ Area ${this.box.id}: ${w}⨯${h} @${x},${y}`;
}
}
const EmptyContainingBlock = new BoxArea(null);
export function prelayout(root) {
const stack = [root];
const parents = [];
const ifcs = [];
while (stack.length) {
const box = stack.pop();
if ('sentinel' in box) {
const box = parents.pop();
if (box.isIfcInline())
ifcs.pop();
const parent = parents.at(-1);
if (parent)
box.propagate(parent);
box.prelayout();
}
else if (box.isBox()) {
parents.push(box);
if (box.isIfcInline())
ifcs.push(box);
stack.push({ sentinel: true });
for (let i = box.children.length - 1; i >= 0; i--) {
stack.push(box.children[i]);
}
}
else if (box.isRun()) {
box.propagate(parents.at(-1), ifcs.at(-1).paragraph.string);
}
else {
box.propagate(parents.at(-1));
}
}
}
export function postlayout(root) {
const stack = [root];
const parents = [];
while (stack.length) {
const box = stack.pop();
if ('sentinel' in box) {
const parent = parents.pop();
parent.postlayoutPostorder();
}
else {
box.postlayoutPreorder();
stack.push({ sentinel: true });
parents.push(box);
for (let i = box.children.length - 1; i >= 0; i--) {
const child = box.children[i];
if (child.isBox())
stack.push(child);
}
}
}
}