Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Textbox): min/max width #8470

Closed
wants to merge 17 commits into from
Closed
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)
- 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)
Expand Down
184 changes: 120 additions & 64 deletions src/shapes/textbox.class.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// @ts-nocheck
import { TClassProperties } from '../typedefs';
import { classRegistry } from '../util/class_registry';
import { capValue } from '../util/misc/capValue';
import { IText } from './itext.class';
import { textDefaultValues } from './text.class';
import { classRegistry } from '../util/class_registry';

/**
* Textbox class, based on IText, allows the user to resize the text rectangle
Expand All @@ -12,7 +13,9 @@ import { classRegistry } from '../util/class_registry';
*/
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 @@ -27,6 +30,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 @@ -41,6 +59,33 @@ 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_ */
return this;
}

/**
* Unlike superclass's version of this function, Textbox does not update
* its width.
Expand All @@ -56,12 +101,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 @@ -240,11 +284,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 @@ -299,57 +382,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 @@ -372,9 +425,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 @@ -417,14 +467,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 @@ -451,21 +500,28 @@ 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 } : {}),
};
}
}

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