From d2ecf2064e0eab8a813525dfb9c6f85312148237 Mon Sep 17 00:00:00 2001 From: Anton Lavrevov Date: Sat, 9 Aug 2025 10:49:46 +0900 Subject: [PATCH] new charRenderFunc property for Konva.text, clean tests --- src/Node.ts | 2 +- src/shapes/Text.ts | 62 +++++++++++++++++++++++++++++++++- test/sandbox.html | 63 ++++++++++++++++++----------------- test/unit/Layer-test.ts | 4 +-- test/unit/MouseEvents-test.ts | 5 +-- test/unit/Node-cache-test.ts | 1 - test/unit/Node-test.ts | 14 +++----- test/unit/Path-test.ts | 2 -- test/unit/Stage-test.ts | 29 ++++++++-------- test/unit/Text-test.ts | 52 ++++++++++++++++++++--------- tsconfig.json | 2 +- 11 files changed, 155 insertions(+), 81 deletions(-) diff --git a/src/Node.ts b/src/Node.ts index 58f44384..81f75660 100644 --- a/src/Node.ts +++ b/src/Node.ts @@ -913,7 +913,7 @@ export abstract class Node { * @returns {Array} * @example * shape.getAncestors().forEach(function(node) { - * console.log(node.getId()); + * console.log(node.id()); * }) */ getAncestors() { diff --git a/src/shapes/Text.ts b/src/shapes/Text.ts index 0289f1a7..c26355d5 100644 --- a/src/shapes/Text.ts +++ b/src/shapes/Text.ts @@ -13,6 +13,18 @@ import { _registerNode } from '../Global'; 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[] { // Use Unicode-aware splitting return [...string].reduce((acc, char, index, array) => { @@ -223,6 +235,7 @@ export class Text extends Shape { align = this.align(), totalWidth = this.getWidth(), letterSpacing = this.letterSpacing(), + charRenderFunc = this.charRenderFunc(), fill = this.fill(), textDecoration = this.textDecoration(), shouldUnderline = textDecoration.indexOf('underline') !== -1, @@ -318,10 +331,14 @@ export class Text extends Shape { context.stroke(); context.restore(); } + // 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 // be supported otherwise. - if (direction !== RTL && (letterSpacing !== 0 || align === JUSTIFY)) { + if ( + direction !== RTL && + (letterSpacing !== 0 || align === JUSTIFY || charRenderFunc) + ) { // var words = text.split(' '); const spacesNumber = text.split(' ').length - 1; const array = stringToArray(text); @@ -338,7 +355,31 @@ export class Text extends Shape { this._partialTextX = lineTranslateX; this._partialTextY = translateY + lineTranslateY; 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); + if (charRenderFunc) { + context.restore(); + } lineTranslateX += this.measureSize(letter).width + letterSpacing; } } else { @@ -713,6 +754,7 @@ export class Text extends Shape { text: GetSet; wrap: GetSet; ellipsis: GetSet; + charRenderFunc: GetSet void), this>; } Text.prototype._fillFunc = _fillFunc; @@ -995,3 +1037,21 @@ Factory.addGetterSetter(Text, 'text', '', getStringValidator()); */ 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); diff --git a/test/sandbox.html b/test/sandbox.html index bf961d4d..73142948 100644 --- a/test/sandbox.html +++ b/test/sandbox.html @@ -48,37 +48,40 @@ var layer = new Konva.Layer(); stage.add(layer); - var canvas = document.createElement('canvas'); - // use external library to parse and draw gif animation - function onDrawFrame(ctx, frame) { - // update canvas size - canvas.width = frame.width; - canvas.height = frame.height; - // update canvas that we are using for Konva.Image - ctx.drawImage(frame.buffer, 0, 0); - // redraw the layer - layer.draw(); - } + const text = new Konva.Text({ + text: 'Hello, how are you doing today? Would you like to start using konva.js', + width: 400, + fontSize: 50, + x: 100, + y: 100, + draggable: true, + }); + layer.add(text); - gifler('https://konvajs.org/assets/yoda.gif').frames(canvas, onDrawFrame); - - function testKonvaImage() { - setInterval(() => { - const image = new Konva.Image({ - image: canvas, - x: Math.random() * width, - y: Math.random() * height, - }); - layer.add(image); - - setTimeout(() => { - image.image(canvas); - image.destroy(); - }, 500); - }, 10); - } - - testKonvaImage(); + const anim = new Konva.Animation((frame) => { + text.charRenderFunc(({ char, index, context }) => { + const animationDuration = 4000; + const animationTime = frame.time % animationDuration; + const length = text.text().length; + const durationPerChar = animationDuration / length; + const localStartTime = index * durationPerChar; + const localTime = animationTime - localStartTime; + const inAnimation = localTime > 0; + if (!inAnimation) { + context.setAttr('globalAlpha', 0); + return; + } + const afterAnimation = localTime > durationPerChar; + if (afterAnimation) { + return; + } + const animationAlpha = Math.abs(localTime / durationPerChar); + const oldOpacity = context.globalAlpha; + context.setAttr('globalAlpha', oldOpacity * animationAlpha * 0.5); + context.translate(0, -15 + (localTime / durationPerChar) * 15); + }); + }, layer); + anim.start(); diff --git a/test/unit/Layer-test.ts b/test/unit/Layer-test.ts index b0891d56..bf9424ce 100644 --- a/test/unit/Layer-test.ts +++ b/test/unit/Layer-test.ts @@ -171,9 +171,7 @@ describe('Layer', function () { circle.colorKey = '#000000'; - circle.on('mouseover', function () { - console.log('mouseover'); - }); + circle.on('mouseover', function () {}); layer.add(circle); stage.add(layer); diff --git a/test/unit/MouseEvents-test.ts b/test/unit/MouseEvents-test.ts index ed08d144..b2d010f2 100644 --- a/test/unit/MouseEvents-test.ts +++ b/test/unit/MouseEvents-test.ts @@ -2386,9 +2386,7 @@ describe('MouseEvents', function () { var layer = new Konva.Layer(); stage.add(layer); - stage.on('mousedown mousemove mouseup click', function (e) { - console.log('state', e.type); - }); + stage.on('mousedown mousemove mouseup click', function (e) {}); var rect = new Konva.Rect({ width: 50, @@ -2402,7 +2400,6 @@ describe('MouseEvents', function () { var clicks = 0; rect.on('click', function () { - console.log('click'); clicks += 1; if (clicks === 2) { debugger; diff --git a/test/unit/Node-cache-test.ts b/test/unit/Node-cache-test.ts index 687a1c5a..da9310d2 100644 --- a/test/unit/Node-cache-test.ts +++ b/test/unit/Node-cache-test.ts @@ -831,7 +831,6 @@ describe('Caching', function () { group.cache(); const canvas = group._cache.get('canvas').scene; - console.log(canvas.width / 2); assert.equal(canvas.width, 106 * canvas.pixelRatio); }); diff --git a/test/unit/Node-test.ts b/test/unit/Node-test.ts index 0fe1921c..b43268db 100644 --- a/test/unit/Node-test.ts +++ b/test/unit/Node-test.ts @@ -924,11 +924,11 @@ describe('Node', function () { }); circle1.on('mousemove', function () { - console.log('mousemove circle1'); + // console.log('mousemove circle1'); }); circle2.on('mousemove', function () { - console.log('mousemove circle2'); + // console.log('mousemove circle2'); }); layer1.add(circle1); @@ -1101,11 +1101,11 @@ describe('Node', function () { }); circle1.on('mousemove', function () { - console.log('mousemove circle1'); + // console.log('mousemove circle1'); }); circle2.on('mousemove', function () { - console.log('mousemove circle2'); + // console.log('mousemove circle2'); }); group.add(circle2); @@ -1887,12 +1887,6 @@ describe('Node', function () { circle.on('click', function () { clicks.push('circle'); - - /* - var evt = window.event; - var rightClick = evt.which ? evt.which == 3 : evt.button == 2; - console.log(rightClick); - */ }); var foo; circle.on('customEvent', function (evt) { diff --git a/test/unit/Path-test.ts b/test/unit/Path-test.ts index a90467ff..02608bd4 100644 --- a/test/unit/Path-test.ts +++ b/test/unit/Path-test.ts @@ -1186,7 +1186,6 @@ describe('Path', function () { SVGPath.setAttribute('d', data); for (var i = 0.001; i < path.getLength(); i += 1) { var p = path.getPointAtLength(i); - console.log(p); var circle = new Konva.Circle({ x: p.x + path.x(), y: p.y + path.y(), @@ -1196,7 +1195,6 @@ describe('Path', function () { }); layer.add(circle); const position = SVGPath.getPointAtLength(i); - console.log(position); assert(Math.abs(p.x - position.x) <= 1); assert(Math.abs(p.y - position.y) <= 1); } diff --git a/test/unit/Stage-test.ts b/test/unit/Stage-test.ts index 1a309a48..48ab5ce7 100644 --- a/test/unit/Stage-test.ts +++ b/test/unit/Stage-test.ts @@ -398,7 +398,7 @@ describe('Stage', function () { '17) getAllIntersections should return one shape' ); assert.equal( - stage.getAllIntersections({ x: 266, y: 114 })[0].getId(), + stage.getAllIntersections({ x: 266, y: 114 })[0].id(), 'greenCircle', '19) first intersection should be greenCircle' ); @@ -409,7 +409,7 @@ describe('Stage', function () { '18) getAllIntersections should return one shape' ); assert.equal( - stage.getAllIntersections({ x: 414, y: 115 })[0].getId(), + stage.getAllIntersections({ x: 414, y: 115 })[0].id(), 'redCircle', '20) first intersection should be redCircle' ); @@ -420,12 +420,12 @@ describe('Stage', function () { '1) getAllIntersections should return two shapes' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[0].id(), 'redCircle', '2) first intersection should be redCircle' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[1].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[1].id(), 'greenCircle', '3) second intersection should be greenCircle' ); @@ -440,7 +440,7 @@ describe('Stage', function () { '4) getAllIntersections should return one shape' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[0].id(), 'redCircle', '5) first intersection should be redCircle' ); @@ -455,12 +455,12 @@ describe('Stage', function () { '6) getAllIntersections should return two shapes' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[0].id(), 'redCircle', '7) first intersection should be redCircle' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[1].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[1].id(), 'greenCircle', '8) second intersection should be greenCircle' ); @@ -475,7 +475,7 @@ describe('Stage', function () { '9) getAllIntersections should return one shape' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[0].id(), 'greenCircle', '10) first intersection should be greenCircle' ); @@ -490,12 +490,12 @@ describe('Stage', function () { '11) getAllIntersections should return two shapes' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[0].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[0].id(), 'redCircle', '12) first intersection should be redCircle' ); assert.equal( - stage.getAllIntersections({ x: 350, y: 118 })[1].getId(), + stage.getAllIntersections({ x: 350, y: 118 })[1].id(), 'greenCircle', '13) second intersection should be greenCircle' ); @@ -507,12 +507,12 @@ describe('Stage', function () { '14) getAllIntersections should return two shapes' ); assert.equal( - layer.getAllIntersections({ x: 350, y: 118 })[0].getId(), + layer.getAllIntersections({ x: 350, y: 118 })[0].id(), 'redCircle', '15) first intersection should be redCircle' ); assert.equal( - layer.getAllIntersections({ x: 350, y: 118 })[1].getId(), + layer.getAllIntersections({ x: 350, y: 118 })[1].id(), 'greenCircle', '16) second intersection should be greenCircle' ); @@ -1424,7 +1424,10 @@ describe('Stage', function () { mimeType: 'image/jpeg', 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) { console.error(e); assert.fail('error creating blob'); diff --git a/test/unit/Text-test.ts b/test/unit/Text-test.ts index 3a42e346..e66ef825 100644 --- a/test/unit/Text-test.ts +++ b/test/unit/Text-test.ts @@ -215,7 +215,7 @@ describe('Text', function () { var canvas = createCanvas(); var context = canvas.getContext('2d'); context.textBaseline = 'middle'; - context.letterSpacing = '10px'; + (context as any).letterSpacing = '10px'; context.font = 'normal normal 50px Arial'; context.fillStyle = 'darkgrey'; context.fillText('आपकी दौड़ के लिए परफेक्ट जूते!', 10, 10 + 25); @@ -1625,14 +1625,14 @@ describe('Text', function () { ctx.textBaseline = 'middle'; var grd = ctx.createPattern(imageObj, 'repeat'); - grd.setTransform({ + (grd as any).setTransform({ a: 1, b: 0, c: 0, d: 1, e: -50, f: 0, - }); + } as any); ctx.fillStyle = grd; ctx.fillText(text.text(), 0, 15); @@ -1668,19 +1668,13 @@ describe('Text', function () { ctx.textBaseline = 'middle'; var grd = ctx.createPattern(imageObj, 'repeat'); - const matrix = - typeof DOMMatrix === 'undefined' - ? { - a: 0.5, // Horizontal scaling. A value of 1 results in no scaling. - b: 0, // Vertical skewing. - 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]); + // node-canvas expects its own DOMMatrix type; cast to any for cross-env test + const matrix: any = + typeof (global as any).DOMMatrix === 'undefined' + ? { a: 0.5, b: 0, c: 0, d: 0.5, e: 0, f: 0 } + : new (global as any).DOMMatrix([0.5, 0, 0, 0.5, 0, 0]); - grd.setTransform(matrix); + (grd as any).setTransform(matrix); ctx.fillStyle = grd; @@ -1791,4 +1785,32 @@ describe('Text', function () { 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();' + ); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 0312b5cc..f93cf106 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "outDir": "lib", - "module": "CommonJS", + "module": "ESNext", "target": "ES2018", // "sourceMap": true, "noEmitOnError": true,