diff --git a/src/render/canvas/__tests__/textarea-layout.ts b/src/render/canvas/__tests__/textarea-layout.ts new file mode 100644 index 0000000..2148ba5 --- /dev/null +++ b/src/render/canvas/__tests__/textarea-layout.ts @@ -0,0 +1,57 @@ +import {strictEqual} from 'assert'; +import {layout} from '../textarea-layout'; +import {fromCodePoint, toCodePoints} from 'css-line-break'; + +describe('textarea-layout', () => { + it('should wrap lines at word boundaries', () => { + layoutEqual(' A long text with several lines.', + [' A long text', 'with several', 'lines.']); + }); + it('should omit spaces at the end of a line', () => { + layoutEqual(' A long text with several lines. 2', + [' A long text', 'with several', 'lines. 2']); + }); + it('should omit spaces at the end of a line 2', () => { + layoutEqual(' A long text with several lines. 2', + [' A long', 'text with', 'several', 'lines. 2']); + }); + it('should omit spaces at the end of a line 3', () => { + layoutEqual('A long text with sev eral lines.', + ['A long text', 'with sev', 'eral lines.']); + }); + it('should respect newlines', () => { + layoutEqual(' A long text\n with\n\n several lines.', + [' A long text', ' with', '', ' several', 'lines.']); + }); + it('should wrap too long words', () => { + layoutEqual('Donaudampfschifffahrtskapitänskoch 2', + ['Donaudampfsc', 'hifffahrtska', 'pitänskoch 2']); + }); + it('should wrap too long words 2', () => { + layoutEqual('Donaudampfschifffahrtskapitänskoch\n 2', + ['Donaudampfsc', 'hifffahrtska', 'pitänskoch', ' 2']); + }); + it('should wrap too long words 3', () => { + layoutEqual('Donaudampfsc x2', + ['Donaudampfsc', 'x2']); + }); +}); + +function layoutEqual(s: string, lines: string[]) { + const pos = layout(toCodePoints(s).map(i => fromCodePoint(i)), 120, _ => 10); + let line = ''; + let y = 0; + let j = 0; + for (let i = 0; i < pos.length; i++) { + if (y === pos[i][1]) { + if (pos[i][0] >= 0) { + line += s.charAt(i); + } + } else { + strictEqual(line, lines[j]); + j++; + line = pos[i][0] >= 0 ? s.charAt(i) : ''; + y = pos[i][1]; + } + } +} diff --git a/src/render/canvas/canvas-renderer.ts b/src/render/canvas/canvas-renderer.ts index 5fd694f..e9d9a07 100644 --- a/src/render/canvas/canvas-renderer.ts +++ b/src/render/canvas/canvas-renderer.ts @@ -38,6 +38,7 @@ import {TextareaElementContainer} from '../../dom/elements/textarea-element-cont import {SelectElementContainer} from '../../dom/elements/select-element-container'; import {IFrameElementContainer} from '../../dom/replaced-elements/iframe-element-container'; import {TextShadow} from '../../css/property-descriptors/text-shadow'; +import {layout} from './textarea-layout'; export type RenderConfigurations = RenderOptions & { backgroundColor: Color | null; @@ -141,77 +142,18 @@ export class CanvasRenderer { } renderTextWithLetterSpacing(text: TextBounds, letterSpacing: number, lineHeight?: number) { - if (letterSpacing === 0) { - if (lineHeight===undefined){ - this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + text.bounds.height); - }else { - let pos = 0 - let word = 0 - let line = 0 - let y = text.bounds.top + lineHeight / 2 - while (pos < text.text.length) { - let c = text.text.charAt(pos) - pos++ - switch (c) { - case ' ': - if (this.ctx.measureText(text.text.substring(line, pos - 1)).width > text.bounds.width) { - if (line === word) { - let p = pos - while (this.ctx.measureText(text.text.substring(line, p)).width > text.bounds.width) { - p-- - } - this.ctx.fillText(text.text.substring(line, p), text.bounds.left, y) - y += lineHeight - line = word = p - } else { - this.ctx.fillText(text.text.substring(line, word), text.bounds.left, y) - y += lineHeight - let s = word === pos - 1 - while (pos < text.text.length && text.text.charAt(pos) === ' ') pos++ - line = s ? pos : word - word = pos - } - } else { - word = pos - } - break - case '\n': - if (this.ctx.measureText(text.text.substring(line, pos - 1)).width > text.bounds.width) { - if (line === word) { - let p = pos - while (this.ctx.measureText(text.text.substring(line, p)).width > text.bounds.width) { - p-- - } - this.ctx.fillText(text.text.substring(line, p), text.bounds.left, y) - y += lineHeight - line = word = p - } else { - this.ctx.fillText(text.text.substring(line, word), text.bounds.left, y) - y += lineHeight - line = word - word = pos - } - } else { - this.ctx.fillText(text.text.substring(line, pos - 1), text.bounds.left, y) - y += lineHeight - line = pos - word = pos - } - break - default: - } - } - if (pos > line) { - this.ctx.fillText(text.text.substring(line, pos + 1), text.bounds.left, y) + if (lineHeight === undefined) { + this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + text.bounds.height); + } else { + const chars = toCodePoints(text.text).map(i => fromCodePoint(i)); + const pos = layout(chars, text.bounds.width, s => this.ctx.measureText(s).width + letterSpacing); + const dx = text.bounds.left; + const dy = text.bounds.top + lineHeight / 2; + for (let i = 0; i < pos.length; i++) { + if (pos[i][0] >= 0) { + this.ctx.fillText(chars[i], pos[i][0] + dx, pos[i][1] * lineHeight + dy); } } - } else { - const letters = toCodePoints(text.text).map(i => fromCodePoint(i)); - letters.reduce((left, letter) => { - this.ctx.fillText(letter, left, text.bounds.top + text.bounds.height); - - return left + this.ctx.measureText(letter).width; - }, text.bounds.left); } } @@ -455,7 +397,8 @@ export class CanvasRenderer { ]); this.ctx.clip(); - this.renderTextWithLetterSpacing(new TextBounds(container.value, textBounds), styles.letterSpacing, computeLineHeight(styles.lineHeight, styles.fontSize.number)); + const lineHeight = (container instanceof TextareaElementContainer) ? computeLineHeight(styles.lineHeight, styles.fontSize.number) : undefined; + this.renderTextWithLetterSpacing(new TextBounds(container.value, textBounds), styles.letterSpacing, lineHeight); this.ctx.restore(); this.ctx.textBaseline = 'bottom'; this.ctx.textAlign = 'left'; diff --git a/src/render/canvas/textarea-layout.ts b/src/render/canvas/textarea-layout.ts new file mode 100644 index 0000000..a425400 --- /dev/null +++ b/src/render/canvas/textarea-layout.ts @@ -0,0 +1,45 @@ +export function layout(chars: string[], width: number, measure: (s: string) => number): number[][] { + const pos: number[][] = []; + let line = 0; + let lineWidth = 0; + for (let i = 0; i < chars.length; i++) { + if (chars[i] === '\n') { + pos.push([-1, line]); + line++; + lineWidth = 0; + } else { + pos.push([lineWidth, line]); + lineWidth += measure(chars[i]); + if (lineWidth > width) { + if (chars[i] === ' ') { + let p = i; + while (p > 0 && pos[p][1] === line && chars[p] === ' ') p--; + for (let j = i; j > p; j--) { + pos[j][0] = -1; + } + line++; + lineWidth = 0; + while (i < chars.length + 1 && chars[i + 1] === ' ') { + pos.push([-1, line]); + i++; + } + } else { + let p = i; + while (p > 0 && pos[p][1] === line && chars[p] !== ' ') p--; + line++; + if (chars[p] === ' ') { + lineWidth -= pos[p + 1][0]; + for (let j = i; j > p; j--) { + pos[j] = [pos[j][0] - pos[p + 1][0], line]; + } + pos[p][0] = -1; + } else { + lineWidth -= pos[i][0]; + pos[i] = [0, line]; + } + } + } + } + } + return pos; +} diff --git a/tests/reftests/text/textarea.html b/tests/reftests/text/textarea.html index a6a4aac..341eaaf 100644 --- a/tests/reftests/text/textarea.html +++ b/tests/reftests/text/textarea.html @@ -16,31 +16,33 @@
1. word wrap
1. word wrap
-1. word wrap
2. padding
-1. word wrap
3. line height
-1. word wrap
4. letter spacing
+ + +5. multiple spaces
-1. word wrap
6. newlines
-1. word wrap
1. word wrap
1. word wrap
7. long word