UNPKG

@pmndrs/uikit

Version:

Build performant 3D user interfaces with Three.js and yoga.

327 lines (326 loc) 16.5 kB
import { effect, signal } from '@preact/signals-core'; import { InstancedGlyph } from './instanced-glyph.js'; import { abortableEffect } from '../../utils.js'; import { getGlyphLayoutHeight, getGlyphOffsetX, getGlyphOffsetY, getOffsetToNextGlyph, getOffsetToNextLine, toAbsoluteNumber, } from '../utils.js'; import { buildGlyphLayout, computedCustomLayouting } from '../layout.js'; export const additionalTextDefaults = { verticalAlign: 'middle', }; export function createInstancedText(text, parentClippingRect, selectionRange, selectionTransformations, caretTransformation, instancedTextRef) { let layoutPropertiesRef = { current: undefined }; const customLayouting = computedCustomLayouting(text.properties, text.fontSignal, layoutPropertiesRef); const layoutSignal = signal(undefined); abortableEffect(() => text.node.addLayoutChangeListener(() => { const layoutProperties = layoutPropertiesRef.current; const { size: { value: size }, paddingInset: { value: paddingInset }, borderInset: { value: borderInset }, } = text; if (layoutProperties == null || size == null || paddingInset == null || borderInset == null) { return; } const [width, height] = size; const [pTop, pRight, pBottom, pLeft] = paddingInset; const [bTop, bRight, bBottom, bLeft] = borderInset; const actualWidth = width - pRight - pLeft - bRight - bLeft; const actualheight = height - pTop - pBottom - bTop - bBottom; layoutSignal.value = buildGlyphLayout(layoutProperties, actualWidth, actualheight); }), text.abortSignal); abortableEffect(() => { const font = text.fontSignal.value; if (font == null || text.orderInfo.value == null) { return; } const instancedText = new InstancedText(text.root.value.glyphGroupManager.getGroup(text.orderInfo.value, text.properties.value.depthTest, text.properties.value.depthWrite ?? false, text.properties.value.renderOrder, font), text.properties, layoutSignal, text.globalTextMatrix, text.isVisible, parentClippingRect, selectionRange, selectionTransformations, caretTransformation); if (instancedTextRef != null) { instancedTextRef.current = instancedText; } return () => instancedText.destroy(); }, text.abortSignal); return customLayouting; } const noSelectionTransformations = []; export class InstancedText { group; properties; layoutSignal; matrix; parentClippingRect; selectionRange; selectionTransformations; caretTransformation; glyphLines = []; lastLayout; unsubscribeInitialList = []; unsubscribeShowList = []; constructor(group, properties, layoutSignal, matrix, isVisible, parentClippingRect, selectionRange, selectionTransformations, caretTransformation) { this.group = group; this.properties = properties; this.layoutSignal = layoutSignal; this.matrix = matrix; this.parentClippingRect = parentClippingRect; this.selectionRange = selectionRange; this.selectionTransformations = selectionTransformations; this.caretTransformation = caretTransformation; this.unsubscribeInitialList = [ effect(() => { if (!isVisible.value || toAbsoluteNumber(this.properties.value.opacity, () => 1) < 0.01) { this.hide(); return; } this.show(); }), effect(() => this.updateSelectionBoxes(this.lastLayout, selectionRange?.value, properties.peek().verticalAlign, properties.peek().textAlign)), ]; } getCharIndex(x, y, position) { const layout = this.lastLayout; if (layout == null) { return 0; } y -= -getYOffset(layout, this.properties.peek().verticalAlign); const lineIndex = Math.floor(y / -getOffsetToNextLine(layout.lineHeight)); const lines = layout.lines; if (lineIndex < 0 || lines.length === 0) { return 0; } if (lineIndex >= lines.length) { const lastLine = lines[lines.length - 1]; return lastLine.charIndexOffset + lastLine.charLength + 1; } const line = lines[lineIndex]; const whitespaceWidth = layout.font.getGlyphInfo(' ').xadvance * layout.fontSize; const glyphs = this.glyphLines[lineIndex]; let glyphsLength = glyphs.length; for (let i = 0; i < glyphsLength; i++) { const entry = glyphs[i]; if (x < this.getGlyphX(entry, position === 'between' ? 0.5 : 1, whitespaceWidth) + layout.availableWidth / 2) { return i + line.charIndexOffset; } } return line.charIndexOffset + line.charLength + 1; } updateSelectionBoxes(layout, range, verticalAlign, textAlign) { if (this.caretTransformation == null || this.selectionTransformations == null) { return; } if (range == null || layout == null || layout.lines.length === 0) { this.caretTransformation.value = undefined; this.selectionTransformations.value = noSelectionTransformations; return; } const whitespaceWidth = layout.font.getGlyphInfo(' ').xadvance * layout.fontSize; const [startCharIndexIncl, endCharIndexExcl] = range; if (endCharIndexExcl <= startCharIndexIncl) { const { lineIndex, x } = this.getGlyphLineAndX(layout, endCharIndexExcl, true, whitespaceWidth, textAlign); const y = -(getYOffset(layout, verticalAlign) - layout.availableHeight / 2 + lineIndex * getOffsetToNextLine(layout.lineHeight) + getGlyphOffsetY(layout.fontSize, layout.lineHeight)); this.caretTransformation.value = { position: [x, y - layout.fontSize / 2], height: layout.fontSize }; this.selectionTransformations.value = []; return; } this.caretTransformation.value = undefined; const start = this.getGlyphLineAndX(layout, startCharIndexIncl, true, whitespaceWidth, textAlign); const end = this.getGlyphLineAndX(layout, endCharIndexExcl - 1, false, whitespaceWidth, textAlign); if (start.lineIndex === end.lineIndex) { this.selectionTransformations.value = [ this.computeSelectionTransformation(start.lineIndex, start.x, end.x, layout, verticalAlign, whitespaceWidth), ]; return; } const newSelectionTransformations = [ this.computeSelectionTransformation(start.lineIndex, start.x, undefined, layout, verticalAlign, whitespaceWidth), ]; for (let i = start.lineIndex + 1; i < end.lineIndex; i++) { newSelectionTransformations.push(this.computeSelectionTransformation(i, undefined, undefined, layout, verticalAlign, whitespaceWidth)); } newSelectionTransformations.push(this.computeSelectionTransformation(end.lineIndex, undefined, end.x, layout, verticalAlign, whitespaceWidth)); this.selectionTransformations.value = newSelectionTransformations; } computeSelectionTransformation(lineIndex, startX, endX, layout, verticalAlign, whitespaceWidth) { const lineGlyphs = this.glyphLines[lineIndex]; if (startX == null) { startX = this.getGlyphX(lineGlyphs[0], 0, whitespaceWidth); } if (endX == null) { endX = this.getGlyphX(lineGlyphs[lineGlyphs.length - 1], 1, whitespaceWidth); } const height = getOffsetToNextLine(layout.lineHeight); const y = -(getYOffset(layout, verticalAlign) - layout.availableHeight / 2 + lineIndex * height); const width = endX - startX; return { position: [startX + width / 2, y - height / 2], size: [width, height] }; } getGlyphLineAndX({ lines, availableWidth }, charIndex, start, whitespaceWidth, textAlign) { const linesLength = lines.length; if (charIndex >= lines[0].charIndexOffset) { for (let lineIndex = 0; lineIndex < linesLength; lineIndex++) { const line = lines[lineIndex]; if (charIndex >= line.charIndexOffset + line.charLength) { continue; } //line found const glyphEntry = this.glyphLines[lineIndex][Math.max(charIndex - line.charIndexOffset, 0)]; return { lineIndex, x: this.getGlyphX(glyphEntry, start ? 0 : 1, whitespaceWidth) }; } } const lastLine = lines[linesLength - 1]; if (lastLine.charLength === 0 || charIndex < lastLine.charIndexOffset) { return { lineIndex: linesLength - 1, x: getXOffset(availableWidth, lastLine.nonWhitespaceWidth, textAlign) - availableWidth / 2, }; } const lastGlyphEntry = this.glyphLines[linesLength - 1][lastLine.charLength - 1]; return { lineIndex: linesLength - 1, x: this.getGlyphX(lastGlyphEntry, 1, whitespaceWidth) }; } getGlyphX(entry, widthMultiplier, whitespaceWidth) { if (typeof entry === 'number') { return entry + widthMultiplier * whitespaceWidth; } return entry.getX(widthMultiplier); } show() { if (this.unsubscribeShowList.length > 0) { return; } traverseGlyphs(this.glyphLines, (glyph) => glyph.show()); this.unsubscribeShowList.push(effect(() => { const matrix = this.matrix.value; if (matrix == null) { return; } traverseGlyphs(this.glyphLines, (glyph) => glyph.updateBaseMatrix(matrix)); }), effect(() => { const clippingRect = this.parentClippingRect?.value; traverseGlyphs(this.glyphLines, (glyph) => glyph.updateClippingRect(clippingRect)); }), effect(() => { const color = this.properties.value.color; const opacity = toAbsoluteNumber(this.properties.value.opacity, () => 1); traverseGlyphs(this.glyphLines, (glyph) => glyph.updateColor(color ?? 0, opacity)); }), effect(() => { const layout = this.layoutSignal.value; if (layout == null) { return; } const { text, font, lines, letterSpacing = 0, fontSize = 16, lineHeight = 1.2, availableWidth } = layout; let y = getYOffset(layout, this.properties.value.verticalAlign) - layout.availableHeight / 2; const linesLength = lines.length; const pixelSize = this.properties.value.pixelSize; for (let lineIndex = 0; lineIndex < linesLength; lineIndex++) { if (lineIndex === this.glyphLines.length) { this.glyphLines.push([]); } const { whitespacesBetween, nonWhitespaceWidth, charIndexOffset: firstNonWhitespaceCharIndex, nonWhitespaceCharLength, charLength, } = lines[lineIndex]; const textAlign = this.properties.value.textAlign; let offsetPerWhitespace = textAlign === 'justify' ? (availableWidth - nonWhitespaceWidth) / whitespacesBetween : 0; let x = getXOffset(availableWidth, nonWhitespaceWidth, textAlign) - availableWidth / 2; let prevGlyphId; const glyphs = this.glyphLines[lineIndex]; for (let charIndex = firstNonWhitespaceCharIndex; charIndex < firstNonWhitespaceCharIndex + charLength; charIndex++) { const glyphIndex = charIndex - firstNonWhitespaceCharIndex; const char = text[charIndex]; const glyphInfo = font.getGlyphInfo(char); if (char === ' ' || charIndex > nonWhitespaceCharLength + firstNonWhitespaceCharIndex) { prevGlyphId = glyphInfo.id; const xPosition = x + getGlyphOffsetX(font, fontSize, glyphInfo, prevGlyphId); if (typeof glyphs[glyphIndex] === 'number') { glyphs[glyphIndex] = x; } else { glyphs.splice(glyphIndex, 0, xPosition); } x += offsetPerWhitespace + getOffsetToNextGlyph(fontSize, glyphInfo, letterSpacing); continue; } //non space character //delete undefined entries so we find a reusable glyph let glyphOrNumber = glyphs[glyphIndex]; while (glyphIndex < glyphs.length && typeof glyphOrNumber == 'number') { glyphs.splice(glyphIndex, 1); glyphOrNumber = glyphs[glyphIndex]; } //the prev. loop assures that glyphOrNumber is a InstancedGlyph or undefined let glyph = glyphOrNumber; if (glyph == null) { //no reusable glyph found glyphs[glyphIndex] = glyph = new InstancedGlyph(this.group, this.matrix.peek(), this.properties.peek().color ?? 0, toAbsoluteNumber(this.properties.peek().opacity, () => 1), this.parentClippingRect?.peek()); } glyph.updateGlyphAndTransformation(glyphInfo, x + getGlyphOffsetX(font, fontSize, glyphInfo, prevGlyphId), -(y + getGlyphOffsetY(fontSize, lineHeight, glyphInfo)), fontSize, pixelSize); glyph.show(); prevGlyphId = glyphInfo.id; x += getOffsetToNextGlyph(fontSize, glyphInfo, letterSpacing); } y += getOffsetToNextLine(lineHeight); //remove unnecassary glyphs const glyphsLength = glyphs.length; const newGlyphsLength = charLength; for (let ii = newGlyphsLength; ii < glyphsLength; ii++) { const glyph = glyphs[ii]; if (typeof glyph === 'number') { continue; } glyph.hide(); } glyphs.length = newGlyphsLength; } //remove unnecassary glyph lines traverseGlyphs(this.glyphLines, (glyph) => glyph.hide(), linesLength); this.glyphLines.length = linesLength; this.lastLayout = layout; this.updateSelectionBoxes(layout, this.selectionRange?.peek(), this.properties.value.verticalAlign, this.properties.value.textAlign); })); } hide() { const unsubscribeListLength = this.unsubscribeShowList.length; if (unsubscribeListLength === 0) { return; } for (let i = 0; i < unsubscribeListLength; i++) { this.unsubscribeShowList[i](); } this.unsubscribeShowList.length = 0; traverseGlyphs(this.glyphLines, (glyph) => glyph.hide()); } destroy() { this.hide(); this.glyphLines.length = 0; const length = this.unsubscribeInitialList.length; for (let i = 0; i < length; i++) { this.unsubscribeInitialList[i](); } } } function getXOffset(availableWidth, nonWhitespaceWidth, textAlign) { switch (textAlign) { case 'right': return availableWidth - nonWhitespaceWidth; case 'center': return (availableWidth - nonWhitespaceWidth) / 2; default: return 0; } } function getYOffset(layout, verticalAlign) { switch (verticalAlign) { case 'center': case 'middle': return (layout.availableHeight - getGlyphLayoutHeight(layout.lines.length, layout.lineHeight)) / 2; case 'bottom': return layout.availableHeight - getGlyphLayoutHeight(layout.lines.length, layout.lineHeight); default: return 0; } } function traverseGlyphs(glyphLines, fn, offset = 0) { const glyphLinesLength = glyphLines.length; for (let i = offset; i < glyphLinesLength; i++) { const glyphs = glyphLines[i]; const glyphsLength = glyphs.length; for (let ii = 0; ii < glyphsLength; ii++) { const glyph = glyphs[ii]; if (typeof glyph == 'number') { continue; } fn(glyph); } } }