diff --git a/README.md b/README.md index 2636ae8..8be3713 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Following are rules that work or will work soon. Shorthand properties are not li | margin | `em`, `px`, `%`, `cm` etc, `auto` | ✅‍ Works | | max-height, max-width,
min-height, min-width | `em`, `px`, `%`, `cm` etc, `auto` | 🚧‍ Planned | | padding | `em`, `px`, `%`, `cm` etc | ✅‍ Works | -| position | `absolute` | 🚧‍ Planned | +| position | `absolute` | ✅‍ Works | | position | `fixed` | 🚧‍ Planned | | position | `relative` | ✅‍ Works | | transform | | 🚧‍ Planned | diff --git a/src/layout-box.ts b/src/layout-box.ts index 91888b2..9c4e005 100644 --- a/src/layout-box.ts +++ b/src/layout-box.ts @@ -163,7 +163,10 @@ export abstract class Box extends TreeNode { // 17..18: propagation bits: Inline <- FormattingBox hasFloatOrReplaced: 1 << 17, hasInlineBlocks: 1 << 18, - // 19..31: if you take them, remove them from PROPAGATES_TO_INLINE_BITS + // 19: the only bit that also propagates out of an Inline, up to the + // containing block: Inline, FormattingBox <- Inline, FormattingBox + hasAbsoluteInCb: 1 << 19, + // 20..31: if you take them, remove them from PROPAGATES_TO_INLINE_BITS }; /** @@ -415,6 +418,10 @@ export abstract class Box extends TreeNode { return Boolean(this.bitfield & Box.BITS.hasForegroundInDescendent); } + hasAbsoluteInCb() { + return Boolean(this.bitfield & Box.BITS.hasAbsoluteInCb); + } + postlayoutPreorder(layout: Layout) { // TODO: Inlines don't use this yet. Get rid of paragraph's backgroundBoxes // and use normal inline areas instead, with fragmentation @@ -555,7 +562,11 @@ export abstract class FormattingBox extends Box { } isOutOfFlow() { - return this.style.float !== 'none'; // TODO: or position === 'absolute' + return this.style.float !== 'none' || this.style.position === 'absolute'; + } + + isAbsolute() { + return this.style.position === 'absolute'; } propagate(parent: Box) { @@ -564,11 +575,19 @@ export abstract class FormattingBox extends Box { if (this.isFloat()) { parent.bitfield |= Box.BITS.hasFloatOrReplaced; } + + if (this.isAbsolute() || this.hasAbsoluteInCb() && !this.isPositioned()) { + parent.bitfield |= Box.BITS.hasAbsoluteInCb; + } } isInlineLevel() { return this.style.display.outer === 'inline'; } + + isAbsoluteContainingBlock() { + return (this.isPositioned() || this.treeStart === 0) && this.hasAbsoluteInCb(); + } } export class BoxArea { @@ -606,6 +625,10 @@ export class BoxArea { return this.box.style.direction; } + getWritingModeAsParticipant() { + return this.parent ? this.parent.getEstablishedWritingMode() : 'horizontal-tb'; + } + get x() { return this.lineLeft; } @@ -630,10 +653,49 @@ export class BoxArea { return this.blockSize; } + getBlockEndInset() { + if (!this.parent) return 0; + if ( + (this.getWritingModeAsParticipant() === 'horizontal-tb') !== + (this.parent.getWritingModeAsParticipant() === 'horizontal-tb') + ) { + return this.parent.inlineSize - this.blockStart - this.blockSize; + } else { + return this.parent.blockSize - this.blockStart - this.blockSize; + } + } + + getLineRightInset() { + if (!this.parent) return 0; + if ( + (this.getWritingModeAsParticipant() === 'horizontal-tb') !== + (this.parent.getWritingModeAsParticipant() === 'horizontal-tb') + ) { + return this.parent.blockSize - this.lineLeft - this.inlineSize; + } else { + return this.parent.inlineSize - this.lineLeft - this.inlineSize; + } + } + setParent(p: BoxArea) { this.parent = p; } + blockSizeForPotentiallyOrthogonal(box: FormattingBox) { + if (!this.parent) return this.blockSize; // root area + if (!this.box.isBlockContainer()) return this.blockSize; // cannot be orthogonal + const cb1 = this.box.getContainingBlock(); + const cb2 = box.getContainingBlock(); + if ( + (this.box.getWritingModeAsParticipant(cb1) === 'horizontal-tb') !== + (box.getWritingModeAsParticipant(cb2) === 'horizontal-tb') + ) { + return this.inlineSize; + } else { + return this.blockSize; + } + } + inlineSizeForPotentiallyOrthogonal(box: FormattingBox) { if (!this.parent) return this.inlineSize; // root area if (!this.box.isBlockContainer()) return this.inlineSize; // cannot be orthogonal @@ -649,6 +711,39 @@ export class BoxArea { } } + getLineLeftOfAreaAgainstSelf(area: BoxArea) { + const thisWm = this.getEstablishedWritingMode(); + const areaCb = area.box.getContainingBlock(); + const areaWm = area.box.getWritingModeAsParticipant(areaCb); + + if (thisWm === 'horizontal-tb') { + if (areaWm === 'vertical-rl') return area.getBlockEndInset(); + if (areaWm === 'vertical-lr') return area.blockStart; + } else { // 'vertical-rl', 'vertical-lr' + if (areaWm === 'horizontal-tb') return area.blockStart; + } + + return area.lineLeft; + } + + getBlockStartOfAreaAgainstSelf(area: BoxArea) { + const thisWm = this.getEstablishedWritingMode(); + const areaCb = area.box.getContainingBlock(); + const areaWm = area.box.getWritingModeAsParticipant(areaCb); + + if (thisWm === 'horizontal-tb') { + if (areaWm !== 'horizontal-tb') return area.lineLeft; + } else if (thisWm === 'vertical-rl') { + if (areaWm === 'horizontal-tb') return area.getLineRightInset(); + if (areaWm === 'vertical-lr') return area.getBlockEndInset(); + } else { // 'vertical-lr' + if (areaWm === 'horizontal-tb') return area.lineLeft; + if (areaWm === 'vertical-rl') return area.getBlockEndInset(); + } + + return area.blockStart; + } + absolutify() { let x, y, width, height; diff --git a/src/layout-flow.ts b/src/layout-flow.ts index 217a1fb..0e2cf1c 100644 --- a/src/layout-flow.ts +++ b/src/layout-flow.ts @@ -82,7 +82,7 @@ const EMPTY_MAP = new Map(); export class BlockFormattingContext { public inlineSize: number; public fctx?: FloatContext; - public stack: (BlockContainer | {post: BlockContainer})[]; + public stack: (BlockContainer | {post: BlockContainer} | {abs: BlockLevel})[]; public cbBlockStart: number; public cbLineLeft: number; public cbLineRight: number; @@ -111,7 +111,7 @@ export class BlockFormattingContext { this.hypotheticals = EMPTY_MAP; } - collapseStart(layout: Layout, box: BlockLevel) { + collapseStart(layout: Layout, box: BlockLevel, ctx: LayoutContext) { const containingBlock = box.getContainingBlock(); const marginBlockStart = box.style.getMarginBlockStart(containingBlock); let floatBottom = 0; @@ -137,7 +137,7 @@ export class BlockFormattingContext { if (adjoinsPrevious) { this.margin.collection.add(marginBlockStart); } else { - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); const c = floatBottom - this.cbBlockStart; this.margin = {level: this.level, collection: new MarginCollapseCollection(c)}; if (box.canCollapseThrough(layout)) this.margin.clearanceAtLevel = this.level; @@ -151,7 +151,7 @@ export class BlockFormattingContext { const borderBlockStartWidth = box.style.getBorderBlockStartWidth(containingBlock); const adjoinsNext = paddingBlockStart === 0 && borderBlockStartWidth === 0; - this.collapseStart(layout, box); + this.collapseStart(layout, box, ctx); this.last = 'start'; this.level += 1; @@ -172,12 +172,12 @@ export class BlockFormattingContext { } if (!adjoinsNext) { - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); this.margin = {level: this.level, collection: new MarginCollapseCollection()}; } } - boxEnd(layout: Layout, box: BlockContainer) { + boxEnd(layout: Layout, box: BlockContainer, ctx: LayoutContext) { const containingBlock = box.getContainingBlock(); const {lineLeft, lineRight} = box.getContainingBlockToContent(containingBlock); const paddingBlockEnd = box.style.getPaddingBlockEnd(containingBlock); @@ -206,7 +206,7 @@ export class BlockFormattingContext { this.cbLineRight -= lineRight; if (!adjoins) { - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); this.margin = {level: this.level, collection: new MarginCollapseCollection()}; } @@ -225,13 +225,18 @@ export class BlockFormattingContext { this.last = 'end'; } - boxAtomic(layout: Layout, box: BlockLevel) { + boxOutOfFlow(box: BlockLevel) { + layoutStaticBox(box); + this.stack.push({abs: box}); + } + + boxAtomic(layout: Layout, box: BlockLevel, ctx: LayoutContext) { const containingBlock = box.getContainingBlock(); const marginBlockEnd = box.style.getMarginBlockEnd(containingBlock); assumePx(marginBlockEnd); - this.collapseStart(layout, box); + this.collapseStart(layout, box, ctx); this.fctx?.boxStart(); - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); box.setBlockPosition(this.cbBlockStart); this.margin.collection = new MarginCollapseCollection(); this.margin.collection.add(marginBlockEnd); @@ -264,14 +269,21 @@ export class BlockFormattingContext { return this.fctx || (this.fctx = new FloatContext(this, blockOffset)); } - finalize(box: BlockContainer) { + finalize(layout: Layout, box: BlockContainer, ctx: LayoutContext) { if (!box.isBfcRoot()) throw new Error('This is for bfc roots only'); const containingBlock = box.getContainingBlock(); const blockSize = box.style.getBlockSize(containingBlock); + let insetBlockStart: number | 'auto' = 'auto'; + let insetBlockEnd: number | 'auto' = 'auto'; + + this.positionBlockContainers(layout, ctx); - this.positionBlockContainers(); + if (box.isAbsolute()) { + insetBlockStart = box.style.getInsetBlockStart(containingBlock); + insetBlockEnd = box.style.getInsetBlockEnd(containingBlock); + } - if (blockSize === 'auto') { + if (blockSize === 'auto' && !(insetBlockStart !== 'auto' && insetBlockEnd !== 'auto')) { let lineboxHeight = 0; if (box.isBlockContainerOfInlines()) { lineboxHeight = box.getContentArea().blockSize; @@ -279,9 +291,11 @@ export class BlockFormattingContext { const blockSize = Math.max(lineboxHeight, this.cbBlockStart, this.fctx?.getBothBottom() ?? 0); box.setBlockSize(containingBlock, blockSize); } + + finalizeBlockContainer(layout, box, ctx); } - positionBlockContainers() { + positionBlockContainers(layout: Layout, ctx: LayoutContext) { const sizeStack = this.sizeStack; const offsetStack = this.offsetStack; const margin = this.margin.collection.get(); @@ -292,6 +306,18 @@ export class BlockFormattingContext { this.cbBlockStart += margin; for (const item of this.stack) { + if ('abs' in item) { + // Out of flow: it takes a static position but no space, and its margins + // collapse with nothing + const level = sizeStack.length - 1; + const atMarginLevel = passedMarginLevel || this.margin.level === level; + const containingBlock = item.abs.getContainingBlock(); + const {lineLeft} = item.abs.getMarginsAutoIsZero(containingBlock); + item.abs.setBlockPosition(sizeStack[level] + (atMarginLevel ? 0 : margin)); + item.abs.setInlinePosition(lineLeft); + continue; + } + const box = 'post' in item ? item.post : item; if ('post' in item) { @@ -305,6 +331,8 @@ export class BlockFormattingContext { box.setBlockSize(containingBlock, childSize); } + if (!box.isBfcRoot()) finalizeBlockContainer(layout, box, ctx); + const blockSize = box.getBorderArea().blockSize; sizeStack[level] += blockSize; @@ -813,7 +841,6 @@ export abstract class BlockContainerBase extends FormattingBox { super.propagate(parent); if (this.isInlineLevel()) { - // TODO: and not absolutely positioned parent.bitfield |= Box.BITS.hasInlineBlocks; } } @@ -955,7 +982,8 @@ export class BlockContainerOfInlines extends BlockContainerBase { return inline.hasText() || inline.hasSizedInline() || inline.hasFloatOrReplaced() - || inline.hasInlineBlocks(); + || inline.hasInlineBlocks() + || inline.hasAbsoluteInCb(); } doTextLayout(layout: Layout, ctx: LayoutContext) { @@ -1095,13 +1123,17 @@ function layoutBlockBoxInner( for (let i = box.treeStart + 1; i <= box.treeFinal; i++) { const child = layout.tree[i]; if (!child.isFormattingBox()) throw new Error('Assertion failed'); - layoutBlockLevelBox(layout, child, cctx); + if (child.isAbsolute()) { + cctx.bfc!.boxOutOfFlow(child); + } else { + layoutBlockLevelBox(layout, child, cctx); + } i = child.treeFinal; } } if (establishedBfc) { - establishedBfc.finalize(box); + establishedBfc.finalize(layout, box, ctx); if (establishedBfc.fctx) { if (box.loggingEnabled()) { console.log('Left floats'); @@ -1113,7 +1145,7 @@ function layoutBlockBoxInner( } } - containingBfc?.boxEnd(layout, box); + containingBfc?.boxEnd(layout, box, ctx); } function layoutBlockBox( @@ -1137,7 +1169,7 @@ function layoutReplacedBox( box.fillAreas(containingBlock); doInlineBoxModelForBlockBox(box); box.setBlockSize(containingBlock, box.getDefiniteInnerBlockSize()); - ctx.bfc!.boxAtomic(layout, box); + ctx.bfc!.boxAtomic(layout, box, ctx); } export function layoutBlockLevelBox( @@ -1239,6 +1271,246 @@ export function layoutFloatBox( } } +function getShrinkToFitInlineSize( + layout: Layout, + box: BlockLevel, + available: number +) { + const minContent = layoutContribution(layout, box, 'min-content'); + const maxContent = layoutContribution(layout, box, 'max-content'); + return Math.max(minContent, Math.min(maxContent, available)); +} + +// § 10.3.7 +function doInlineBoxModelForAbsoluteBox( + layout: Layout, + staticContainingBlock: BoxArea | null, + box: BlockLevel +) { + const containingBlock = box.getContainingBlock(); + const cInlineSize = containingBlock.inlineSizeForPotentiallyOrthogonal(box); + const ltr = box.getDirectionAsParticipant(containingBlock) === 'ltr'; + const insetLineLeft = box.style.getInsetLineLeft(containingBlock); + const insetLineRight = box.style.getInsetLineRight(containingBlock); + const styleMarginLineLeft = box.style.getMarginLineLeft(containingBlock); + const styleMarginLineRight = box.style.getMarginLineRight(containingBlock); + const definiteInlineSize = box.getDefiniteOuterInlineSize(containingBlock); + let marginLineLeft = styleMarginLineLeft === 'auto' ? 0 : styleMarginLineLeft; + let marginLineRight = styleMarginLineRight === 'auto' ? 0 : styleMarginLineRight; + let sizedFromInsets = false; + let overConstrained = false; + let inlineSize; + let lineLeft; + + // solve: + // left = px , width = auto, right = px + // left = px , width = auto, right = auto + // left = px , width = px , right = auto + if (definiteInlineSize !== undefined) { + inlineSize = definiteInlineSize; + } else if (box.isReplacedBox()) { + inlineSize = box.getIntrinsicIsize(); + } else if (insetLineLeft !== 'auto' && insetLineRight !== 'auto') { + // Paragraph 3: both insets are given, so the size is what they leave behind + // and auto margins are zero + inlineSize = Math.max(0, cInlineSize + - insetLineLeft + - insetLineRight + - marginLineLeft + - marginLineRight); + sizedFromInsets = true; + } else { + // Paragraphs 1 and 5: shrink-to-fit over the space the insets leave behind + const available = cInlineSize + - (insetLineLeft === 'auto' ? 0 : insetLineLeft) + - (insetLineRight === 'auto' ? 0 : insetLineRight); + inlineSize = getShrinkToFitInlineSize(layout, box, available) + - marginLineLeft + - marginLineRight; + } + + // solve: + // left = px , width = px , right = px + if (!sizedFromInsets && insetLineLeft !== 'auto' && insetLineRight !== 'auto') { + // Paragraph 2: the equation is solvable, so what is left over goes to the + // margins that are auto + const rest = cInlineSize - insetLineLeft - insetLineRight - inlineSize; + + if (styleMarginLineLeft === 'auto' && styleMarginLineRight === 'auto') { + if (rest < 0) { + // Equal margins would be negative, so the line-start one is dropped + marginLineLeft = ltr ? 0 : rest; + marginLineRight = ltr ? rest : 0; + } else { + marginLineLeft = marginLineRight = rest / 2; + } + } else if (styleMarginLineLeft === 'auto') { + marginLineLeft = rest - marginLineRight; + } else if (styleMarginLineRight === 'auto') { + marginLineRight = rest - marginLineLeft; + } + + // Otherwise the values are over-constrained + overConstrained = styleMarginLineLeft !== 'auto' && styleMarginLineRight !== 'auto'; + } + + if (insetLineLeft === 'auto' && insetLineRight === 'auto') { + // solve: + // left = auto, width = auto, right = auto + // left = auto, width = px , right = auto + lineLeft = box.getBorderArea().lineLeft; // against the static cb + let area = staticContainingBlock; + while (area && area !== containingBlock) { + lineLeft += containingBlock.getLineLeftOfAreaAgainstSelf(area); + area = area.parent; + } + if (!ltr) lineLeft -= inlineSize; + } else if (insetLineLeft !== 'auto' && (insetLineRight === 'auto' || ltr || !overConstrained)) { + // solve: + // left = px , width = auto, right = auto + // left = px , width = px , right = auto + // left = px , width = auto, right = px + // left = px , width = px , right = px , unless over-constrained in rtl + lineLeft = insetLineLeft + marginLineLeft; + } else if (insetLineRight !== 'auto') { + // solve: + // left = auto, width = auto, right = px + // left = auto, width = px , right = px + // left = px , width = px , right = px , over-constrained in rtl + lineLeft = cInlineSize - insetLineRight - marginLineRight - inlineSize; + } else { + throw new Error('Assertion failed'); + } + + box.setInlineOuterSize(containingBlock, inlineSize); + box.setInlinePosition(lineLeft); +} + +// § 10.6.4 +function doBlockBoxModelForAbsoluteBox( + staticContainingBlock: BoxArea | null, + box: BlockLevel +) { + const containingBlock = box.getContainingBlock(); + const borderArea = box.getBorderArea(); + const cBlockSize = containingBlock.blockSizeForPotentiallyOrthogonal(box); + const insetBlockStart = box.style.getInsetBlockStart(containingBlock); + const insetBlockEnd = box.style.getInsetBlockEnd(containingBlock); + const marginBlockStart = box.style.getMarginBlockStart(containingBlock); + const marginBlockEnd = box.style.getMarginBlockEnd(containingBlock); + let blockSize = box.getDefiniteInnerBlockSize(containingBlock); + + if ( + blockSize === undefined && + insetBlockStart !== 'auto' && + insetBlockEnd !== 'auto' + ) { + // Paragraph 5: an auto size is what the two insets leave behind + const borderBlockStartWidth = box.style.getBorderBlockStartWidth(containingBlock); + const paddingBlockStart = box.style.getPaddingBlockStart(containingBlock); + const paddingBlockEnd = box.style.getPaddingBlockEnd(containingBlock); + const borderBlockEndWidth = box.style.getBorderBlockEndWidth(containingBlock); + + blockSize = Math.max(0, cBlockSize + - insetBlockStart + - insetBlockEnd + - (marginBlockStart === 'auto' ? 0 : marginBlockStart) + - (marginBlockEnd === 'auto' ? 0 : marginBlockEnd) + - borderBlockStartWidth + - paddingBlockStart + - paddingBlockEnd + - borderBlockEndWidth); + } + + if (blockSize !== undefined) { + box.setBlockSize(containingBlock, blockSize); + } + + const outerBlockSize = borderArea.blockSize; + let usedMarginBlockStart = marginBlockStart === 'auto' ? 0 : marginBlockStart; + let usedMarginBlockEnd = marginBlockEnd === 'auto' ? 0 : marginBlockEnd; + + if (insetBlockStart !== 'auto' && insetBlockEnd !== 'auto') { + const rest = cBlockSize - insetBlockStart - insetBlockEnd - outerBlockSize; + + if (marginBlockStart === 'auto' && marginBlockEnd === 'auto') { + // Paragraph 6: equal margins center the box, negative values included + usedMarginBlockStart = usedMarginBlockEnd = rest / 2; + } else if (marginBlockStart === 'auto') { + usedMarginBlockStart = rest - usedMarginBlockEnd; + } else if (marginBlockEnd === 'auto') { + usedMarginBlockEnd = rest - marginBlockStart; + } + // Otherwise over-constrained, and the block-end inset is the one ignored + } + + if (insetBlockStart !== 'auto') { + box.setBlockPosition(insetBlockStart + usedMarginBlockStart); + } else if (insetBlockEnd !== 'auto') { + box.setBlockPosition(cBlockSize - insetBlockEnd - usedMarginBlockEnd - outerBlockSize); + } else { + let blockStart = borderArea.blockStart + usedMarginBlockStart; + let area = staticContainingBlock; + while (area && area !== containingBlock) { + blockStart += containingBlock.getBlockStartOfAreaAgainstSelf(area); + area = area.parent; + } + box.setBlockPosition(blockStart); + } +} + +function layoutAbsoluteBox( + layout: Layout, + staticContainingBlock: BoxArea | null, + box: BlockLevel, + ctx: LayoutContext +) { + const cctx: LayoutContext = {...ctx, bfc: undefined}; + + // NB: fillAreas was called in layoutStaticBox + doInlineBoxModelForAbsoluteBox(layout, staticContainingBlock, box); + doBlockBoxModelForAbsoluteBox(staticContainingBlock, box); + + // A replaced box has no inner layout: the box model above sized it + if (box.isBlockContainer()) layoutBlockBoxInner(layout, box, cctx); +} + +function finalizeBlockContainer( + layout: Layout, + box: BlockContainer, + ctx: LayoutContext +) { + if (box.isAbsoluteContainingBlock()) { + const staticParents: FormattingBox[] = []; + + for (let i = box.treeStart + 1; i <= box.treeFinal; i++) { + const item = layout.tree[i]; + + if (item.isFormattingBox()) { + if (!item.isPositioned()) staticParents.push(item); + if (item.isAbsolute()) { + const staticParent = staticParents[staticParents.length - 1]; + const staticContainingBlock = staticParent?.getContentArea() ?? null; + layoutAbsoluteBox(layout, staticContainingBlock, item, ctx); + } + } + + // A positioned descendant finalizes its own absolutes + if (item.isBox() && item.isPositioned()) i = item.treeFinal; + + while ( + staticParents.length && + i >= staticParents[staticParents.length - 1].treeFinal + ) staticParents.pop(); + } + } +} + +export function layoutStaticBox(box: FormattingBox) { + const containingBlock = box.getContainingBlock(); + box.fillAreas(containingBlock); +} + export class Break extends TreeNode { public className = 'break'; @@ -1296,6 +1568,8 @@ export class Inline extends Box { // Bits that propagate to Inline propagate again if the parent is Inline parent.bitfield |= (this.bitfield & Box.PROPAGATES_TO_INLINE_BITS); + } else if (parent.isFormattingBox()) { + if (this.hasAbsoluteInCb()) parent.bitfield |= Box.BITS.hasAbsoluteInCb; } } @@ -1561,12 +1835,8 @@ export function inlineIteratorStateNext(state: InlineIteratorState) { } else if (item.isBreak()) { state.buffered.push({state: 'break', index: state.index}); } else { - if (item.isFloat()) { - state.buffered.push({state: 'box', item}); - } else { - state.buffered.push({state: 'box', item}); - state.isInlineBlock = true; - } + state.buffered.push({state: 'box', item}); + if (!item.isOutOfFlow()) state.isInlineBlock = true; state.index = item.treeFinal; } } @@ -1830,6 +2100,7 @@ export function generateBlockContainer(tree: InlineLevel[], el: HTMLElement) { // generatesBreak, etc if ( el.style.float !== 'none' || + el.style.position === 'absolute' || el.style.overflow === 'hidden' || el.style.display.inner === 'flow-root' || el.parent && writingModeInlineAxis(el) !== writingModeInlineAxis(el.parent) @@ -1862,7 +2133,15 @@ export function generateBlockContainer(tree: InlineLevel[], el: HTMLElement) { preBcInlineChild(tree, ctx); tree.push(new Break(child.style)); } else if (child.style.display.outer === 'block') { - if (child.style.isOutOfFlow()) { + if (child.style.position === 'absolute') { + // Joins whichever formatting context is open: the BFC positions it + // among blocks, the IFC on the line it would have been on + if (ctx.ifcIndex > -1) { + preBcInlineChild(tree, ctx); + } else { + preBcBlockChild(tree, ctx); + } + } else if (child.style.isOutOfFlow()) { preBcInlineChild(tree, ctx); } else { preBcBlockChild(tree, ctx); diff --git a/src/layout-text.ts b/src/layout-text.ts index cd22443..a6359db 100644 --- a/src/layout-text.ts +++ b/src/layout-text.ts @@ -6,6 +6,7 @@ import { IfcVacancy, Inline, layoutFloatBox, + layoutStaticBox, layoutContribution, createInlineIteratorState, inlineIteratorStateNext, @@ -2030,8 +2031,9 @@ function createMarkIterator( inlineIteratorStateNext(inline); } - // Consume floats - if (inline.value?.state === 'box' && inline.value.item.isFloat() && inlineMark === mark.position) { + // Consume out-of-flow boxes: floats, which the line has to flow around, and + // absolutely positioned boxes, which only need the line they landed on + if (inline.value?.state === 'box' && inline.value.item.isOutOfFlow() && inlineMark === mark.position) { mark.box = inline.value.item; inlineIteratorStateNext(inline); return {done: false, value: mark}; @@ -2270,6 +2272,7 @@ class InlineFormattingContext { lineHasWord: boolean; /** True when we should append the line */ lineIsDirty: boolean; + lineHasAbsolutes: boolean; /** Inlines to be fragmented; shared across finishLine calls */ inlines: Inline[]; @@ -2296,6 +2299,7 @@ class InlineFormattingContext { this.blockOffset = this.bfc.cbBlockStart; this.lineHasWord = false; this.lineIsDirty = false; + this.lineHasAbsolutes = false; this.inlines = []; } } @@ -2531,9 +2535,10 @@ function positionPhysicalLineItems( } } else { const box = layout.tree[item.treeIndex]; - if (box.isFormattingBox() && !box.isOutOfFlow()) { + if (box.isFormattingBox() && !box.isFloat()) { const {lineLeft} = box.getMarginsAutoIsZero(containingBlock); box.setInlinePosition(x + lineLeft); + if (box.isAbsolute()) box.setBlockPosition(line.blockOffset); } } x += item.inlineSpace + item.endSpace; @@ -2547,9 +2552,10 @@ function positionPhysicalLineItems( ifc.block.items[item.itemIndex].x = x; } else { const box = layout.tree[item.treeIndex]; - if (box.isFormattingBox() && !box.isOutOfFlow()) { + if (box.isFormattingBox() && !box.isFloat()) { const {lineLeft} = box.getMarginsAutoIsZero(containingBlock); box.setInlinePosition(x - lineLeft); + if (box.isAbsolute()) box.setBlockPosition(line.blockOffset); } } x -= item.endSpace; @@ -2920,6 +2926,13 @@ export function createIfcLineboxes( } } + if (mark.box?.isAbsolute()) { + // Rides the line with no width, to learn its static position (§ 10.3.7) + layoutStaticBox(mark.box); + ifc.candidates.addBox(mark.box.treeStart, mark.box.treeFinal, 0); + ifc.lineHasAbsolutes = true; + } + if (mark.inlinePost) { const inlineSpace = mark.inlinePost.getInlineEndSize(containingBlock); if (inlineSpace > 0) ifc.candidates.width.addInk(inlineSpace); @@ -3060,12 +3073,17 @@ export function createIfcLineboxes( // There could have been floats after the paragraph's final line break bfc.getLocalVacancyForLine(bfc, ifc.blockOffset, ifc.line.height(), ifc.vacancy); finishLine(ifc, ifc.line, true); - } else if (ifc.candidates.width.hasContent()) { + } else if (ifc.candidates.width.hasContent() || ifc.lineHasAbsolutes) { // We never hit a break opportunity because there is no non-whitespace // text and no inline-blocks, but there is some content on spans (border, - // padding, or margin). Add everything. + // padding, or margin), or an absolute still needs a static position. + // Add everything. + const forAbsolutesOnly = !ifc.candidates.width.hasContent(); + const blockOffset = ifc.blockOffset; ifc.line.concat(ifc.candidates); finishLine(ifc, ifc.line, true); + // Not a line box, so it adds no height + if (forAbsolutesOnly) ifc.blockOffset = blockOffset; } else { bfc.fctx?.consumeMisfits(); } diff --git a/src/style.ts b/src/style.ts index 83b704b..079f072 100644 --- a/src/style.ts +++ b/src/style.ts @@ -35,6 +35,10 @@ const LogicalMaps = Object.freeze({ borderLineRightStyle: 'borderRightStyle', borderInlineStartStyle: Object.freeze({ltr: 'borderLeftStyle', rtl: 'borderRightStyle'}), borderInlineEndStyle: Object.freeze({ltr: 'borderRightStyle', rtl: 'borderLeftStyle'}), + insetBlockStart: 'top', + insetBlockEnd: 'bottom', + insetLineLeft: 'left', + insetLineRight: 'right', blockSize: 'height', inlineSize: 'width' }), @@ -63,6 +67,10 @@ const LogicalMaps = Object.freeze({ borderLineRightStyle: 'borderBottomStyle', borderInlineStartStyle: Object.freeze({ltr: 'borderTopStyle', rtl: 'borderBottomStyle'}), borderInlineEndStyle: Object.freeze({ltr: 'borderBottomStyle', rtl: 'borderTopStyle'}), + insetBlockStart: 'left', + insetBlockEnd: 'right', + insetLineLeft: 'top', + insetLineRight: 'bottom', blockSize: 'width', inlineSize: 'height' }), @@ -91,6 +99,10 @@ const LogicalMaps = Object.freeze({ borderLineRightStyle: 'borderBottomStyle', borderInlineStartStyle: Object.freeze({ltr: 'borderTopStyle', rtl: 'borderBottomStyle'}), borderInlineEndStyle: Object.freeze({ltr: 'borderBottomStyle', rtl: 'borderTopStyle'}), + insetBlockStart: 'right', + insetBlockEnd: 'left', + insetLineLeft: 'top', + insetLineRight: 'bottom', blockSize: 'width', inlineSize: 'height' }) @@ -503,7 +515,7 @@ export class Style { } isOutOfFlow() { - return this.float !== 'none'; // TODO: or this.position === 'absolute' + return this.float !== 'none' || this.position === 'absolute'; } isWsCollapsible() { @@ -669,6 +681,44 @@ export class Style { return resolvePercent(containingBlock, cssWidthVal); } + /** + * `top`, `right`, `bottom` and `left` are physical, but layout is logical, so + * they get mapped like the rest of the box model. Percentages on the line + * axis resolve against the containing block's inline size and percentages on + * the block axis against its block size (CSS 2.2 § 10.3.7, § 10.6.4). + */ + private getInset( + key: 'insetBlockStart' | 'insetBlockEnd' | 'insetLineLeft' | 'insetLineRight', + containingBlock: BoxArea + ) { + const writingMode = containingBlock.box.style.writingMode; + const map = LogicalMaps[writingMode]; + const cssVal = this[map[key]]; + if (cssVal === 'auto') return cssVal; + if (typeof cssVal === 'object') { + const isBlockAxis = key === 'insetBlockStart' || key === 'insetBlockEnd'; + const size = containingBlock[isBlockAxis ? map.blockSize : map.inlineSize]; + return cssVal.value / 100 * size; + } + return cssVal; + } + + getInsetBlockStart(containingBlock: BoxArea) { + return this.getInset('insetBlockStart', containingBlock); + } + + getInsetBlockEnd(containingBlock: BoxArea) { + return this.getInset('insetBlockEnd', containingBlock); + } + + getInsetLineLeft(containingBlock: BoxArea) { + return this.getInset('insetLineLeft', containingBlock); + } + + getInsetLineRight(containingBlock: BoxArea) { + return this.getInset('insetLineRight', containingBlock); + } + getBlockSize(containingBlock: BoxArea) { const writingMode = containingBlock.box.style.writingMode; let cssVal = this[LogicalMaps[writingMode].blockSize]; @@ -1097,9 +1147,10 @@ function computeStyle(parentStyle: Style, cascadedStyle: DeclaredStyle) { const style = new Style(computed, parentStyle, cascadedStyle); - // Blockify floats (TODO: abspos too) (CSS Display §2.7). This drives what + // Blockify floats and absolutely positioned boxes (CSS Display §2.7). This + // drives what // type of box is created (-> not an inline), but otherwise has no effect. - if (computed.float !== 'none') style.blockify(); + if (computed.float !== 'none' || computed.position === 'absolute') style.blockify(); return style; } diff --git a/test/ci.js b/test/ci.js index cbdaf77..50ddd70 100644 --- a/test/ci.js +++ b/test/ci.js @@ -8,6 +8,7 @@ import './api.spec.js'; import './cascade.spec.js'; import './css.spec.js'; import './flow.spec.js'; +import './position-absolute.spec.js'; import './text.spec.js'; import './font.spec.js'; import './itemize.spec.js'; diff --git a/test/position-absolute.spec.js b/test/position-absolute.spec.js new file mode 100644 index 0000000..e99b1cd --- /dev/null +++ b/test/position-absolute.spec.js @@ -0,0 +1,455 @@ +import '#register-default-environment'; +import {expect} from 'chai'; +import * as flow from 'dropflow'; +import parse from 'dropflow/parse.js'; +import {registerFontAsset, unregisterFontAsset} from '../assets/register.ts'; +import PaintSpy from './paint-spy.js'; +import paint from '../src/paint.ts'; + +const adaUrl = new URL(import.meta.resolve('#assets/images/ada.png')); + +describe('Absolute positioning', function () { + before(function () { + registerFontAsset('Arimo/Arimo-Regular.ttf'); + this.reflow = function (html, width = 300, height = 500) { + this.rootElement = parse(html); + flow.loadSync(this.rootElement); + this.layout = flow.layout(this.rootElement); + flow.reflow(this.layout, width, height); + this.get = selector => this.rootElement.query(selector)?.boxes[0]; + this.border = selector => { + const box = this.get(selector); + if (!box) throw new Error(`no box for ${selector}`); + const area = box.getBorderArea(); + return {x: area.x, y: area.y, width: area.width, height: area.height}; + }; + }; + }); + + after(function () { + unregisterFontAsset('Arimo/Arimo-Regular.ttf'); + }); + + it('places the border box against the containing block padding box', function () { + this.reflow(` +
+
+
+ `); + // The containing block is the parent's padding box, which starts inside the + // 3px border + expect(this.border('#t')).to.deep.equal({x: 10, y: 8, width: 40, height: 30}); + }); + + it('uses the nearest positioned ancestor, not the parent', function () { + this.reflow(` +
+
+
+
+
+
+ `); + // The insets are measured from the positioned ancestor, so neither the + // margin nor the padding of the boxes in between moves the box + expect(this.border('#t')).to.deep.equal({x: 4, y: 3, width: 10, height: 10}); + }); + + it('falls back to the initial containing block', function () { + this.reflow(` +
+
+
+
+ `, 300, 500); + // No positioned ancestor: the 300x500 initial containing block is used, so + // the box is at its line-right edge, not the 200px wide parent's + expect(this.border('#t')).to.deep.equal({x: 290, y: 0, width: 10, height: 10}); + }); + + it('takes the box out of flow', function () { + this.reflow(` +
+
+
+
+
+
+ `); + // The parent has no in-flow content, so it has no block size, and the + // sibling after it is not pushed down by the positioned box + expect(this.border('#p').height).to.equal(0); + expect(this.border('#after').y).to.equal(0); + }); + + it('does not add the box to the line it appears in', function () { + this.reflow(` +
aaa
+ `); + // One line of text: the positioned box neither widens the line nor makes the + // paragraph as tall as itself + expect(this.border('#p').height).to.equal(20); + }); + + it('sizes the box from a pair of insets', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 10, y: 10, width: 260, height: 170}); + }); + + it('subtracts border and padding from a size taken from insets', function () { + this.reflow(` +
+
+
+ `); + // The insets are measured to the margin edge, so the border box fills them + // and the padding and border are inside it + const t = this.get('#t'); + expect(this.border('#t')).to.deep.equal({x: 0, y: 0, width: 200, height: 200}); + expect(t.getContentArea().width).to.equal(200 - 2 * 8 - 2 * 2); + expect(t.getContentArea().height).to.equal(200 - 2 * 5 - 2 * 2); + }); + + it('shrink-to-fits an auto inline size', function () { + this.reflow(` +
+
aaa bbb ccc
+
aaa bbb ccc
+
+ `); + // The first box may use the whole 300px and keeps its text on one line; the + // second only has 35px left, so each word gets its own line + expect(this.border('#wide').height).to.equal(20); + expect(this.border('#narrow').height).to.equal(60); + expect(this.border('#narrow').width).to.be.at.most(35); + }); + + it('centers with auto margins on both axes', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 100, y: 75, width: 100, height: 50}); + }); + + it('solves for a single auto margin', function () { + this.reflow(` +
+
+
+
+ `); + // The containing block is the 240px padding box, so margin-left takes + // 240 - 50 - 20, measured from the padding box edge + expect(this.border('#t').x).to.equal(170); + expect(this.border('#t').y).to.equal(0); + }); + + it('adds the line-left margin when the size came from both insets', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').x).to.equal(15); + expect(this.border('#t').width).to.equal(300 - 10 - 30 - 5); + }); + + it('ignores the line-right inset when over-constrained in ltr', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').x).to.equal(10); + }); + + it('ignores the line-left inset when over-constrained in rtl', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').x).to.equal(140); + }); + + it('ignores the block-end inset when over-constrained', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').y).to.equal(10); + }); + + it('resolves inset percentages against the containing block padding box', function () { + this.reflow(` +
+
+
+ `); + // The padding box is 220x120, so 25% is 55 and 50% is 60, both measured from + // the padding box origin + expect(this.border('#t')).to.deep.equal({x: 55, y: 60, width: 10, height: 10}); + }); + + it('resolves size percentages against the containing block padding box', function () { + this.reflow(` +
+
+
+
+ `); + // The padding box is 220x120, so the box is 110x30 at its origin, above the + // in-flow box that comes before it + expect(this.border('#t')).to.deep.equal({x: 0, y: 0, width: 110, height: 30}); + }); + + it('leaves the box at its static position after in-flow siblings', function () { + this.reflow(` +
+
+
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 0, y: 42, width: 10, height: 10}); + // and the parent stops at the in-flow content + expect(this.border('div').height).to.equal(42); + }); + + it('offsets the static position by the margins', function () { + this.reflow(` +
+
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 6, y: 19, width: 20, height: 20}); + expect(this.border('div').height).to.equal(15); + }); + + it('uses the static position of the line the box appears on', function () { + this.reflow(` +
aaa bbb ccc
+ `); + // Each word gets its own 20px line in 30px and the box comes after all of + // them, so it starts on the third line + expect(this.border('#t').y).to.equal(40); + }); + + it('uses the position on the line, not the start of it', function () { + this.reflow(` +
hello __is there anybody out there?
+ `); + // The zero-width inline-block is in flow at the same point on the line + expect(this.border('#t').x).to.equal(this.border('#ref').x); + expect(this.border('#t').x).to.be.greaterThan(30); + }); + + it('follows text-align on the line it appears on', function () { + this.reflow(` +
aaa_
+ `); + expect(this.border('#t').x).to.equal(this.border('#ref').x); + expect(this.border('#t').x).to.be.greaterThan(150); + }); + + it('moves to the line the box ends up on after a soft wrap', function () { + this.reflow(` +
aaa bbb ccc
+ `); + // "ccc" does not fit on the first line, and the box goes with it + expect(this.border('#t').y).to.equal(20); + }); + + it('takes the static position from the line-right edge in rtl', function () { + this.reflow(` +
+
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 170, y: 20, width: 30, height: 10}); + expect(this.border('div').height).to.equal(20); + }); + + it('maps insets through a vertical-lr containing block', function () { + this.reflow(` +
+
+
+ `); + // The block axis runs left to right, so `left` is the block-start inset and + // `top` is the line-left one + expect(this.border('#t').x).to.equal(12); + expect(this.border('#t').y).to.equal(9); + }); + + it('maps insets through a vertical-rl containing block', function () { + this.reflow(` +
+
+
+ `); + // The block axis runs right to left, so `right` is the block-start inset + expect(this.border('#t').x).to.equal(200 - 12 - 20); + expect(this.border('#t').y).to.equal(9); + }); + + it('maps the static position through a vertical-rl containing block', function () { + this.reflow(` +
+
+
+
+ `); + // `top` is the line-left inset here, and the block axis still comes from the + // position the box would have had + expect(this.border('#t').x).to.equal(200 - 25 - 20); + expect(this.border('#t').y).to.equal(6); + }); + + it('maps a fully auto static position through a vertical-rl containing block', function () { + this.reflow(` +
+
a
+
+
+ `); + // Both insets are auto on both axes, so the containing block's own + // writing mode is what maps the offset, not the one it participates in + expect(this.border('#t').x).to.equal(200 - 30 - 10); + expect(this.border('#t').y).to.equal(0); + }); + + it('does not wrap a box among block-level content in an inline', function () { + this.reflow(`
:D
`); + const p = this.get('#p'); + expect(this.layout.tree[p.treeStart + 1]).to.equal(this.get('#t')); + }); + + it('is positioned by the block formatting context among blocks', function () { + this.reflow(`
`); + expect(this.border('#t')).to.deep.equal({x: 6, y: 19, width: 20, height: 20}); + }); + + it('does not collapse its margins with the ones around it', function () { + this.reflow(`
`); + // The siblings collapse to 30 as if the positioned box were not there + expect(this.border('#b').y).to.equal(40); + expect(this.border('#p').height).to.equal(50); + }); + + it('takes a static position from a static parent that already ended', function () { + this.reflow(`
x
`); + // The in-flow parent is the outer box, not the padded box that ended on the + // same tree index as the positioned box inside it + expect(this.border('#t').x).to.equal(0); + expect(this.border('#t').y).to.equal(30); + }); + + it('uses the intrinsic size of a positioned replaced box', function () { + this.reflow(` +
+ +
+ `); + const t = this.border('#t'); + expect(t.x).to.equal(5); + expect(t.y).to.equal(5); + expect(t.width).to.be.greaterThan(0); + expect(t.height).to.be.greaterThan(0); + }); + + it('sizes a positioned replaced box from its style', function () { + this.reflow(` +
+
+ +
+ `); + expect(this.border('#t')).to.deep.equal({x: 3, y: 7, width: 40, height: 20}); + }); + + it('establishes a formatting context that contains its floats', function () { + this.reflow(` +
+
+
+
+
+ `); + expect(this.border('#t').height).to.equal(40); + }); + + it('blockifies an inline that is positioned', function () { + this.reflow(` +
+ aaa +
+ `); + // The span generates a block box, so it is not on a line and the paragraph + // has no line boxes at all + expect(this.border('#t')).to.deep.equal({x: 0, y: 0, width: 40, height: 40}); + expect(this.border('#p').height).to.equal(0); + }); + + it('paints a positioned box above in-flow content', function () { + this.reflow(` +
+
+
+
+ `); + const spy = new PaintSpy(); + paint(this.layout, spy); + const rects = spy.getCalls().filter(call => call.t === 'rect'); + const red = rects.findIndex(call => call.fillColor === '#f00'); + const blue = rects.findIndex(call => call.fillColor === '#00f'); + // The in-flow box is not pushed down by the positioned one + expect(rects[red].y).to.equal(0); + // Document order puts the positioned box first, but it paints last because + // it is in a later layer + expect(red).to.be.greaterThan(-1); + expect(blue).to.be.greaterThan(red); + }); +});