UNPKG

smoosic

Version:

<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i

855 lines (811 loc) 28.1 kB
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic) // Copyright (c) Aaron David Newman 2021. import { SvgHelpers, OutlineInfo } from './svgHelpers'; import { SmoTextGroup, SmoScoreText } from '../../smo/data/scoreText'; import { SuiTextEditor } from './textEdit'; import { SuiScroller } from './scroller'; import { SmoAttrs, SvgBox, getId, ElementLike } from '../../smo/data/common'; import { SvgPage, SvgPageMap } from './svgPageMap'; import { smoSerialize } from '../../common/serializationHelpers'; import { VexFlow, chordSubscriptOffset, chordSuperscriptOffset, FontInfo, getVexGlyphFromChordCode, getChordSymbolMetricsForGlyph, blockMetricsYShift } from '../../common/vex'; import { TextFormatter } from '../../common/textformatter'; declare var $: any; const VF = VexFlow; // From textfont.ts in VF /** * parameters to render text * @category SuiRender */ export interface SuiInlineTextParams { fontFamily: string, fontWeight: string, fontSize: number, fontStyle: string, startX: number, startY: number, scroller: SuiScroller, purpose: string, context: SvgPage, pageMap: SvgPageMap } /** * metrics for a single line of text. A textGroup can be composed * of multiple inline blocks. * @category SuiRender */ export interface SuiInlineBlock { symbolType: number, textType: number, highlighted: boolean, x: number, y: number, width: number, height: number, scale: number, metrics: any, glyph: any, glyphCode: string, text: string } /** * @category SuiRender */ export interface SuiInlineArtifact { block: SuiInlineBlock, box: SvgBox, index: number } // ## textRender.js // Classes responsible for formatting and rendering text in SVG space. /** * Inline text is a block of SVG text with the same font. Each block can * contain either text or an svg (vex) glyph. Each block in the text has its own * metrics so we can support inline svg text editors (cursor). * @category SuiRender */ export class SuiInlineText { static get textTypes() { return { normal: 0, superScript: 1, subScript: 2 }; } static get symbolTypes() { return { GLYPH: 1, TEXT: 2, LINE: 3 }; } static get textPurposes(): Record<string, string> { return {render: 'sui-inline-render', edit: 'sui-inline-edit' }; } // ### textTypeTransitions // Given a current text type and a type change request, what is the result // text type? This truth table tells you. static get textTypeTransitions(): number[][] { return [ [1, 1, 0], [1, 0, 1], [1, 2, 2], [2, 2, 0], [2, 0, 2], [2, 1, 1], [0, 1, 1], [0, 0, 0], [0, 2, 2] ]; } static getTextTypeResult(oldType: number, newType: number): number { let rv = SuiInlineText.textTypes.normal; let i = 0; for (i = 0; i < SuiInlineText.textTypeTransitions.length; ++i) { const tt = SuiInlineText.textTypeTransitions[i]; if (tt[0] === oldType && tt[1] === newType) { rv = tt[2]; break; } } return rv; } static getTextTypeTransition(oldType: number, result: number): number { let rv = SuiInlineText.textTypes.normal; let i = 0; for (i = 0; i < SuiInlineText.textTypeTransitions.length; ++i) { const tt = SuiInlineText.textTypeTransitions[i]; if (tt[0] === oldType && tt[2] === result) { rv = tt[1]; break; } } return rv; } get spacing(): number { return VF.ChordSymbol.spacingBetweenBlocks; } static get defaults(): SuiInlineTextParams { return JSON.parse(JSON.stringify({ blocks: [], fontFamily: 'Merriweather', fontSize: 14, startX: 100, startY: 100, fontWeight: 500, fontStyle: 'normal', scale: 1, activeBlock: -1, artifacts: [], purpose: 'render', classes: '', updatedMetrics: false })); } fontFamily: string; fontWeight: string; fontStyle: string; fontSize: number; width: number = -1; height: number = -1; purpose: string; attrs: SmoAttrs; textFont: TextFormatter; startX: number; startY: number; blocks: SuiInlineBlock[] = []; updatedMetrics: boolean = false; context: SvgPage; pageMap: SvgPageMap; scroller: SuiScroller; artifacts: SuiInlineArtifact[] = []; logicalBox: SvgBox = SvgBox.default; element: ElementLike = null; updateFontInfo(): TextFormatter { const tf = TextFormatter.create({ family: this.fontFamily, weight: this.fontWeight, size: this.fontSize, style: this.fontStyle }); return tf; } // ### constructor just creates an empty svg constructor(params: SuiInlineTextParams) { this.fontFamily = params.fontFamily; this.fontWeight = params.fontWeight; this.fontStyle = params.fontStyle; this.fontSize = params.fontSize; this.textFont = this.updateFontInfo(); this.scroller = params.scroller; this.startX = params.startX; this.startY = params.startY; this.purpose = params.purpose; this.attrs = { id: getId().toString(), type: 'SuiInlineText' }; this.context = params.context; this.pageMap = params.pageMap; } static fromScoreText(scoreText: SmoScoreText, context: SvgPage, pageMap: SvgPageMap, scroller: SuiScroller): SuiInlineText { const params: SuiInlineTextParams = { fontFamily: SmoScoreText.familyString(scoreText.fontInfo.family), fontWeight: SmoScoreText.weightString(scoreText.fontInfo.weight), fontStyle: scoreText.fontInfo.style ?? 'normal', startX: scoreText.x, startY: scoreText.y, scroller, purpose: SuiInlineText.textPurposes.render, fontSize: SmoScoreText.fontPointSize(scoreText.fontInfo.size), context, pageMap }; const rv = new SuiInlineText(params); rv.attrs.id = scoreText.attrs.id; const blockParams = SuiInlineText.blockDefaults; blockParams.text = scoreText.text; rv.addTextBlockAt(0, blockParams); return rv; } static get blockDefaults(): SuiInlineBlock { return JSON.parse(JSON.stringify({ symbolType: SuiInlineText.symbolTypes.TEXT, textType: SuiInlineText.textTypes.normal, highlighted: false, x: 0, y: 0, width: 0, height: 0, scale: 1.0, glyph: {}, text: '', glyphCode: '' })); } // ### pointsToPixels // The font size is specified in points, convert to 'pixels' in the svg space get pointsToPixels(): number { return this.textFont.fontSizeInPixels; } offsetStartX(offset: number) { this.startX += offset; this.blocks.forEach((block) => { block.x += offset; }); } offsetStartY(offset: number) { this.startY += offset; this.blocks.forEach((block) => { block.y += offset; }); } maxFontHeight(scale: number): number { return this.textFont.maxHeight * scale; } _glyphOffset(block: SuiInlineBlock): number { return blockMetricsYShift(block.glyph.getMetrics()) * this.pointsToPixels * block.scale; } /** * Based on the font metrics, compute the width of the strings and glyph that make up * this block */ _calculateBlockIndex() { var curX = this.startX; var maxH = 0; let superXAlign = 0; let superXWidth = 0; let prevBlock: SuiInlineBlock | null = null; let i = 0; this.textFont.setFontSize(this.fontSize); this.blocks.forEach((block) => { // super/subscript const sp = this.isSuperscript(block); const sub = this.isSubcript(block); block.width = 0; block.height = 0; // coeff for sub/super script const subAdj = (sp || sub) ? VF.ChordSymbol.superSubRatio : 1.0; // offset for super/sub let subOffset = 0; if (sp) { subOffset = chordSuperscriptOffset() * this.pointsToPixels; } else if (sub) { subOffset = chordSubscriptOffset() * this.pointsToPixels; } else { subOffset = 0; } block.x = curX; if (block.symbolType === SuiInlineText.symbolTypes.TEXT) { for (i = 0; i < block.text.length; ++i) { const ch = block.text[i]; const glyph = this.textFont.getGlyphMetrics(ch); block.width += ((glyph.advanceWidth ?? 0) / this.textFont.getResolution()) * this.pointsToPixels * block.scale * subAdj; const blockHeight = (glyph.ha / this.textFont.getResolution()) * this.pointsToPixels * block.scale; block.height = block.height < blockHeight ? blockHeight : block.height; block.y = this.startY + (subOffset * block.scale); } } else if (block.symbolType === SuiInlineText.symbolTypes.GLYPH) { // TODO: vexflow broke leftSideBearing and advanceWidth // vex5 /* block.width = (block.glyph.getMetrics().width) * this.pointsToPixels * block.scale; block.height = (block.glyph.getMetrics().ha) * this.pointsToPixels * block.scale; block.x += block.glyph.getMetrics().xMin * this.pointsToPixels * block.scale; */ block.width = (block.metrics.advanceWidth / VF.ChordSymbol.engravingFontResolution) * this.pointsToPixels * block.scale; block.height = (block.glyph.metrics.ha / VF.ChordSymbol.engravingFontResolution) * this.pointsToPixels * block.scale; block.x += block.metrics.leftSideBearing / VF.ChordSymbol.engravingFontResolution * this.pointsToPixels * block.scale; block.y = this.startY + this._glyphOffset(block) + subOffset; } // Line subscript up with super if the follow each other if (sp) { if (superXAlign === 0) { superXAlign = block.x; } } else if (sub) { if (superXAlign > 0 && prevBlock !== null) { block.x = superXAlign; superXWidth = prevBlock.x + prevBlock.width; curX = superXAlign; superXAlign = 0; } else { if (superXWidth > 0 && superXWidth < block.width + block.x) { superXWidth = block.width + block.x; } } } else if (superXWidth > 0) { block.x = superXWidth + VF.ChordSymbol.spacingBetweenBlocks; superXWidth = 0; } else { superXAlign = 0; } curX += block.width; maxH = block.height > maxH ? maxH : block.height; prevBlock = block; }); this.width = curX - this.startX; this.height = maxH; this.updatedMetrics = true; } // ### getLogicalBox // return the calculated svg metrics. In SMO parlance the // logical box is in SVG space, 'renderedBox' is in client space. getLogicalBox(): SvgBox { let rv: SvgBox = SvgBox.default; if (!this.updatedMetrics) { this._calculateBlockIndex(); } const adjBox = (box: SvgBox) => { const nbox = SvgHelpers.smoBox(box); nbox.y = nbox.y - nbox.height; return nbox; }; this.blocks.forEach((block) => { if (!rv.x) { rv = SvgHelpers.smoBox(adjBox(block)); } else { rv = SvgHelpers.unionRect(rv, adjBox(block)); } }); return rv; } // ### renderCursorAt // When we are using textLayout to render editor, create a cursor that adjusts it's size renderCursorAt(position: number, textType: number) { let adjH = 0; let adjY = 0; if (!this.updatedMetrics) { this._calculateBlockIndex(); } const group = this.context.getContext().openGroup(); group.id = 'inlineCursor'; const h = this.fontSize; if (this.blocks.length <= position || position < 0) { const x = this.startX - this.context.box.x; const y = this.startY - this.context.box.y; SvgHelpers.renderCursor(group, x, y - h, h); this.context.getContext().closeGroup(); return; } const block = this.blocks[position]; adjH = block.symbolType === SuiInlineText.symbolTypes.GLYPH ? h / 2 : h; // For glyph, add y adj back to the cursor since it's not a glyph adjY = block.symbolType === SuiInlineText.symbolTypes.GLYPH ? block.y - this._glyphOffset(block) : block.y; if (typeof (textType) === 'number' && textType !== SuiInlineText.textTypes.normal) { const ratio = textType !== SuiInlineText.textTypes.normal ? VF.ChordSymbol.superSubRatio : 1.0; adjH = adjH * ratio; if (textType !== block.textType) { if (textType === SuiInlineText.textTypes.superScript) { adjY -= h / 2; } else { adjY += h / 2; } } } const x = block.x + block.width - this.context.box.x; const y = adjY - (adjH * block.scale) - this.context.box.y; SvgHelpers.renderCursor(group, x, y, adjH * block.scale); this.context.getContext().closeGroup(); } removeCursor() { $('svg #inlineCursor').remove(); } unrender() { this.element?.remove(); this.element = null; } getIntersectingBlocks(box: SvgBox): SuiInlineArtifact[] { if (!this.artifacts) { return []; } return SvgHelpers.findIntersectingArtifact(box, this.artifacts) as SuiInlineArtifact[]; } _addBlockAt(position: number, block: SuiInlineBlock) { if (position >= this.blocks.length) { this.blocks.push(block); } else { this.blocks.splice(position, 0, block); } } removeBlockAt(position: number) { this.blocks.splice(position, 1); this.updatedMetrics = false; } // ### addTextBlockAt // Add a text block to the line of text. // params must contain at least: // {text:'xxx'} addTextBlockAt(position: number, params: SuiInlineBlock) { const block: SuiInlineBlock = JSON.parse(JSON.stringify(SuiInlineText.blockDefaults)); smoSerialize.vexMerge(block, params); block.text = params.text; block.scale = params.scale ? params.scale : 1; this._addBlockAt(position, block); this.updatedMetrics = false; } _getGlyphBlock(params: SuiInlineBlock): SuiInlineBlock { // vex 5 /* const block: SuiInlineBlock = JSON.parse(JSON.stringify(SuiInlineText.blockDefaults)); smoSerialize.vexMerge(block, params); params.text = params.glyphCode; block.text = params.text; block.scale = params.scale ? params.scale : 1; */ const block = JSON.parse(JSON.stringify(SuiInlineText.blockDefaults)); block.symbolType = SuiInlineText.symbolTypes.GLYPH; block.glyphCode = params.glyphCode; const vexCode = getVexGlyphFromChordCode(block.glyphCode); block.glyph = new VF.Glyph(vexCode, this.fontSize); // Vex 4 feature, vex 5 elimitated metrics here block.metrics = getChordSymbolMetricsForGlyph(vexCode); block.scale = (params.textType && params.textType !== SuiInlineText.textTypes.normal) ? 2 * VF.ChordSymbol.superSubRatio : 2; block.textType = params.textType ? params.textType : SuiInlineText.textTypes.normal; block.glyph.scale = block.glyph.scale * block.scale; return block; } // ### addGlyphBlockAt // Add a glyph block to the line of text. Params must include: // {glyphCode:'csymDiminished'} addGlyphBlockAt(position: number, params: SuiInlineBlock) { const block = this._getGlyphBlock(params); this._addBlockAt(position, block); this.updatedMetrics = false; } isSuperscript(block: SuiInlineBlock): boolean { return block.textType === SuiInlineText.textTypes.superScript; } isSubcript(block: SuiInlineBlock): boolean { return block.textType === SuiInlineText.textTypes.subScript; } getHighlight(block: SuiInlineBlock): boolean { return block.highlighted; } setHighlight(block: SuiInlineBlock, value: boolean) { block.highlighted = value; } rescale(scale: number) { scale = (scale * this.fontSize < 6) ? 6 / this.fontSize : scale; scale = (scale * this.fontSize > 72) ? 72 / this.fontSize : scale; this.blocks.forEach((block) => { block.scale = scale; }); this.updatedMetrics = false; } render() { if (!this.updatedMetrics) { this._calculateBlockIndex(); } this.context.getContext().setFont({ family: this.fontFamily, size: this.fontSize, weight: this.fontWeight, style: this.fontStyle }); const group = this.context.getContext().openGroup(); this.element = group; const mmClass = 'suiInlineText'; let ix = 0; group.classList.add('vf-' + this.attrs.id); group.classList.add(this.attrs.id); group.classList.add(mmClass); group.classList.add(this.purpose); group.id = this.attrs.id; this.artifacts = []; this.blocks.forEach((block) => { var bg = this.context.getContext().openGroup(); bg.classList.add('textblock-' + this.attrs.id + ix); this._drawBlock(block); this.context.getContext().closeGroup(); const artifact: SuiInlineArtifact = { block, box: SvgBox.default, index: 0 }; artifact.box = this.context.offsetBbox(bg); artifact.index = ix; this.artifacts.push(artifact); ix += 1; }); this.context.getContext().closeGroup(); this.logicalBox = this.context.offsetBbox(group); } _drawBlock(block: SuiInlineBlock) { const sp = this.isSuperscript(block); const sub = this.isSubcript(block); const highlight = this.getHighlight(block); const y = block.y - this.context.box.y; // relative y into page if (highlight) { this.context.getContext().save(); this.context.getContext().setFillStyle('#999'); } // This is how svgcontext expects to get 'style' const weight = this.fontWeight; const style = this.fontStyle; const family = this.fontFamily; if (sp || sub) { this.context.getContext().save(); this.context.getContext().setFont({ family, size: this.fontSize * VF.ChordSymbol.superSubRatio * block.scale, weight, style }); } else { this.context.getContext().setFont({ family, size: this.fontSize * block.scale, weight, style }); } if (block.symbolType === SuiInlineText.symbolTypes.TEXT) { this.context.getContext().fillText(block.text, block.x, y); } else if (block.symbolType === SuiInlineText.symbolTypes.GLYPH) { block.glyph.render(this.context.getContext(), block.x, y); } if (sp || sub) { this.context.getContext().restore(); } if (highlight) { this.context.getContext().restore(); } } getText(): string { let rv = ''; this.blocks.forEach((block) => { rv += block.text; }); return rv; } } /** * @category SuiRender */ export interface SuiTextBlockBlock { text: SuiInlineText; position: number; activeText: boolean; } /** * @category SuiRender */ export interface SuiTextBlockParams { blocks: SuiTextBlockBlock[]; scroller: SuiScroller; spacing: number; context: SvgPage; skipRender: boolean; justification: number; } /** * @category SuiRender */ export interface SuiTextBlockJusityCalc { blocks: SuiInlineText[], minx: number, maxx: number, width: number } /** * SVG representation of SmoTextGroup * @category SuiRender */ export class SuiTextBlock { static get relativePosition() { return { ABOVE: SmoTextGroup.relativePositions.ABOVE, BELOW: SmoTextGroup.relativePositions.BELOW, LEFT: SmoTextGroup.relativePositions.LEFT, RIGHT: SmoTextGroup.relativePositions.RIGHT }; } inlineBlocks: SuiTextBlockBlock[] = []; scroller: SuiScroller; spacing: number = 0; context: SvgPage; skipRender: boolean; currentBlockIndex: number = 0; justification: number; outlineRect: OutlineInfo | null = null; currentBlock: SuiTextBlockBlock | null = null; logicalBox: SvgBox = SvgBox.default; constructor(params: SuiTextBlockParams) { this.inlineBlocks = []; this.scroller = params.scroller; this.spacing = params.spacing; this.context = params.context; this.skipRender = false; // used when editing the text if (params.blocks.length < 1) { const inlineParams = SuiInlineText.defaults; inlineParams.scroller = this.scroller; inlineParams.context = this.context; const inst = new SuiInlineText(inlineParams); params.blocks = [{ text: inst, position: SmoTextGroup.relativePositions.RIGHT, activeText: true }]; } params.blocks.forEach((block) => { if (!this.currentBlock) { this.currentBlock = block; this.currentBlockIndex = 0; } this.inlineBlocks.push(block); }); this.justification = params.justification ? params.justification : SmoTextGroup.justifications.LEFT; } render() { this.unrender(); this.inlineBlocks.forEach((block) => { block.text.render(); if (block.activeText) { this._outlineBox(this.context, block.text.logicalBox); } if (!this.logicalBox || this.logicalBox.width < 1) { this.logicalBox = SvgHelpers.smoBox(block.text.logicalBox); } else { this.logicalBox = SvgHelpers.unionRect(this.logicalBox, block.text.logicalBox); } }); } _outlineBox(context: any, box: SvgBox) { const outlineStroke = SuiTextEditor.strokes['text-highlight']; if (!this.outlineRect) { this.outlineRect = { context, box, classes: 'text-drag', stroke: outlineStroke, scroll: this.scroller.scrollState, timeOff: 1000 }; } this.outlineRect.box = box; this.outlineRect.context = context; this.outlineRect.scroll = this.scroller.scrollState; SvgHelpers.outlineRect(this.outlineRect); } offsetStartX(offset: number) { this.inlineBlocks.forEach((block) => { block.text.offsetStartX(offset); }); } offsetStartY(offset: number) { this.inlineBlocks.forEach((block) => { block.text.offsetStartY(offset); }); } rescale(scale: number) { this.inlineBlocks.forEach((block) => { block.text.rescale(scale); }); } get x(): number { return this.getLogicalBox().x; } get y(): number { return this.getLogicalBox().y; } maxFontHeight(scale: number): number { let rv = 0; this.inlineBlocks.forEach((block) => { const blockHeight = block.text.maxFontHeight(scale); rv = blockHeight > rv ? blockHeight : rv; }); return rv; } static blockFromScoreText(scoreText: SmoScoreText, context: SvgPage, pageMap: SvgPageMap, position: number, scroller: SuiScroller): SuiTextBlockBlock { var inlineText = SuiInlineText.fromScoreText(scoreText, context, pageMap, scroller); return { text: inlineText, position, activeText: true }; } getLogicalBox(): SvgBox { return this._calculateBoundingClientRect(); } _calculateBoundingClientRect(): SvgBox { let rv: SvgBox = SvgBox.default; this.inlineBlocks.forEach((block) => { if (!rv.x) { rv = block.text.getLogicalBox(); } else { rv = SvgHelpers.unionRect(rv, block.text.getLogicalBox()); } }); rv.y = rv.y - rv.height; return rv; } static fromTextGroup(tg: SmoTextGroup, context: SvgPage, pageMap: SvgPageMap, scroller: SuiScroller): SuiTextBlock { const blocks: SuiTextBlockBlock[] = []; // Create an inline block for each ScoreText tg.textBlocks.forEach((stBlock) => { const st = stBlock.text; const newText = SuiTextBlock.blockFromScoreText(st, context, pageMap, stBlock.position, scroller); newText.activeText = stBlock.activeText; blocks.push(newText); }); const rv = new SuiTextBlock({ blocks, justification: tg.justification, spacing: tg.spacing, context, scroller, skipRender: false }); rv._justify(); return rv; } unrender() { this.inlineBlocks.forEach((block) => { if (block.text.element) { block.text.element.remove(); block.text.element = null; } }); } // ### _justify // justify the blocks according to the group justify policy and the // relative position of the blocks _justify() { let hIx = 0; let left = 0; let minx = 0; let maxx = 0; let lvl = 0; let maxwidth = 0; let runningWidth = 0; let runningHeight = 0; if (!this.inlineBlocks.length) { return; } minx = this.inlineBlocks[0].text.startX; // We justify relative to first block x/y. const initialX = this.inlineBlocks[0].text.startX; const initialY = this.inlineBlocks[0].text.startY; const vert: Record<string, SuiTextBlockJusityCalc> = {}; this.inlineBlocks.forEach((inlineBlock) => { const block = inlineBlock.text; const blockBox = block.getLogicalBox(); // If this is a horizontal positioning, reset to first blokc position // if (hIx > 0) { block.startX = initialX; block.startY = initialY; } minx = block.startX < minx ? block.startX : minx; maxx = (block.startX + blockBox.width) > maxx ? block.startX + blockBox.width : maxx; lvl = inlineBlock.position === SmoTextGroup.relativePositions.ABOVE ? lvl + 1 : lvl; lvl = inlineBlock.position === SmoTextGroup.relativePositions.BELOW ? lvl - 1 : lvl; if (inlineBlock.position === SmoTextGroup.relativePositions.RIGHT) { block.startX += runningWidth; if (hIx > 0) { block.startX += this.spacing; } } if (inlineBlock.position === SmoTextGroup.relativePositions.LEFT) { if (hIx > 0) { block.startX = minx - blockBox.width; minx = block.startX; block.startX -= this.spacing; } } if (inlineBlock.position === SmoTextGroup.relativePositions.BELOW) { block.startY += runningHeight; if (hIx > 0) { block.startY += this.spacing; } } if (inlineBlock.position === SmoTextGroup.relativePositions.ABOVE) { block.startY -= runningHeight; if(hIx > 0) { block.startY -= this.spacing; } } if (!vert[lvl]) { vert[lvl] = { blocks: [block], minx: block.startX, maxx: block.startX + blockBox.width, width: blockBox.width }; maxwidth = vert[lvl].width; vert[lvl].blocks = [block]; vert[lvl].minx = block.startX; vert[lvl].maxx = block.startX + blockBox.width; maxwidth = vert[lvl].width = blockBox.width; } else { vert[lvl].blocks.push(block); vert[lvl].minx = vert[lvl].minx < block.startX ? vert[lvl].minx : block.startX; vert[lvl].maxx = vert[lvl].maxx > (block.startX + blockBox.width) ? vert[lvl].maxx : (block.startX + blockBox.width); vert[lvl].width += blockBox.width; maxwidth = maxwidth > vert[lvl].width ? maxwidth : vert[lvl].width; } runningWidth += blockBox.width; runningHeight += blockBox.height; hIx += 1; block.updatedMetrics = false; }); const levels = Object.keys(vert); // Horizontal justify the vertical blocks levels.forEach((level) => { const vobj = vert[level]; if (this.justification === SmoTextGroup.justifications.LEFT) { left = minx - vobj.minx; } else if (this.justification === SmoTextGroup.justifications.RIGHT) { left = maxx - vobj.maxx; } else { left = (maxwidth / 2) - (vobj.width / 2); left += minx - vobj.minx; } vobj.blocks.forEach((block) => { block.offsetStartX(left); }); }); } }