new charRenderFunc property for Konva.text, clean tests

This commit is contained in:
Anton Lavrevov 2025-08-09 10:49:46 +09:00
parent 464432a2a5
commit d2ecf2064e
11 changed files with 155 additions and 81 deletions

View File

@ -913,7 +913,7 @@ export abstract class Node<Config extends NodeConfig = NodeConfig> {
* @returns {Array} * @returns {Array}
* @example * @example
* shape.getAncestors().forEach(function(node) { * shape.getAncestors().forEach(function(node) {
* console.log(node.getId()); * console.log(node.id());
* }) * })
*/ */
getAncestors() { getAncestors() {

View File

@ -13,6 +13,18 @@ import { _registerNode } from '../Global';
import { GetSet } from '../types'; import { GetSet } from '../types';
export interface CharRenderProps {
char: string;
index: number;
x: number;
y: number;
lineIndex: number;
column: number;
isLastInLine: boolean;
width: number;
context: Context;
}
export function stringToArray(string: string): string[] { export function stringToArray(string: string): string[] {
// Use Unicode-aware splitting // Use Unicode-aware splitting
return [...string].reduce((acc, char, index, array) => { return [...string].reduce((acc, char, index, array) => {
@ -223,6 +235,7 @@ export class Text extends Shape<TextConfig> {
align = this.align(), align = this.align(),
totalWidth = this.getWidth(), totalWidth = this.getWidth(),
letterSpacing = this.letterSpacing(), letterSpacing = this.letterSpacing(),
charRenderFunc = this.charRenderFunc(),
fill = this.fill(), fill = this.fill(),
textDecoration = this.textDecoration(), textDecoration = this.textDecoration(),
shouldUnderline = textDecoration.indexOf('underline') !== -1, shouldUnderline = textDecoration.indexOf('underline') !== -1,
@ -318,10 +331,14 @@ export class Text extends Shape<TextConfig> {
context.stroke(); context.stroke();
context.restore(); context.restore();
} }
// As `letterSpacing` isn't supported on Safari, we use this polyfill. // As `letterSpacing` isn't supported on Safari, we use this polyfill.
// The exception is for RTL text, which we rely on native as it cannot // The exception is for RTL text, which we rely on native as it cannot
// be supported otherwise. // be supported otherwise.
if (direction !== RTL && (letterSpacing !== 0 || align === JUSTIFY)) { if (
direction !== RTL &&
(letterSpacing !== 0 || align === JUSTIFY || charRenderFunc)
) {
// var words = text.split(' '); // var words = text.split(' ');
const spacesNumber = text.split(' ').length - 1; const spacesNumber = text.split(' ').length - 1;
const array = stringToArray(text); const array = stringToArray(text);
@ -338,7 +355,31 @@ export class Text extends Shape<TextConfig> {
this._partialTextX = lineTranslateX; this._partialTextX = lineTranslateX;
this._partialTextY = translateY + lineTranslateY; this._partialTextY = translateY + lineTranslateY;
this._partialText = letter; this._partialText = letter;
if (charRenderFunc) {
context.save();
const previousLines = textArr.slice(0, n);
const previousGraphemes = previousLines.reduce(
(acc, line) => acc + stringToArray(line.text).length,
0
);
const charIndex = li + previousGraphemes;
charRenderFunc({
char: letter,
index: charIndex,
x: lineTranslateX,
y: translateY + lineTranslateY,
lineIndex: n,
column: li,
isLastInLine: lastLine,
width: this.measureSize(letter).width,
context,
});
}
context.fillStrokeShape(this); context.fillStrokeShape(this);
if (charRenderFunc) {
context.restore();
}
lineTranslateX += this.measureSize(letter).width + letterSpacing; lineTranslateX += this.measureSize(letter).width + letterSpacing;
} }
} else { } else {
@ -713,6 +754,7 @@ export class Text extends Shape<TextConfig> {
text: GetSet<string, this>; text: GetSet<string, this>;
wrap: GetSet<string, this>; wrap: GetSet<string, this>;
ellipsis: GetSet<boolean, this>; ellipsis: GetSet<boolean, this>;
charRenderFunc: GetSet<null | ((props: CharRenderProps) => void), this>;
} }
Text.prototype._fillFunc = _fillFunc; Text.prototype._fillFunc = _fillFunc;
@ -995,3 +1037,21 @@ Factory.addGetterSetter(Text, 'text', '', getStringValidator());
*/ */
Factory.addGetterSetter(Text, 'textDecoration', ''); Factory.addGetterSetter(Text, 'textDecoration', '');
/**
* get/set per-character render hook. The callback is invoked for each grapheme before drawing.
* It can mutate the provided context (e.g. translate, rotate, change styles) and should return void.
* Note: per-character rendering may disable native kerning/ligatures.
* @name Konva.Text#charRenderFunc
* @method
* @param {(props: {char: string, index: number, x: number, y: number, lineIndex: number, column: number, isLastInLine: boolean, width: number, context: Konva.Context}) => void} charRenderFunc
* @returns {(props: CharRenderProps) => void}
* @example
* // apply small x-translation to every second character
* text.charRenderFunc(function(props) {
* if (props.index % 2 === 1) {
* props.context.translate(2, 0);
* }
* });
*/
Factory.addGetterSetter(Text, 'charRenderFunc', undefined);

View File

@ -48,37 +48,40 @@
var layer = new Konva.Layer(); var layer = new Konva.Layer();
stage.add(layer); stage.add(layer);
var canvas = document.createElement('canvas'); const text = new Konva.Text({
// use external library to parse and draw gif animation text: 'Hello, how are you doing today? Would you like to start using konva.js',
function onDrawFrame(ctx, frame) { width: 400,
// update canvas size fontSize: 50,
canvas.width = frame.width; x: 100,
canvas.height = frame.height; y: 100,
// update canvas that we are using for Konva.Image draggable: true,
ctx.drawImage(frame.buffer, 0, 0); });
// redraw the layer layer.add(text);
layer.draw();
}
gifler('https://konvajs.org/assets/yoda.gif').frames(canvas, onDrawFrame); const anim = new Konva.Animation((frame) => {
text.charRenderFunc(({ char, index, context }) => {
function testKonvaImage() { const animationDuration = 4000;
setInterval(() => { const animationTime = frame.time % animationDuration;
const image = new Konva.Image({ const length = text.text().length;
image: canvas, const durationPerChar = animationDuration / length;
x: Math.random() * width, const localStartTime = index * durationPerChar;
y: Math.random() * height, const localTime = animationTime - localStartTime;
}); const inAnimation = localTime > 0;
layer.add(image); if (!inAnimation) {
context.setAttr('globalAlpha', 0);
setTimeout(() => { return;
image.image(canvas); }
image.destroy(); const afterAnimation = localTime > durationPerChar;
}, 500); if (afterAnimation) {
}, 10); return;
} }
const animationAlpha = Math.abs(localTime / durationPerChar);
testKonvaImage(); const oldOpacity = context.globalAlpha;
context.setAttr('globalAlpha', oldOpacity * animationAlpha * 0.5);
context.translate(0, -15 + (localTime / durationPerChar) * 15);
});
}, layer);
anim.start();
</script> </script>
</body> </body>
</html> </html>

View File

@ -171,9 +171,7 @@ describe('Layer', function () {
circle.colorKey = '#000000'; circle.colorKey = '#000000';
circle.on('mouseover', function () { circle.on('mouseover', function () {});
console.log('mouseover');
});
layer.add(circle); layer.add(circle);
stage.add(layer); stage.add(layer);

View File

@ -2386,9 +2386,7 @@ describe('MouseEvents', function () {
var layer = new Konva.Layer(); var layer = new Konva.Layer();
stage.add(layer); stage.add(layer);
stage.on('mousedown mousemove mouseup click', function (e) { stage.on('mousedown mousemove mouseup click', function (e) {});
console.log('state', e.type);
});
var rect = new Konva.Rect({ var rect = new Konva.Rect({
width: 50, width: 50,
@ -2402,7 +2400,6 @@ describe('MouseEvents', function () {
var clicks = 0; var clicks = 0;
rect.on('click', function () { rect.on('click', function () {
console.log('click');
clicks += 1; clicks += 1;
if (clicks === 2) { if (clicks === 2) {
debugger; debugger;

View File

@ -831,7 +831,6 @@ describe('Caching', function () {
group.cache(); group.cache();
const canvas = group._cache.get('canvas').scene; const canvas = group._cache.get('canvas').scene;
console.log(canvas.width / 2);
assert.equal(canvas.width, 106 * canvas.pixelRatio); assert.equal(canvas.width, 106 * canvas.pixelRatio);
}); });

View File

@ -924,11 +924,11 @@ describe('Node', function () {
}); });
circle1.on('mousemove', function () { circle1.on('mousemove', function () {
console.log('mousemove circle1'); // console.log('mousemove circle1');
}); });
circle2.on('mousemove', function () { circle2.on('mousemove', function () {
console.log('mousemove circle2'); // console.log('mousemove circle2');
}); });
layer1.add(circle1); layer1.add(circle1);
@ -1101,11 +1101,11 @@ describe('Node', function () {
}); });
circle1.on('mousemove', function () { circle1.on('mousemove', function () {
console.log('mousemove circle1'); // console.log('mousemove circle1');
}); });
circle2.on('mousemove', function () { circle2.on('mousemove', function () {
console.log('mousemove circle2'); // console.log('mousemove circle2');
}); });
group.add(circle2); group.add(circle2);
@ -1887,12 +1887,6 @@ describe('Node', function () {
circle.on('click', function () { circle.on('click', function () {
clicks.push('circle'); clicks.push('circle');
/*
var evt = window.event;
var rightClick = evt.which ? evt.which == 3 : evt.button == 2;
console.log(rightClick);
*/
}); });
var foo; var foo;
circle.on('customEvent', function (evt) { circle.on('customEvent', function (evt) {

View File

@ -1186,7 +1186,6 @@ describe('Path', function () {
SVGPath.setAttribute('d', data); SVGPath.setAttribute('d', data);
for (var i = 0.001; i < path.getLength(); i += 1) { for (var i = 0.001; i < path.getLength(); i += 1) {
var p = path.getPointAtLength(i); var p = path.getPointAtLength(i);
console.log(p);
var circle = new Konva.Circle({ var circle = new Konva.Circle({
x: p.x + path.x(), x: p.x + path.x(),
y: p.y + path.y(), y: p.y + path.y(),
@ -1196,7 +1195,6 @@ describe('Path', function () {
}); });
layer.add(circle); layer.add(circle);
const position = SVGPath.getPointAtLength(i); const position = SVGPath.getPointAtLength(i);
console.log(position);
assert(Math.abs(p.x - position.x) <= 1); assert(Math.abs(p.x - position.x) <= 1);
assert(Math.abs(p.y - position.y) <= 1); assert(Math.abs(p.y - position.y) <= 1);
} }

View File

@ -398,7 +398,7 @@ describe('Stage', function () {
'17) getAllIntersections should return one shape' '17) getAllIntersections should return one shape'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 266, y: 114 })[0].getId(), stage.getAllIntersections({ x: 266, y: 114 })[0].id(),
'greenCircle', 'greenCircle',
'19) first intersection should be greenCircle' '19) first intersection should be greenCircle'
); );
@ -409,7 +409,7 @@ describe('Stage', function () {
'18) getAllIntersections should return one shape' '18) getAllIntersections should return one shape'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 414, y: 115 })[0].getId(), stage.getAllIntersections({ x: 414, y: 115 })[0].id(),
'redCircle', 'redCircle',
'20) first intersection should be redCircle' '20) first intersection should be redCircle'
); );
@ -420,12 +420,12 @@ describe('Stage', function () {
'1) getAllIntersections should return two shapes' '1) getAllIntersections should return two shapes'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), stage.getAllIntersections({ x: 350, y: 118 })[0].id(),
'redCircle', 'redCircle',
'2) first intersection should be redCircle' '2) first intersection should be redCircle'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[1].getId(), stage.getAllIntersections({ x: 350, y: 118 })[1].id(),
'greenCircle', 'greenCircle',
'3) second intersection should be greenCircle' '3) second intersection should be greenCircle'
); );
@ -440,7 +440,7 @@ describe('Stage', function () {
'4) getAllIntersections should return one shape' '4) getAllIntersections should return one shape'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), stage.getAllIntersections({ x: 350, y: 118 })[0].id(),
'redCircle', 'redCircle',
'5) first intersection should be redCircle' '5) first intersection should be redCircle'
); );
@ -455,12 +455,12 @@ describe('Stage', function () {
'6) getAllIntersections should return two shapes' '6) getAllIntersections should return two shapes'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), stage.getAllIntersections({ x: 350, y: 118 })[0].id(),
'redCircle', 'redCircle',
'7) first intersection should be redCircle' '7) first intersection should be redCircle'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[1].getId(), stage.getAllIntersections({ x: 350, y: 118 })[1].id(),
'greenCircle', 'greenCircle',
'8) second intersection should be greenCircle' '8) second intersection should be greenCircle'
); );
@ -475,7 +475,7 @@ describe('Stage', function () {
'9) getAllIntersections should return one shape' '9) getAllIntersections should return one shape'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), stage.getAllIntersections({ x: 350, y: 118 })[0].id(),
'greenCircle', 'greenCircle',
'10) first intersection should be greenCircle' '10) first intersection should be greenCircle'
); );
@ -490,12 +490,12 @@ describe('Stage', function () {
'11) getAllIntersections should return two shapes' '11) getAllIntersections should return two shapes'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), stage.getAllIntersections({ x: 350, y: 118 })[0].id(),
'redCircle', 'redCircle',
'12) first intersection should be redCircle' '12) first intersection should be redCircle'
); );
assert.equal( assert.equal(
stage.getAllIntersections({ x: 350, y: 118 })[1].getId(), stage.getAllIntersections({ x: 350, y: 118 })[1].id(),
'greenCircle', 'greenCircle',
'13) second intersection should be greenCircle' '13) second intersection should be greenCircle'
); );
@ -507,12 +507,12 @@ describe('Stage', function () {
'14) getAllIntersections should return two shapes' '14) getAllIntersections should return two shapes'
); );
assert.equal( assert.equal(
layer.getAllIntersections({ x: 350, y: 118 })[0].getId(), layer.getAllIntersections({ x: 350, y: 118 })[0].id(),
'redCircle', 'redCircle',
'15) first intersection should be redCircle' '15) first intersection should be redCircle'
); );
assert.equal( assert.equal(
layer.getAllIntersections({ x: 350, y: 118 })[1].getId(), layer.getAllIntersections({ x: 350, y: 118 })[1].id(),
'greenCircle', 'greenCircle',
'16) second intersection should be greenCircle' '16) second intersection should be greenCircle'
); );
@ -1424,7 +1424,10 @@ describe('Stage', function () {
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
quality: 0.5, quality: 0.5,
}); });
assert.isTrue(blob instanceof Blob && blob.type === 'image/jpeg', "can't change type of blob"); assert.isTrue(
blob instanceof Blob && blob.type === 'image/jpeg',
"can't change type of blob"
);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
assert.fail('error creating blob'); assert.fail('error creating blob');

View File

@ -215,7 +215,7 @@ describe('Text', function () {
var canvas = createCanvas(); var canvas = createCanvas();
var context = canvas.getContext('2d'); var context = canvas.getContext('2d');
context.textBaseline = 'middle'; context.textBaseline = 'middle';
context.letterSpacing = '10px'; (context as any).letterSpacing = '10px';
context.font = 'normal normal 50px Arial'; context.font = 'normal normal 50px Arial';
context.fillStyle = 'darkgrey'; context.fillStyle = 'darkgrey';
context.fillText('आपकी दौड़ के लिए परफेक्ट जूते!', 10, 10 + 25); context.fillText('आपकी दौड़ के लिए परफेक्ट जूते!', 10, 10 + 25);
@ -1625,14 +1625,14 @@ describe('Text', function () {
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
var grd = ctx.createPattern(imageObj, 'repeat'); var grd = ctx.createPattern(imageObj, 'repeat');
grd.setTransform({ (grd as any).setTransform({
a: 1, a: 1,
b: 0, b: 0,
c: 0, c: 0,
d: 1, d: 1,
e: -50, e: -50,
f: 0, f: 0,
}); } as any);
ctx.fillStyle = grd; ctx.fillStyle = grd;
ctx.fillText(text.text(), 0, 15); ctx.fillText(text.text(), 0, 15);
@ -1668,19 +1668,13 @@ describe('Text', function () {
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
var grd = ctx.createPattern(imageObj, 'repeat'); var grd = ctx.createPattern(imageObj, 'repeat');
const matrix = // node-canvas expects its own DOMMatrix type; cast to any for cross-env test
typeof DOMMatrix === 'undefined' const matrix: any =
? { typeof (global as any).DOMMatrix === 'undefined'
a: 0.5, // Horizontal scaling. A value of 1 results in no scaling. ? { a: 0.5, b: 0, c: 0, d: 0.5, e: 0, f: 0 }
b: 0, // Vertical skewing. : new (global as any).DOMMatrix([0.5, 0, 0, 0.5, 0, 0]);
c: 0, // Horizontal skewing.
d: 0.5,
e: 0, // Horizontal translation (moving).
f: 0, // Vertical translation (moving).
}
: new DOMMatrix([0.5, 0, 0, 0.5, 0, 0]);
grd.setTransform(matrix); (grd as any).setTransform(matrix);
ctx.fillStyle = grd; ctx.fillStyle = grd;
@ -1791,4 +1785,32 @@ describe('Text', function () {
assert.equal(layer.getContext().getTrace(), trace); assert.equal(layer.getContext().getTrace(), trace);
}); });
it('charRenderFunc draws per character and can mutate context', function () {
var stage = addStage();
var layer = new Konva.Layer();
stage.add(layer);
var text = new Konva.Text({
x: 10,
y: 10,
text: 'AB',
fontSize: 20,
charRenderFunc: function (props) {
if (props.index === 1) {
// shift only the second character
props.context.translate(0, 10);
}
},
});
layer.add(text);
layer.draw();
var trace = layer.getContext().getTrace();
assert.equal(
trace,
'clearRect(0,0,578,200);clearRect(0,0,578,200);save();transform(1,0,0,1,10,10);font=normal normal 20px Arial;textBaseline=middle;textAlign=left;translate(0,0);save();save();fillStyle=black;fillText(A,0,10);restore();save();translate(0,10);fillStyle=black;fillText(B,13.34,10);restore();restore();restore();'
);
});
}); });

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "lib", "outDir": "lib",
"module": "CommonJS", "module": "ESNext",
"target": "ES2018", "target": "ES2018",
// "sourceMap": true, // "sourceMap": true,
"noEmitOnError": true, "noEmitOnError": true,