Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ebc41be
Update Textbox.ts
ShaMan123 Jun 6, 2023
109264c
renaming + typing
ShaMan123 Jun 6, 2023
08a88b3
consolidate measuring in a single pass
ShaMan123 Jun 6, 2023
037a390
more cleanup
ShaMan123 Jun 6, 2023
04bdd29
readable
ShaMan123 Jun 7, 2023
ccac2e1
cleaner
ShaMan123 Jun 7, 2023
d16cff8
more readable edge case - early return
ShaMan123 Jun 7, 2023
2eacd5a
finalize
ShaMan123 Jun 7, 2023
f54fde4
update CHANGELOG.md
github-actions[bot] Jun 7, 2023
df1b784
extract measuring logic to a method `measureLineForWrapping`
ShaMan123 Jun 7, 2023
36baea0
clear infix logic
ShaMan123 Jun 7, 2023
4918050
rename `largestWordWidth`
ShaMan123 Jun 7, 2023
55b6598
Merge branch 'more-wrap-line-cleanup' of https://github.com/fabricjs/…
ShaMan123 Jun 7, 2023
c367e1d
finalize
ShaMan123 Jun 7, 2023
b574768
final comments
ShaMan123 Jun 7, 2023
76eef16
apply review comment
ShaMan123 Jun 7, 2023
1e800f0
Merge branch 'master' into more-wrap-line-cleanup
asturur Jun 7, 2023
2a65e6e
BREAKING: return more data from `_measureWord`
ShaMan123 Jun 8, 2023
fbf25f5
Update Textbox.ts
ShaMan123 Jun 8, 2023
d36d641
update CHANGELOG.md
github-actions[bot] Jun 8, 2023
7d54580
measure at the correct time
ShaMan123 Jun 8, 2023
7361cb9
Merge branch 'more-wrap-line-cleanup' of https://github.com/fabricjs/…
ShaMan123 Jun 8, 2023
8536cb6
rename keys
ShaMan123 Jun 8, 2023
d089a89
Merge branch 'master' into more-wrap-line-cleanup
ShaMan123 Jun 12, 2023
4df61e5
Merge branch 'master' into more-wrap-line-cleanup
ShaMan123 Jun 14, 2023
549d52f
Update CHANGELOG.md
ShaMan123 Jun 14, 2023
e4fc78a
Merge branch 'master' into more-wrap-line-cleanup
ShaMan123 Jun 16, 2023
4fbb46a
Update package-lock.json
ShaMan123 Jun 16, 2023
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]

- fix(Textbox): minWidth of text, measure lines before wrapping [#8994](https://github.com/fabricjs/fabric.js/pull/8994)
- ci(): Revert "invoke tests after changelog action (#8974)" [#9013](https://github.com/fabricjs/fabric.js/pull/9013)
- fix(IText): empty line selection [#9019](https://github.com/fabricjs/fabric.js/pull/9019)
- ci(): Added playwright testing [#8616](https://github.com/fabricjs/fabric.js/pull/8616)
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

222 changes: 139 additions & 83 deletions src/shapes/Textbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { IText } from './IText/IText';
import { classRegistry } from '../ClassRegistry';
import { createTextboxDefaultControls } from '../controls/commonControls';
import { JUSTIFY } from './Text/constants';
import type { GraphemeBBox } from './Text/Text';

// @TODO: Many things here are configuration related and shouldn't be on the class nor prototype
// regexes, list of properties that are not suppose to change by instances, magic consts.
// this will be a separated effort
Expand Down Expand Up @@ -258,11 +260,26 @@ 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, reservedSpace = 0) {
const wrapped: string[][] = [];
this.isWrapping = true;
const { lines: data, minWidth } = this.measureLinesForWrapping(lines);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We should cache this and recalc only when text/styles change.
This will make resizing a lot faster

const additionalSpace = this._getWidthOfCharSpacing();
const maxWidth = Math.max(
desiredWidth - reservedSpace,
minWidth,
this.dynamicMinWidth
);
if (minWidth + reservedSpace > this.dynamicMinWidth) {
this.dynamicMinWidth = minWidth - additionalSpace + reservedSpace;
}
Comment on lines +273 to +275

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this part I don't understand.
When I do I can make this and maxWidth a bit more tidy and add some comments

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if i remember correctly maxWidth is the largest space we have at our disposal.
The larges between what we want the texbox to be, or the size of the smallest word.

for (let i = 0; i < lines.length; i++) {
wrapped.push(...this._wrapLine(lines[i], i, desiredWidth));
wrapped.push(
...this._wrapLine(
{ lines: data, minWidth, maxWidth, additionalSpace },
i
)
);
}
this.isWrapping = false;
return wrapped;
Expand All @@ -278,24 +295,28 @@ export class Textbox extends IText {
* @param {String} text
* @param {number} lineIndex
* @param {number} charOffset
* @returns {number}
*/
_measureWord(word, lineIndex: number, charOffset = 0): number {
_measureWord(word: string | string[], lineIndex: number, charOffset = 0) {
let width = 0,
prevGrapheme;
const skipLeft = true;
for (let i = 0, len = word.length; i < len; i++) {
height = 0;
const data: GraphemeBBox<false>[] = [];
for (let i = 0, prevGrapheme: string | undefined; i < word.length; i++) {
const box = this._getGraphemeBox(
word[i],
lineIndex,
i + charOffset,
prevGrapheme,
skipLeft
true
);
// TODO: support vertical text
width += box.kernedWidth;
height = Math.max(height, box.height);

data.push(box);
prevGrapheme = word[i];
}
return width;

return { width, height, data };
}

/**
Expand All @@ -308,6 +329,77 @@ export class Textbox extends IText {
return value.split(this._wordJoiners);
}

measureLinesForWrapping(lines: string[]) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't like the naming

let min = 0;
const data = lines.map((line, lineIndex) => {
const parts = this.splitByGrapheme
? this.graphemeSplit(line)
: this.wordSplit(line);

if (parts.length === 0) {
return { parts: [], minWidth: 0 };
}

const { data, minWidth } = parts.reduce(
({ data, offset, minWidth }, part, index) => {
let infixWidth = 0;
let infix: string | null = null;
if (index > 0 && !this.splitByGrapheme) {
infix = ' ';
// measure infix at offset to respect styling etc.
infixWidth = this._measureWord(infix, lineIndex, offset).width;
// move cursor after infix
offset += infix.length;
}
// split words if necessary
const graphemes = !this.splitByGrapheme
? this.graphemeSplit(part)
: // we must use concat for compatibility
[].concat(part);
// measure
const {
width,
height,
data: d,
} = this._measureWord(graphemes, lineIndex, offset);
data.push({
graphemes,
offset,
width,
height,
data: d,
infix,
infixWidth,
});
return {
offset: offset + graphemes.length,
data,
minWidth: Math.max(width, minWidth),
};
},
{
offset: 0,
data: [] as {
graphemes: string[];
offset: number;
width: number;
height: number;
data: GraphemeBBox<false>[];
/**
* space to insert before `graphemes`
*/
infix: string | null;
infixWidth: number;
}[],
minWidth: 0,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

not sure this is useful

}
);
min = Math.max(min, minWidth);
return { parts: data, minWidth };
});
return { lines: data, minWidth: min };
}

/**
* Wraps a line of text using the width of the Textbox and a context.
* @param {Array} line The grapheme array that represent the line
Expand All @@ -316,84 +408,48 @@ export class Textbox extends IText {
* @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.
*
* @todo do we need to account for different spacing/offsetting in different languages?
*/
_wrapLine(
_line,
lineIndex: number,
desiredWidth: number,
reservedSpace = 0
): Array<any> {
const additionalSpace = this._getWidthOfCharSpacing(),
splitByGrapheme = this.splitByGrapheme,
graphemeLines = [],
words = splitByGrapheme
? this.graphemeSplit(_line)
: this.wordSplit(_line),
infix = splitByGrapheme ? '' : ' ';

let lineWidth = 0,
line = [],
// 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 + infix.length;
return { word, width };
});

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

