diff --git a/examples/tests/text-contain.ts b/examples/tests/text-contain.ts index 5ab83480..eb370d59 100644 --- a/examples/tests/text-contain.ts +++ b/examples/tests/text-contain.ts @@ -54,7 +54,8 @@ export default async function test(settings: ExampleSettings) { fontFamily: 'Ubuntu', textRendererOverride: 'sdf', fontSize: 20, - text: `Lorem ipsum dolor sit e + text: `LoremipsumdolorsiteConsecteturadipiscingelit.Vivamusid. +Lorem ipsum dolor sit e Consectetur adipiscing elit. Vivamus id. Suspendisse sollicitudin posuere felis. Vivamus consectetur ex magna, non mollis.`, @@ -132,6 +133,7 @@ Vivamus consectetur ex magna, non mollis.`, // SDF, contain none text1.textRendererOverride = 'sdf'; text1.contain = 'none'; + text1.wrapWord = 'normal'; text1.width = 0; text1.height = 0; }, @@ -153,11 +155,17 @@ Vivamus consectetur ex magna, non mollis.`, // SDF, contain both (1 pixel larger to show another line) text1.height = 204; }, + () => { + // SDF, contain both (1 pixel larger to show another line), wrap word + text1.contain = 'width'; + text1.wrapWord = 'break'; + }, + () => { // Canvas, contain none text1.textRendererOverride = 'canvas'; text1.contain = 'none'; - text1.width = 0; + (text1.wrapWord = 'normal'), (text1.width = 0); text1.height = 0; }, () => { @@ -180,6 +188,11 @@ Vivamus consectetur ex magna, non mollis.`, // Canvas, contain both (1 pixel larger to show another line) text1.height = 204; }, + () => { + // Canvas, contain both (1 pixel larger to show another line), wrap word + text1.contain = 'width'; + text1.wrapWord = 'break'; + }, ]; /** * Run the next mutation in the list @@ -202,6 +215,7 @@ Vivamus consectetur ex magna, non mollis.`, text1.contain, text1.width, text1.height, + text1.wrapWord, ); indexInfo.text = (i + 1).toString(); textSetDimsInfo.text = `Set size: ${Math.round(text1.width)}x${Math.round( @@ -237,6 +251,7 @@ function makeHeader( contain: string, width: number, height: number, + wrapWord: string, ) { - return `${renderer}, contain = ${contain}`; + return `${renderer}, contain = ${contain}, wrapWord = ${wrapWord}`; } diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index c2507833..d22121c7 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -104,6 +104,7 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { textBaseline: props.textBaseline, verticalAlign: props.verticalAlign, overflowSuffix: props.overflowSuffix, + wrapWord: props.wrapWord, }); this.textRenderer = resolvedTextRenderer; this.trState = textRendererState; @@ -346,6 +347,16 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { } } + get wrapWord(): CoreTextNodeProps['wrapWord'] { + return this.trState.props.wrapWord; + } + + set wrapWord(value: CoreTextNodeProps['wrapWord']) { + if (this.textRenderer.set.wrapWord) { + this.textRenderer.set.wrapWord(this.trState, value); + } + } + get debug(): CoreTextNodeProps['debug'] { return this.trState.props.debug; } diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 14358503..5af553c2 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -496,6 +496,7 @@ export class Stage { overflowSuffix: props.overflowSuffix ?? '...', debug: props.debug ?? {}, shaderProps: null, + wrapWord: props.wrapWord ?? 'normal', }; return new CoreTextNode(this, resolvedProps); diff --git a/src/core/text-rendering/renderers/CanvasTextRenderer.ts b/src/core/text-rendering/renderers/CanvasTextRenderer.ts index b6c15979..6fffe553 100644 --- a/src/core/text-rendering/renderers/CanvasTextRenderer.ts +++ b/src/core/text-rendering/renderers/CanvasTextRenderer.ts @@ -417,6 +417,7 @@ export class CanvasTextRenderer extends TextRenderer { ].join(' '), textColor: getNormalizedRgbaComponents(state.props.color), offsetY: state.props.offsetY, + wordBreak: state.props.wrapWord == 'break', wordWrap: state.props.contain !== 'none', wordWrapWidth: state.props.contain === 'none' ? undefined : state.props.width, diff --git a/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts b/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts index 27c01d82..e530c764 100644 --- a/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts +++ b/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts @@ -323,6 +323,7 @@ export class LightningTextTextureRenderer { wordWrapWidth, letterSpacing, textIndent, + this._settings.wordBreak, ); } else { linesInfo = { l: this._settings.text.split(/(?:\r\n|\r|\n)/), n: [] }; @@ -347,6 +348,7 @@ export class LightningTextTextureRenderer { wordWrapWidth - w, letterSpacing, textIndent, + this._settings.wordBreak, ); usedLines[usedLines.length - 1] = `${al.l[0]!}${ this._settings.overflowSuffix @@ -681,6 +683,7 @@ export class LightningTextTextureRenderer { wordWrapWidth: number, letterSpacing: number, indent = 0, + wordBreak: boolean, ) { // Greedy wrapping algorithm that will wrap words as the line grows longer. // than its horizontal bounds. @@ -691,11 +694,21 @@ export class LightningTextTextureRenderer { const resultLines = []; let result = ''; let spaceLeft = wordWrapWidth - indent; - const words = lines[i]!.split(' '); + const words = wordBreak ? [lines[i]!] : lines[i]!.split(' '); for (let j = 0; j < words.length; j++) { const wordWidth = this.measureText(words[j]!, letterSpacing); const wordWidthWithSpace = wordWidth + this.measureText(' ', letterSpacing); + if (wordBreak && wordWidthWithSpace > wordWrapWidth) { + let remainder = ''; + while (this.measureText(words[j]!) > wordWrapWidth) { + remainder = words[j]!.slice(-1) + remainder; + words[j] = words[j]!.slice(0, -1); + } + if (remainder.length > 0) { + words.splice(j + 1, 0, remainder); + } + } if (j === 0 || wordWidthWithSpace > spaceLeft) { // Skip printing the newline if it's the first word of the line that is. // greater than the word wrap width. @@ -720,7 +733,6 @@ export class LightningTextTextureRenderer { realNewlines.push(allLines.length); } } - return { l: allLines, n: realNewlines }; } diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts b/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts index 382279b6..55baf527 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts @@ -431,6 +431,7 @@ export class SdfTextRenderer extends TextRenderer { scrollable, overflowSuffix, maxLines, + wrapWord, } = state.props; // scrollY only has an effect when contain === 'both' and scrollable === true @@ -569,6 +570,7 @@ export class SdfTextRenderer extends TextRenderer { scrollable, overflowSuffix, maxLines, + wrapWord, ); state.bufferUploaded = false; diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts index a8e1cc06..ead0f897 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts @@ -56,6 +56,7 @@ export function layoutText( scrollable: TrProps['scrollable'], overflowSuffix: TrProps['overflowSuffix'], maxLines: TrProps['maxLines'], + wrapWord: TrProps['wrapWord'], ): { bufferNumFloats: number; bufferNumQuads: number; @@ -292,6 +293,24 @@ export function layoutText( maxX = Math.max(maxX, quadX + glyph.width); curX += glyph.xAdvance; } + if ( + wrapWord == 'break' && + contain != 'none' && + charEndX + glyph.width >= lineVertexW + ) { + if (curLineBufferStart !== -1 && lineIsWithinWindow) { + bufferLineInfos.push({ + bufferStart: curLineBufferStart, + bufferEnd: bufferOffset, + }); + curLineBufferStart = -1; + } + curX = 0; + curY += vertexLineHeight; + curLineIndex++; + lastWord.codepointIndex = -1; + xStartLastWordBoundary = 0; + } } else { // Unmapped character diff --git a/src/core/text-rendering/renderers/TextRenderer.ts b/src/core/text-rendering/renderers/TextRenderer.ts index cc655a8f..f908293a 100644 --- a/src/core/text-rendering/renderers/TextRenderer.ts +++ b/src/core/text-rendering/renderers/TextRenderer.ts @@ -221,6 +221,12 @@ export interface TrProps extends TrFontProps { * @default 'none' */ contain: 'none' | 'width' | 'both'; + /** + * Word wrap option + * + * @default normal + */ + wrapWord: 'normal' | 'break'; width: number; height: number; /** @@ -370,6 +376,9 @@ const trPropSetterDefaults: TrPropSetters = { contain: (state, value) => { state.props.contain = value; }, + wrapWord: (state, value) => { + state.props.wrapWord = value; + }, offsetY: (state, value) => { state.props.offsetY = value; },