Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [next]

- feat(Textbox): min/max width [#8470](https://github.com/fabricjs/fabric.js/pull/8470)
- chore(TS): polish text [#8489](https://github.com/fabricjs/fabric.js/pull/8489)
- chore(TS): fix import cycle, extract `groupSVGElements` [#8506](https://github.com/fabricjs/fabric.js/pull/8506)
- chore(TS): permissive `Point` typings [#8434](https://github.com/fabricjs/fabric.js/pull/8434)
Expand Down
181 changes: 118 additions & 63 deletions src/shapes/textbox.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { fabric } from '../../HEADER';
import { TClassProperties } from '../typedefs';
import { capValue } from '../util/misc/capValue';
import { stylesFromArray } from '../util/misc/textStyles';
import { IText } from './itext.class';
import { FabricObject } from './object.class';
Expand All @@ -15,7 +16,9 @@ import { textDefaultValues } from './text.class';
*/
export class Textbox extends IText {
/**
* Minimum width of textbox, in pixels.
* Minimum width of textbox, in pixels.\
* Use the `resizing` event to change this value on the fly.\
* Overrides {@link #maxWidth} in case of conflict.
* @type Number
* @default
*/
Expand All @@ -30,6 +33,21 @@ export class Textbox extends IText {
*/
dynamicMinWidth: number;

/**
* Maximum width of textbox, in pixels.\
* Use the `resizing` event to change this value on the fly.\
* Will be overridden by {@link #minWidth} and by {@link #_actualMaxWidth} in case of conflict.
* @type {Number}
* @default
*/
maxWidth: number;

/**
* Holds the calculated max width value taking into account the largest word and minimum values
* @private
*/
_actualMaxWidth = Infinity;

/**
* Cached array of text wrapping.
* @type Array
Expand All @@ -44,6 +62,32 @@ export class Textbox extends IText {
*/
splitByGrapheme: boolean;

_set(key: string, value: any) {
if (key === 'width') {
value = capValue(
this.minWidth,
value,
Math.max(
this.minWidth,
this.maxWidth,
this._actualMaxWidth || this.maxWidth
)
);
}
if (key === 'maxWidth' && !value) {
value = Infinity;
}
super._set(key, value);
/* _DEV_MODE_START_ */
if (
(key === 'maxWidth' && this.width > value) ||
(key === 'minWidth' && this.width < value)
) {
console.warn(`fabric.Textbox: setting ${key}, width is out of range`);
}
/* _DEV_MODE_END_ */
}

/**
* Unlike superclass's version of this function, Textbox does not update
* its width.
Expand All @@ -59,12 +103,11 @@ export class Textbox extends IText {
this._clearCache();
// clear dynamicMinWidth as it will be different after we re-wrap line
this.dynamicMinWidth = 0;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be set to the default value 2?

this._actualMaxWidth = Infinity;
// wrap lines
this._styleMap = this._generateStyleMap(this._splitText());
// if after wrapping, the width is smaller than dynamicMinWidth, change the width and re-wrap
if (this.dynamicMinWidth > this.width) {
this._set('width', this.dynamicMinWidth);
}
this._set('width', Math.max(this.getMinWidth(), this.width));
if (this.textAlign.indexOf('justify') !== -1) {
// once text is measured we need to make space fatter to make justified text.
this.enlargeSpaces();
Expand Down Expand Up @@ -243,11 +286,50 @@ export class Textbox extends IText {
* @param {Number} desiredWidth width you want to wrap to
* @returns {Array} Array of lines
*/
_wrapText(lines: Array<any>, desiredWidth: number): Array<any> {
const wrapped = [];
_wrapText(
lines: string[],
desiredWidth: number = this.width,
reservedSpace = 0
) {
this.isWrapping = true;
for (let i = 0; i < lines.length; i++) {
wrapped.push(...this._wrapLine(lines[i], i, desiredWidth));
const additionalSpace = this._getWidthOfCharSpacing();
let largestWordWidth = 0;
const data = lines.map((line, index) => {
const parts = this.splitByGrapheme
? this.graphemeSplit(line)
: this.wordSplit(line);
// fix a difference between split and graphemeSplit
if (parts.length === 0) {
parts.push([]);
}
const { data } = parts.reduce(
(acc, value) => {
// if using splitByGrapheme words are already in graphemes.
value = this.splitByGrapheme ? value : this.graphemeSplit(value);
const width = this._measureWord(value, index, acc.offset);
largestWordWidth = Math.max(width, largestWordWidth);
// spaces in different languages?
acc.offset += value.length + 1;
acc.data.push({ value, width });
return acc;
},
{ offset: 0, data: [] as { value: string | string[]; width: number }[] }
);
return data;
});

this._actualMaxWidth = Math.max(
Math.min(desiredWidth, this.maxWidth) - reservedSpace,
this.getMinWidth(),
largestWordWidth
);

const wrapped = data.reduce((acc, lineData, index) => {
acc.push(...this._wrapLine(lineData, index));
return acc;
}, [] as string[][]);
if (largestWordWidth + reservedSpace > this.dynamicMinWidth) {
this.dynamicMinWidth = largestWordWidth - additionalSpace + reservedSpace;
}
this.isWrapping = false;
return wrapped;
Expand Down Expand Up @@ -302,57 +384,27 @@ export class Textbox extends IText {
* @returns {Array} Array of line(s) into which the given text is wrapped
* to.
*/
_wrapLine(
_line,
lineIndex: number,
desiredWidth: number,
reservedSpace = 0
): Array<any> {
_wrapLine(data, lineIndex: number) {
const additionalSpace = this._getWidthOfCharSpacing(),
splitByGrapheme = this.splitByGrapheme,
graphemeLines = [],
words = splitByGrapheme
? this.graphemeSplit(_line)
: this.wordSplit(_line),
graphemeLines: string[][] = [],
infix = splitByGrapheme ? '' : ' ';

let lineWidth = 0,
line = [],
line: string[] = [],
// spaces in different languages?
offset = 0,
infixWidth = 0,
largestWordWidth = 0,
lineJustStarted = true;
// fix a difference between split and graphemeSplit
if (words.length === 0) {
words.push([]);
}
desiredWidth -= reservedSpace;
// measure words
const data = words.map((word) => {
// if using splitByGrapheme words are already in graphemes.
word = splitByGrapheme ? word : this.graphemeSplit(word);
const width = this._measureWord(word, lineIndex, offset);
largestWordWidth = Math.max(width, largestWordWidth);
offset += word.length + 1;
return { word: word, width: width };
});

const maxWidth = Math.max(
desiredWidth,
largestWordWidth,
this.dynamicMinWidth
);
lineJustStarted = true,
i;
// layout words
offset = 0;
let i;
for (i = 0; i < words.length; i++) {
const word = data[i].word;
for (i = 0; i < data.length; i++) {
const word = data[i].value;
const wordWidth = data[i].width;
offset += word.length;

lineWidth += infixWidth + wordWidth - additionalSpace;
if (lineWidth > maxWidth && !lineJustStarted) {
if (lineWidth > this._actualMaxWidth && !lineJustStarted) {
graphemeLines.push(line);
line = [];
lineWidth = wordWidth;
Expand All @@ -375,9 +427,6 @@ export class Textbox extends IText {

i && graphemeLines.push(line);

if (largestWordWidth + reservedSpace > this.dynamicMinWidth) {
this.dynamicMinWidth = largestWordWidth - additionalSpace + reservedSpace;
}
return graphemeLines;
}

Expand Down Expand Up @@ -420,14 +469,13 @@ export class Textbox extends IText {
*/
_splitTextIntoLines(text: string) {
const newText = super._splitTextIntoLines(text),
graphemeLines = this._wrapText(newText.lines, this.width),
lines = new Array(graphemeLines.length);
for (let i = 0; i < graphemeLines.length; i++) {
lines[i] = graphemeLines[i].join('');
}
newText.lines = lines;
newText.graphemeLines = graphemeLines;
return newText;
graphemeLines = this._wrapText(newText.lines, this.width);

return {
...newText,
graphemeLines,
lines: graphemeLines.map((value) => value.join('')),
};
}

getMinWidth() {
Expand All @@ -454,10 +502,13 @@ export class Textbox extends IText {
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
* @return {Object} object representation of an instance
*/
toObject(propertiesToInclude: Array<any>): object {
return super.toObject(
['minWidth', 'splitByGrapheme'].concat(propertiesToInclude)
);
toObject(propertiesToInclude: string[]): object {
return {
...super.toObject(
['minWidth', 'splitByGrapheme'].concat(propertiesToInclude)
),
...(this.maxWidth < Infinity ? { maxWidth: this.maxWidth } : {}),
};
}

/**
Expand All @@ -484,11 +535,15 @@ export class Textbox extends IText {
export const textboxDefaultValues: Partial<TClassProperties<Textbox>> = {
type: 'textbox',
minWidth: 20,
maxWidth: Infinity,
dynamicMinWidth: 2,
lockScalingFlip: true,
noScaleCache: false,
_dimensionAffectingProps:
textDefaultValues._dimensionAffectingProps!.concat('width'),
_dimensionAffectingProps: textDefaultValues._dimensionAffectingProps!.concat(
'width',
'minWidth',
'maxWidth'
),
_wordJoiners: /[ \t\r]/,
splitByGrapheme: false,
};
Expand Down
28 changes: 23 additions & 5 deletions test/unit/textbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,20 @@
});

QUnit.test('constructor with width too small', function(assert) {
var textbox = new fabric.Textbox('test', { width: 5 });
var textbox = new fabric.Textbox('test', { minWidth: 5, width: 5 });
assert.equal(Math.round(textbox.width), 56, 'width is calculated by constructor');
});

QUnit.test('constructor with minWidth override', function (assert) {
var textbox = new fabric.Textbox('test', { minWidth: 60, width: 5 });
assert.equal(Math.round(textbox.width), 60, 'width is taken by minWidth');
});

QUnit.test('constructor with illegal maxWidth', function (assert) {
var textbox = new fabric.Textbox('test', { maxWidth: null });
assert.equal(textbox.maxWidth, Infinity, 'maxWidth is taken by contstructor');
});

QUnit.test('initial properties', function(assert) {
var textbox = new fabric.Textbox('test');
assert.equal(textbox.text, 'test');
Expand Down Expand Up @@ -131,6 +141,12 @@
assert.deepEqual(obj.styles[1].style, TEXTBOX_OBJECT.styles[1].style, 'style properties match at second index');
});

QUnit.test('toObject with maxWidth', function (assert) {
var textbox = new fabric.Textbox('The quick \nbrown \nfox', { maxWidth: 400 });
var obj = textbox.toObject();
assert.equal(obj.maxWidth, 400, 'JSON OUTPUT MATCH');
});

QUnit.test('fromObject', function(assert) {
var done = assert.async();
fabric.Textbox.fromObject(TEXTBOX_OBJECT).then(function(textbox) {
Expand Down Expand Up @@ -322,15 +338,15 @@
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 line1 = textbox._wrapText(['xa xb xc xd xe ya yb id'], 100, 0);
var expected1 = [
['x', 'a', ' ', 'x', 'b'],
['x', 'c', ' ', 'x', 'd'],
['x', 'e', ' ', 'y', 'a'],
['y', 'b', ' ', 'i', 'd']];
assert.deepEqual(line1, expected1, 'wrapping without reserved');
assert.deepEqual(textbox.dynamicMinWidth, 40, 'wrapping without reserved');
var line2 = textbox._wrapLine('xa xb xc xd xe ya yb id', 0, 100, 50);
var line2 = textbox._wrapText(['xa xb xc xd xe ya yb id'], 100, 50);
var expected2 = [
['x', 'a'],
['x', 'b'],
Expand All @@ -347,10 +363,10 @@
var textbox = new fabric.Textbox('', {
width: 10,
});
var line1 = textbox._wrapLine('', 0, 100, 0);
var line1 = textbox._wrapText([''], 100, 0);
assert.deepEqual(line1, [[]], 'wrapping without splitByGrapheme');
textbox.splitByGrapheme = true;
var line2 = textbox._wrapLine('', 0, 100, 0);
var line2 = textbox._wrapText([''], 100, 0);
assert.deepEqual(line2, [[]], 'wrapping with splitByGrapheme');
});
QUnit.test('texbox will change width from the mr corner', function(assert) {
Expand Down Expand Up @@ -534,6 +550,7 @@
}
var textbox = new fabric.Textbox(text, {
styles: { 0: styles },
minWidth: 5,
width: 5,
});
assert.equal(typeof textbox._deleteStyleDeclaration, 'function', 'function exists');
Expand All @@ -550,6 +567,7 @@
}
var textbox = new fabric.Textbox(text, {
styles: { 0: styles },
minWidth: 5,
width: 5,
});
assert.equal(typeof textbox._setStyleDeclaration, 'function', 'function exists');
Expand Down