lineWidth += infixWidth + wordWidth - additionalSpace;
if (lineWidth > maxWidth && !lineJustStarted) {
graphemeLines.push(line);
line = [];
lineWidth = wordWidth;
lineJustStarted = true;
} else {
lineWidth += additionalSpace;
}

if (!lineJustStarted && !splitByGrapheme) {
line.push(infix);
}
line = line.concat(word);
{
lines,
additionalSpace,
maxWidth: desiredWidth,
}: ReturnType<this['measureLinesForWrapping']> & {
maxWidth: number;
additionalSpace: number;
},
lineIndex: number
) {
const { parts: lineData } = lines[lineIndex];

infixWidth = splitByGrapheme
? 0
: this._measureWord([infix], lineIndex, offset);
offset++;
lineJustStarted = false;
// fix a difference between split and graphemeSplit
if (!lineData.length) {
return [[]];
}

i && graphemeLines.push(line);

if (largestWordWidth + reservedSpace > this.dynamicMinWidth) {
this.dynamicMinWidth = largestWordWidth - additionalSpace + reservedSpace;
}
return graphemeLines;
// layout
return lineData.reduce(
({ lines, lineWidth }, { graphemes, infix, infixWidth, width }, i) => {
// `i === 0` => `infixWidth === 0`
const lineWidthAfter = lineWidth + infixWidth + width;
if (i === 0 || lineWidthAfter - additionalSpace > desiredWidth) {
// push a new line, we spread to protect `data` from mutation
lines.push([...graphemes]);
lineWidth = width;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
lineWidth = width;
lineWidth = infixWidth + width;

If someone decides to override measureLineForWrapping and return an infix we should respect it.

something like:

some words some words some words some words 
some words some words ....
    a line with an infix at the start

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

and then we should push the infix if it exists

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what do you mean? an infix in this case is just something between parts of text. and is either nothing or a space right now.

measureLines shouldn't change the lines imho, if someone does that is going on its own road with no guarantee of support

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, let's abandon this idea.

measureLines shouldn't change the lines imho, if someone does that is going on its own road with no guarantee of support

I don't understand this

} else {
const currentLine = lines[lines.length - 1];
// push infix if necessary, `i === 0 || splitByGrapheme` => `infix === null`
infix && currentLine.push(infix);
// push graphemes
currentLine.push(...graphemes);
lineWidth = lineWidthAfter;
}
return { lines, lineWidth };
},
{ lines: [] as string[][], lineWidth: 0 }
).lines;
}

/**
Expand Down
20 changes: 16 additions & 4 deletions test/unit/textbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,15 +381,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 @@ -406,12 +406,24 @@
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('wrapping respects max line width', function (assert) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

broken on master!

const a = 'xaxbxc xdxeyaybid xaxbxc';
const b = 'xaxbxcxdxeyaybidxaxbxcxdxeyaybid';
[true, false].forEach(order => {
[true, false].forEach(space => {
const ordered = order ? [a, b] : [b, a];
const text = ordered.join(space ? ' ' : '\n');
const { _textLines: lines } = new fabric.Textbox(text);
assert.deepEqual(lines, ordered.map(line => line.split('')), `max line width should be respected for ${text}`);
});
});
});
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 });
canvas.add(text);
Expand Down