diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d3bb18d7f..83c475bda71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [next] +- feat(Textbox): Add a new text wrap strategy, similar to the css property `overflowWrap: break-word` #8526 - fix(): `_initRetinaScaling` initializaing the scaling regardless of settings in Canvas. [#8565](https://github.com/fabricjs/fabric.js/pull/8565) - fix(): regression of canvas migration with pointer and sendPointToPlane [#8563](https://github.com/fabricjs/fabric.js/pull/8563) - chore(TS): Use exports from files to build fabricJS, get rid of HEADER.js [#8549](https://github.com/fabricjs/fabric.js/pull/8549) diff --git a/src/shapes/itext.class.ts b/src/shapes/itext.class.ts index a3d5f55128d..df7805136cf 100644 --- a/src/shapes/itext.class.ts +++ b/src/shapes/itext.class.ts @@ -381,8 +381,11 @@ export class IText extends ITextClickBehaviorMixin { leftOffset -= this._getWidthOfCharSpacing(); } const boundaries = { - top: topOffset, - left: lineLeftOffset + (leftOffset > 0 ? leftOffset : 0), + top: Math.min(topOffset, this.height), + left: Math.min( + lineLeftOffset + (leftOffset > 0 ? leftOffset : 0), + this.width + ), }; if (this.direction === 'rtl') { if ( diff --git a/src/shapes/textbox.class.ts b/src/shapes/textbox.class.ts index a30ab866ad7..19a133a16a2 100644 --- a/src/shapes/textbox.class.ts +++ b/src/shapes/textbox.class.ts @@ -38,9 +38,26 @@ export class Textbox extends IText { * this is a cheap way to help with chinese/japanese * @type Boolean * @since 2.6.0 + * @deprecated use {@link textOverflow} `anywhere` instead */ splitByGrapheme: boolean; + /** + * Implementation of css property `overflow-wrap` + * The `normal` option is not supported + * Default behavior adjusts size to fit the largest word + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap + */ + textOverflow?: 'break-word' | 'anywhere' | ''; + + /** + * allow backward compatibility for {@link splitByGrapheme} + * @todo remove once {@link splitByGrapheme} is removed + */ + protected resolveTextOverflowStrategy() { + return this.textOverflow || (this.splitByGrapheme && 'anywhere') || ''; + } + /** * Unlike superclass's version of this function, Textbox does not update * its width. @@ -90,7 +107,7 @@ export class Textbox extends IText { charCount++; realLineCount++; } else if ( - !this.splitByGrapheme && + !this.resolveTextOverflowStrategy() && this._reSpaceAndTab.test(textInfo.graphemeText[charCount]) && i > 0 ) { @@ -243,8 +260,11 @@ export class Textbox extends IText { _wrapText(lines: Array, desiredWidth: number): Array { const wrapped = []; this.isWrapping = true; + const wrapFunction = this.resolveTextOverflowStrategy() + ? this._wrapLineWithTextOverflow + : this._wrapLine; for (let i = 0; i < lines.length; i++) { - wrapped.push(...this._wrapLine(lines[i], i, desiredWidth)); + wrapped.push(...wrapFunction.call(this, lines[i], i, desiredWidth)); } this.isWrapping = false; return wrapped; @@ -290,6 +310,93 @@ export class Textbox extends IText { return value.split(this._wordJoiners); } + /** + * This method splits text into lines by looking for the last space in a measured piece of text that fits the bounds. + * If a space is found, the line breaks before the last word in the sequence. + * If not, it splits the last word and breaks the line. + * @param {Array} line The grapheme array that represent the line + * @param {Number} lineIndex + * @param {Number} desiredWidth width you want to wrap the line to + * @param {Number} reservedSpace space to remove from wrapping for custom functionalities + * @returns {Array} Array of line(s) into which the given text is wrapped + * to. + */ + _wrapLineWithTextOverflow( + line: string, + lineIndex: number, + desiredWidth: number, + reservedSpace = 0 + ): string[][] { + desiredWidth -= reservedSpace; + const graphemeLines = []; + const charSpacing = this._getWidthOfCharSpacing(); + let text = line || '', + offset = 0, + largestLetterWidth = 0, + width: number, + length: number, + temp: string, + prevGrapheme: string, + appendWSLength: number; + + if (!text.length) { + graphemeLines.push([]); + } + while (text.length > 0) { + length = text.length; + // Get the maximum string at a fixed width by measuring chars until bounds are reached + width = 0; + prevGrapheme = ''; + for (let k = 0; k < text.length; k++) { + const box = this._getGraphemeBox( + text[k], + lineIndex, + offset + k, + prevGrapheme, + true + ); + width += box.kernedWidth; + largestLetterWidth = Math.max( + largestLetterWidth, + box.kernedWidth - charSpacing + ); + if (width - charSpacing > Math.max(largestLetterWidth, desiredWidth)) { + length = k; + break; + } + prevGrapheme = text[k]; + } + temp = text.substring(0, length); + if (this.resolveTextOverflowStrategy() === 'break-word') { + // find last space to split words + for (let l = temp.length - 1; l >= 0; l--) { + if (this._wordJoiners.test(temp[l])) { + temp = temp.substring(0, l); + break; + } + } + } + // look ahead for whitespace to append to the current line + appendWSLength = text.length; + for (let k = temp.length; k < text.length; k++) { + if (!this._wordJoiners.test(text[k])) { + appendWSLength = k; + break; + } + } + // finalize line + temp += text.substring(temp.length, appendWSLength); + graphemeLines.push(this.graphemeSplit(temp)); + // prepare for next line + offset += temp.length; + text = text.substring(temp.length); + } + if (largestLetterWidth > this.dynamicMinWidth) { + this.dynamicMinWidth = largestLetterWidth + reservedSpace; + } + return graphemeLines; + } + /** * Wraps a line of text using the width of the Textbox and a context. * @param {Array} line The grapheme array that represent the line @@ -306,12 +413,10 @@ export class Textbox extends IText { reservedSpace = 0 ): Array { const additionalSpace = this._getWidthOfCharSpacing(), - splitByGrapheme = this.splitByGrapheme, + breakAnywhere = this.resolveTextOverflowStrategy() === 'anywhere', graphemeLines = [], - words = splitByGrapheme - ? this.graphemeSplit(_line) - : this.wordSplit(_line), - infix = splitByGrapheme ? '' : ' '; + words = breakAnywhere ? this.graphemeSplit(_line) : this.wordSplit(_line), + infix = breakAnywhere ? '' : ' '; let lineWidth = 0, line = [], @@ -327,8 +432,8 @@ export class Textbox extends IText { desiredWidth -= reservedSpace; // measure words const data = words.map((word) => { - // if using splitByGrapheme words are already in graphemes. - word = splitByGrapheme ? word : this.graphemeSplit(word); + // if using textOverflow `anywhere` words are already in graphemes. + word = breakAnywhere ? word : this.graphemeSplit(word); const width = this._measureWord(word, lineIndex, offset); largestWordWidth = Math.max(width, largestWordWidth); offset += word.length + 1; @@ -358,12 +463,12 @@ export class Textbox extends IText { lineWidth += additionalSpace; } - if (!lineJustStarted && !splitByGrapheme) { + if (!lineJustStarted && !breakAnywhere) { line.push(infix); } line = line.concat(word); - infixWidth = splitByGrapheme + infixWidth = breakAnywhere ? 0 : this._measureWord([infix], lineIndex, offset); offset++; @@ -402,7 +507,7 @@ export class Textbox extends IText { * @return Number */ missingNewlineOffset(lineIndex) { - if (this.splitByGrapheme) { + if (this.resolveTextOverflowStrategy()) { return this.isEndOfWrapping(lineIndex) ? 1 : 0; } return 1; @@ -452,9 +557,10 @@ export class Textbox extends IText { * @return {Object} object representation of an instance */ toObject(propertiesToInclude: Array): object { - return super.toObject( - ['minWidth', 'splitByGrapheme'].concat(propertiesToInclude) - ); + return { + ...super.toObject(['minWidth'].concat(propertiesToInclude)), + textOverflow: this.resolveTextOverflowStrategy(), + }; } } @@ -467,7 +573,6 @@ export const textboxDefaultValues: Partial> = { _dimensionAffectingProps: textDefaultValues._dimensionAffectingProps!.concat('width'), _wordJoiners: /[ \t\r]/, - splitByGrapheme: false, }; Object.assign(Textbox.prototype, textboxDefaultValues); diff --git a/test/unit/textbox.js b/test/unit/textbox.js index 5191cf67b4a..586d65c5dc6 100644 --- a/test/unit/textbox.js +++ b/test/unit/textbox.js @@ -67,7 +67,7 @@ } ], minWidth: 20, - splitByGrapheme: false, + textOverflow: '', strokeUniform: false, path: null, direction: 'ltr', @@ -270,28 +270,73 @@ textbox.initDimensions(); assert.equal(textbox.textLines[0], 'xa', 'first line match expectations spacing 800'); }); - QUnit.test('wrapping with charspacing and splitByGrapheme positive', function(assert) { + QUnit.test('wrapping with charspacing and textOverflow `anywhere` positive', function(assert) { var textbox = new fabric.Textbox('xaxbxcxdeyaybid', { width: 190, - splitByGrapheme: true, + textOverflow: 'anywhere', charSpacing: 400 }); assert.deepEqual( textbox.textLines, ['xaxbx', 'cxdey', 'aybid'], - 'lines match splitByGrapheme charSpacing 400' + 'lines match textOverflow `anywhere` charSpacing 400' ); }); - QUnit.test('wrapping with charspacing and splitByGrapheme negative', function(assert) { + QUnit.test('wrapping with charspacing and textOverflow `anywhere` negative', function(assert) { var textbox = new fabric.Textbox('xaxbxcxdeyaybid', { width: 190, - splitByGrapheme: true, + textOverflow: 'anywhere', charSpacing: -100 }); assert.deepEqual( textbox.textLines, ['xaxbxcxdeyay', 'bid'], - 'lines match splitByGrapheme charSpacing -100' + 'lines match textOverflow `anywhere` charSpacing -100' + ); + }); + QUnit.test('wrapping with charspacing and textOverflow `break-word` positive', function(assert) { + var textbox = new fabric.Textbox('xaxbxcxdeyaybid', { + width: 190, + textOverflow: 'break-word', + charSpacing: 400 + }); + assert.deepEqual( + textbox.textLines, + ['xaxbx', 'cxdey', 'aybid'], + 'lines match textOverflow `break-word` charSpacing 400' + ); + }); + QUnit.test('wrapping with textOverflow `break-word` and whitespace', function (assert) { + var textbox = new fabric.Textbox( + 'xaxbxc xdeyaybid \n ', + { + width: 190, + textOverflow: 'break-word', + charSpacing: 400 + } + ); + assert.deepEqual( + textbox.textLines, + [ + 'xaxbx', + 'c ', + 'xdeya', + 'ybid ', + ' ' + ], + 'lines match textOverflow `break-word' + ); + }); + QUnit.test('wrapping with charspacing and textOverflow `break-word` negative', function(assert) { + var textbox = new fabric.Textbox('xaxbxcxdeyaybid', { + width: 190, + textOverflow: 'break-word', + charSpacing: -100 + }); + assert.deepEqual( + textbox.textLines, + ['xaxbxcxdeyay', 'bid'], + 'lines match textOverflow `break-word` charSpacing -100' ); }); QUnit.test('wrapping with different things', function(assert) { @@ -306,24 +351,36 @@ assert.equal(textbox.textLines[5], 'ya', '5 line match expectations'); assert.equal(textbox.textLines[6], 'yb', '6 line match expectations'); }); - QUnit.test('wrapping with splitByGrapheme', function(assert) { + QUnit.test('wrapping with textOverflow `anywhere`', function(assert) { + var textbox = new fabric.Textbox('xaxbxcxdxeyaybid', { + width: 1, + textOverflow: 'anywhere', + }); + assert.equal(textbox.textLines[0], 'x', '0 line match expectations'); + assert.equal(textbox.textLines[1], 'a', '1 line match expectations'); + assert.equal(textbox.textLines[2], 'x', '2 line match expectations'); + assert.equal(textbox.textLines[3], 'b', '3 line match expectations'); + assert.equal(textbox.textLines[4], 'x', '4 line match expectations'); + assert.equal(textbox.textLines[5], 'c', '5 line match expectations'); + }); + QUnit.test('wrapping with textOverflow `break-word`', function(assert) { var textbox = new fabric.Textbox('xaxbxcxdxeyaybid', { width: 1, - splitByGrapheme: true, + textOverflow: 'break-word', }); - assert.equal(textbox.textLines[0], 'x', '0 line match expectations splitByGrapheme'); - assert.equal(textbox.textLines[1], 'a', '1 line match expectations splitByGrapheme'); - assert.equal(textbox.textLines[2], 'x', '2 line match expectations splitByGrapheme'); - assert.equal(textbox.textLines[3], 'b', '3 line match expectations splitByGrapheme'); - assert.equal(textbox.textLines[4], 'x', '4 line match expectations splitByGrapheme'); - assert.equal(textbox.textLines[5], 'c', '5 line match expectations splitByGrapheme'); + assert.equal(textbox.textLines[0], 'x', '0 line match expectations'); + assert.equal(textbox.textLines[1], 'a', '1 line match expectations'); + assert.equal(textbox.textLines[2], 'x', '2 line match expectations'); + assert.equal(textbox.textLines[3], 'b', '3 line match expectations'); + assert.equal(textbox.textLines[4], 'x', '4 line match expectations'); + assert.equal(textbox.textLines[5], 'c', '5 line match expectations'); }); QUnit.test('wrapping with custom space', function(assert) { var textbox = new fabric.Textbox('xa xb xc xd xe ya yb id', { width: 2000, }); var line1 = textbox._wrapLine('xa xb xc xd xe ya yb id', 0, 100, 0); - var expected1 = [ + var expected1 = [ ['x', 'a', ' ', 'x', 'b'], ['x', 'c', ' ', 'x', 'd'], ['x', 'e', ' ', 'y', 'a'], @@ -348,10 +405,13 @@ width: 10, }); var line1 = textbox._wrapLine('', 0, 100, 0); - assert.deepEqual(line1, [[]], 'wrapping without splitByGrapheme'); - textbox.splitByGrapheme = true; + assert.deepEqual(line1, [[]], 'wrapping without textOverflow `anywhere`'); + textbox.textOverflow = 'anywhere'; var line2 = textbox._wrapLine('', 0, 100, 0); - assert.deepEqual(line2, [[]], 'wrapping with splitByGrapheme'); + assert.deepEqual(line2, [[]], 'wrapping with textOverflow `anywhere`'); + textbox.textOverflow = 'break-word'; + var line3 = textbox._wrapLineWithTextOverflow('', 0, 100, 0); + assert.deepEqual(line3, [[]], 'wrapping with textOverflow `break-word`'); }); QUnit.test('texbox will change width from the mr corner', function(assert) { var text = new fabric.Textbox('xa xb xc xd xe ya yb id', { strokeWidth: 0 }); @@ -418,9 +478,9 @@ assert.equal(iText.styles[4], undefined, 'style line 4 has been removed'); }); - QUnit.test('get2DCursorLocation with splitByGrapheme', function(assert) { + QUnit.test('get2DCursorLocation with textOverflow `anywhere`', function(assert) { var iText = new fabric.Textbox('aaaaaaaaaaaaaaaaaaaaaaaa', - { width: 60, splitByGrapheme: true }); + { width: 60, textOverflow: 'anywhere' }); var loc = iText.get2DCursorLocation(); // [ [ '由', '石', '墨' ], @@ -456,9 +516,47 @@ assert.equal(loc.charIndex, 2, 'selection end 14 char 2'); }); - QUnit.test('missingNewlineOffset with splitByGrapheme', function(assert) { + QUnit.test('get2DCursorLocation with textOverflow `break-word`', function(assert) { + var iText = new fabric.Textbox('aaaaaaaaaaaaaaaaaaaaaaaa', + { width: 60, textOverflow: 'break-word' }); + var loc = iText.get2DCursorLocation(); + + // [ [ '由', '石', '墨' ], + // [ '分', '裂', '的' ], + // [ '石', '墨', '分' ], + // [ '裂', '由', '石' ], + // [ '墨', '分', '裂' ], + // [ '由', '石', '墨' ], + // [ '分', '裂', '的' ], + // [ '石', '墨', '分' ], + // [ '裂' ] ] + + assert.equal(loc.lineIndex, 0); + assert.equal(loc.charIndex, 0); + + // '由石墨|分裂的石墨分裂由石墨分裂由石墨分裂的石墨分裂' + iText.selectionStart = iText.selectionEnd = 4; + loc = iText.get2DCursorLocation(); + + assert.equal(loc.lineIndex, 1, 'selection end 4 line 1'); + assert.equal(loc.charIndex, 1, 'selection end 4 char 1'); + + iText.selectionStart = iText.selectionEnd = 7; + loc = iText.get2DCursorLocation(); + + assert.equal(loc.lineIndex, 2, 'selection end 7 line 2'); + assert.equal(loc.charIndex, 1, 'selection end 7 char 1'); + + iText.selectionStart = iText.selectionEnd = 14; + loc = iText.get2DCursorLocation(); + + assert.equal(loc.lineIndex, 4, 'selection end 14 line 4'); + assert.equal(loc.charIndex, 2, 'selection end 14 char 2'); + }); + + QUnit.test('missingNewlineOffset with textOverflow `anywhere`', function(assert) { var textbox = new fabric.Textbox('aaa\naaaaaa\na\naaaaaaaaaaaa\naaa', - { width: 80, splitByGrapheme: true }); + { width: 80, textOverflow: 'anywhere' }); // [ [ 'a', 'a', 'a' ], // [ 'a', 'a', 'a', 'a' ], @@ -488,6 +586,27 @@ assert.equal(offset, 1, 'it returns always 1'); }); + QUnit.test('missingNewlineOffset with textOverflow `break-word`', function(assert) { + var textbox = new fabric.Textbox('aaa\naaaaaa\na\naaaaaaaaaaaa\naaa', + { width: 80, textOverflow: 'break-word' }); + + // Same behavior as textOverflow `anywhere` in this case + // [ [ 'a', 'a', 'a' ], + // [ 'a', 'a', 'a', 'a' ], + // [ 'a', 'a' ], + // [ 'a' ], + // [ 'a', 'a', 'a', 'a' ], + // [ 'a', 'a', 'a', 'a' ], + // [ 'a', 'a', 'a', 'a' ], + // [ 'a', 'a', 'a' ] ] + + var offset = textbox.missingNewlineOffset(0); + assert.equal(offset, 1, 'line 0 is interrupted by a \n so has an offset of 1'); + + offset = textbox.missingNewlineOffset(1); + assert.equal(offset, 0, 'line 1 is wrapped without a \n so it does have an extra char count'); + }); + QUnit.test('_getLineStyle', function(assert) { var textbox = new fabric.Textbox('aaa aaq ggg gg\noee eee', { styles: { @@ -530,7 +649,6 @@ var styles = {}; for (var index = 0; index < text.length; index++) { styles[index] = { fontSize: 4 }; - } var textbox = new fabric.Textbox(text, { styles: { 0: styles }, @@ -584,12 +702,25 @@ var str = '0123456789'; var measureTextbox = new fabric.Textbox(str, { fontSize: 20, - splitByGrapheme: false, + textOverflow: '', + }); + var newTextbox = new fabric.Textbox(str, { + width: measureTextbox.width, + fontSize: 20, + textOverflow: 'anywhere', + }); + assert.equal(newTextbox.textLines.length, measureTextbox.textLines.length, 'The same text is not wrapped'); + }); + QUnit.test('The same text does not need to be wrapped.', function(assert) { + var str = '0123456789'; + var measureTextbox = new fabric.Textbox(str, { + fontSize: 20, + textOverflow: '', }); var newTextbox = new fabric.Textbox(str, { width: measureTextbox.width, fontSize: 20, - splitByGrapheme: true, + textOverflow: 'break-word', }); assert.equal(newTextbox.textLines.length, measureTextbox.textLines.length, 'The same text is not wrapped'); }); diff --git a/test/visual/text.js b/test/visual/text.js index 20cc2ffbd59..5c3b581965d 100644 --- a/test/visual/text.js +++ b/test/visual/text.js @@ -473,7 +473,7 @@ function text13(canvas, callback) { fabric.Textbox.fromObject( - JSON.parse('{"type":"textbox","version":"4.5.0","left":0.94,"top":0.46,"width":231.02,"height":254.93,"scaleX":0.9,"scaleY":0.9,"angle":0.19,"text":"اگر شما یک طراح هستین و یا با طراحی های گرافیکی سروکار دارید.","fontFamily":"Arial","underline":true,"linethrough":true,"textAlign":"right","direction":"rtl","minWidth":20,"splitByGrapheme":false,"styles":{},"path":null}') + JSON.parse('{"type":"textbox","version":"4.5.0","left":0.94,"top":0.46,"width":231.02,"height":254.93,"scaleX":0.9,"scaleY":0.9,"angle":0.19,"text":"اگر شما یک طراح هستین و یا با طراحی های گرافیکی سروکار دارید.","fontFamily":"Arial","underline":true,"linethrough":true,"textAlign":"right","direction":"rtl","minWidth":20,"styles":{},"path":null}') ).then(function(text) { canvas.add(text); canvas.renderAll();