From 2fb460e70a91d380474e2208702d489192f21cb5 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Sat, 17 Oct 2026 23:18:13 +0800 Subject: [PATCH] Wrap oversized text tokens --- packages/core/src/shapes.js | 26 ++++++++++++++++++++++++++ packages/core/test/shapes.test.js | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/core/src/shapes.js b/packages/core/src/shapes.js index 2fb5ed0..832a972 100644 --- a/packages/core/src/shapes.js +++ b/packages/core/src/shapes.js @@ -135,6 +135,27 @@ export function resetTextLayouts() { // ---- text layout ----------------------------------------------------------- +const graphemeSegmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null + +function wrapOversizedLine(text, ctx, maxW) { + const parts = graphemeSegmenter + ? Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment) + : Array.from(text) + const lines = [] + let line = '' + for (const part of parts) { + const next = line + part + if (line && ctx.measureText(next).width > maxW) { + lines.push({ text: line, w: ctx.measureText(line).width }) + line = part + } else line = next + } + if (line) lines.push({ text: line, w: ctx.measureText(line).width }) + return lines +} + function wrapLines(text, font, fontSize, maxW) { const ctx = measurer() ctx.font = `500 ${fontSize}px ${font}` @@ -148,6 +169,11 @@ function wrapLines(text, font, fontSize, maxW) { out.push({ text: line, w: ctx.measureText(line).width }) line = word.trimStart() } else line = test + if (maxW && ctx.measureText(line).width > maxW) { + const wrapped = wrapOversizedLine(line, ctx, maxW) + out.push(...wrapped.slice(0, -1)) + line = wrapped.length ? wrapped[wrapped.length - 1].text : '' + } } out.push({ text: line, w: ctx.measureText(line).width }) } diff --git a/packages/core/test/shapes.test.js b/packages/core/test/shapes.test.js index 95245fe..9a21db1 100644 --- a/packages/core/test/shapes.test.js +++ b/packages/core/test/shapes.test.js @@ -127,6 +127,27 @@ describe('text layout', () => { expect(l.w).toBe(120) }) + it('wraps oversized tokens within a fixed-width box', () => { + const text = 'https://example.com/abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz' + const t = { id: 't', typeName: 'shape', type: 'text', x: 0, y: 0, rot: 0, z: 1, props: { text, size: 'm', autosize: false, w: 200, font: 'draw' } } + const l = textLayout(t) + expect(l.lines.length).toBeGreaterThan(1) + expect(l.lines.map(({ text }) => text).join('')).toBe(text) + expect(l.lines.every(({ w }) => w <= 200)).toBe(true) + }) + + it('wraps at grapheme boundaries for emoji and combining marks', () => { + const family = '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ' + const combining = 'e\u0301' + const text = family.repeat(8) + combining.repeat(8) + const t = { id: 't', typeName: 'shape', type: 'text', x: 0, y: 0, rot: 0, z: 1, props: { text, size: 'm', autosize: false, w: 200, font: 'draw' } } + const l = textLayout(t) + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + const clusters = (value) => Array.from(segmenter.segment(value), ({ segment }) => segment) + expect(l.lines.map(({ text }) => text).flatMap(clusters)).toEqual(clusters(text)) + expect(l.lines.every(({ w }) => w <= 200)).toBe(true) + }) + it('caches per props object (identity)', () => { const t = { id: 't', typeName: 'shape', type: 'text', x: 0, y: 0, rot: 0, z: 1, props: { text: 'hello', size: 'm' } } expect(textLayout(t)).toBe(textLayout(t))