Compare commits

...

14 Commits

Author SHA1 Message Date
Valentin Touffet
7dcaf96742
Merge 65c06c6fbc into 7b9ccd18ba 2025-08-11 07:42:03 -05:00
Anton Lavrenov
7b9ccd18ba
Merge pull request #1940 from Caden-Hornyak/feature/regular-polygon-corner-radius
Some checks failed
Test Browser / build (20.x) (push) Has been cancelled
Test NodeJS / build (23.x) (push) Has been cancelled
Add corner radius for Regular Polygon
2025-08-11 06:54:12 -05:00
Anton Lavrenov
553245c074
Merge pull request #1950 from Caden-Hornyak/fix/corner-radius-negative-dimensions
Fixed corner radius for Konva.Rect negative width/height
2025-08-11 06:38:15 -05:00
Anton Lavrevov
e33341d3f6 refactor 2025-08-11 20:34:32 +09:00
Anton Lavrevov
8f22d97937 fix types in tests, add skia backend 2025-08-10 22:10:55 +09:00
Anton Lavrevov
d2ecf2064e new charRenderFunc property for Konva.text, clean tests 2025-08-09 10:49:46 +09:00
Caden Hornyak
c398aedb0a
Merge branch 'konvajs:master' into feature/regular-polygon-corner-radius 2025-07-23 12:44:47 -07:00
Caden Hornyak
33e5ddf4ae
Merge branch 'konvajs:master' into fix/corner-radius-negative-dimensions 2025-07-23 12:44:20 -07:00
CadenH
18956ae62b Fixed negative polygon radius corner radius bug 2025-07-23 12:39:32 -07:00
CadenH
eb70aaf7d2 fixed negative width/height corner radius 2025-07-23 11:32:25 -07:00
CadenH
1adf506a93 changed const to var 2025-06-30 16:08:17 -07:00
CadenH
089766c7ae fixed bugged test 2025-06-30 13:10:28 -07:00
CadenH
5155a240a3 add corner radius for regular polygon 2025-06-30 01:24:21 -07:00
Valentin Touffet
65c06c6fbc
feat: implement multi-styled text shape 2021-07-02 13:53:44 +02:00
43 changed files with 1297 additions and 354 deletions

View File

@ -1,3 +0,0 @@
import tseslint from 'typescript-eslint';
export default tseslint.config(...tseslint.configs.recommended);

View File

@ -1,77 +0,0 @@
import fs from 'fs';
// relative path here
// but you will need just require('konva-node');
import Konva from '../';
// Create stage. Container parameter is not required in NodeJS.
var stage = new Konva.Stage({
width: 100,
height: 100,
});
var layer = new Konva.Layer();
stage.add(layer);
var rect = new Konva.Rect({
width: 100,
height: 100,
x: 50,
y: 50,
fill: 'white',
});
var text = new Konva.Text({
text: 'Generated inside node js',
x: 20,
y: 20,
fill: 'black',
});
layer.add(rect).add(text);
layer.draw();
stage.setSize({
width: 200,
height: 200,
});
// check tween works
var tween = new Konva.Tween({
node: rect,
duration: 1,
x: -50,
});
tween.play();
// After tween we want to convert stage to dataURL
setTimeout(function () {
stage.toDataURL({
callback: function (data) {
// Then add result to stage
var img = new Konva.window.Image();
img.onload = function () {
var image = new Konva.Image({
image: img,
x: 10,
y: 50,
});
layer.add(image);
layer.draw();
// save stage image as file
stage.toDataURL({
callback: function (data) {
var base64Data = data.replace(/^data:image\/png;base64,/, '');
fs.writeFile('./out.png', base64Data, 'base64', function (err) {
err && console.log(err);
console.log('See out.png');
});
// now try to create image from url
Konva.Image.fromURL(data, () => {
console.log('image loaded');
// shoul'd throw
});
},
});
};
img.src = data;
},
});
}, 1050);

View File

@ -1,39 +0,0 @@
var Konva = require('konva');
var canvas = require('canvas');
// mock window
Konva.window = {
Image: canvas.Image,
devicePixelRatio: 1,
};
// mock document
Konva.document = {
createElement: function () {},
documentElement: {
addEventListener: function () {},
},
};
// make some global injections
global.requestAnimationFrame = (cb) => {
setImmediate(cb);
};
// create canvas in Node env
Konva.Util.createCanvasElement = () => {
const node = new canvas.Canvas();
node.style = {};
return node;
};
// create image in Node env
Konva.Util.createImageElement = () => {
const node = new canvas.Image();
node.style = {};
return node;
};
// _checkVisibility use dom element, in node we can skip it
Konva.Stage.prototype._checkVisibility = () => {};
module.exports = Konva;

View File

@ -1,32 +0,0 @@
{
"name": "konva-node",
"version": "0.11.2",
"description": "Konva framework for NodeJS env",
"main": "index.js",
"files": [
"index.js"
],
"type": "module",
"typings": "./node_modules/konva/konva.d.ts",
"scripts": {},
"keywords": [
"canvas",
"animations",
"graphic",
"html5"
],
"author": "Anton Lavrenov",
"bugs": {
"url": "https://github.com/konvajs/konva/issues"
},
"homepage": "http://konvajs.org/",
"repository": {
"type": "git",
"url": "git://github.com/konvajs/konva.git"
},
"license": "MIT",
"dependencies": {
"canvas": "^2.5.0",
"konva": "^7"
}
}

View File

@ -3,16 +3,34 @@
"version": "9.3.22",
"description": "HTML5 2d canvas library.",
"author": "Anton Lavrenov",
"type": "module",
"files": [
"README.md",
"konva.js",
"konva.min.js",
"lib",
"cmj"
"lib"
],
"main": "./lib/index-node.js",
"browser": "./lib/index.js",
"typings": "./lib/index-types.d.ts",
"exports": {
".": {
"default": "./lib/index.js"
},
"./lib/*": {
"default": "./lib/*.js"
},
"./lib/*.js": {
"import": "./lib/*.js",
"require": "./lib/*.js",
"default": "./lib/*.js"
},
"./canvas-backend": {
"default": "./lib/canvas-backend.js"
},
"./skia-backend": {
"default": "./lib/skia-backend.js"
}
},
"main": "./lib/index.js",
"types": "./lib/index-types.d.ts",
"scripts": {
"start": "npm run test:watch",
"compile": "npm run clean && npm run tsc && cp ./src/index-types.d.ts ./lib/index-types.d.ts && npm run rollup",
@ -22,10 +40,11 @@
"test:build": "PARCEL_WORKER_BACKEND=process parcel build ./test/unit-tests.html --dist-dir ./test-build --target none --public-url ./ --no-source-maps",
"test:browser": "npm run test:build && mocha-headless-chrome -f ./test-build/unit-tests.html -a disable-web-security -a no-sandbox -a disable-setuid-sandbox",
"test:watch": "rm -rf ./.parcel-cache && PARCEL_WORKERS=0 parcel serve ./test/unit-tests.html ./test/manual-tests.html ./test/sandbox.html ./test/text-paths.html ./test/bunnies.html",
"test:node:compiled": "rm -rf ./.test-temp && mkdir ./.test-temp && (tsc -p ./test/tsconfig.json --outDir ./.test-temp || true) && mocha './.test-temp/test/unit/**/*.js' -r ./test/node-global-setup.mjs --exit && rm -rf ./.test-temp && npm run test:import",
"test:node": "npm run test:node:compiled",
"test:node:canvas": "rm -rf ./.test-temp && mkdir ./.test-temp && (tsc -p ./test/tsconfig.json --outDir ./.test-temp || true) && node ./rename-imports-test.mjs && mocha './.test-temp/test/unit/**/*.js' -r ./test/node-canvas-global-setup.mjs --exit && rm -rf ./.test-temp && npm run test:import",
"test:node:skia": "rm -rf ./.test-temp && mkdir ./.test-temp && (tsc -p ./test/tsconfig.json --outDir ./.test-temp || true) && node ./rename-imports-test.mjs && mocha './.test-temp/test/unit/**/*.js' -r ./test/node-skia-global-setup.mjs --exit && rm -rf ./.test-temp && npm run test:import",
"test:node": "npm run test:node:canvas && npm run test:node:skia",
"tsc": "tsc --removeComments",
"rollup": "rollup -c --bundleConfigAsCjs",
"rollup": "rollup -c",
"clean": "rm -rf ./lib && rm -rf ./types && rm -rf ./cmj && rm -rf ./test-build",
"watch": "rollup -c -w",
"size": "size-limit"
@ -61,18 +80,18 @@
}
],
"devDependencies": {
"@parcel/transformer-image": "2.13.2",
"@size-limit/preset-big-lib": "^11.1.6",
"@parcel/transformer-image": "2.15.4",
"@size-limit/preset-big-lib": "^11.2.0",
"@types/mocha": "^10.0.10",
"canvas": "^3.1.0",
"chai": "5.1.2",
"canvas": "^3.1.2",
"chai": "5.2.1",
"filehound": "^1.17.6",
"gulp": "^5.0.0",
"gulp": "^5.0.1",
"gulp-concat": "^2.6.1",
"gulp-connect": "^5.7.0",
"gulp-exec": "^5.0.0",
"gulp-jsdoc3": "^3.0.0",
"gulp-rename": "^2.0.0",
"gulp-rename": "^2.1.0",
"gulp-replace": "^1.1.4",
"gulp-typescript": "^5.0.1",
"gulp-uglify": "^3.0.2",
@ -82,12 +101,13 @@
"mocha-headless-chrome": "^4.0.0",
"parcel": "2.13.3",
"process": "^0.11.10",
"rollup": "^4.31.0",
"rollup": "^4.46.2",
"rollup-plugin-typescript2": "^0.36.0",
"size-limit": "^11.1.6",
"ts-mocha": "^10.0.0",
"size-limit": "^11.2.0",
"skia-canvas": "^2.0.2",
"ts-mocha": "^11.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
"typescript": "^5.9.2"
},
"keywords": [
"canvas",

36
rename-imports-test.mjs Normal file
View File

@ -0,0 +1,36 @@
import FileHound from 'filehound';
import fs from 'fs';
const files = FileHound.create().paths('./.test-temp').ext(['js', 'ts']).find();
files.then((filePaths) => {
filePaths.forEach((filepath) => {
fs.readFile(filepath, 'utf8', (err, text) => {
if (!text.match(/import .* from/g)) {
return;
}
text = text.replace(/(import .* from\s+['"])(.*)(?=['"])/g, '$1$2.js');
if (text.match(/export .* from/g)) {
text = text.replace(/(export .* from\s+['"])(.*)(?=['"])/g, '$1$2.js');
}
if (err) throw err;
// stupid replacement back
text = text.replace("from 'canvas.js';", "from 'canvas';");
text = text.replace("from 'chai.js';", "from 'chai';");
text = text.replace("from 'skia-canvas.js';", "from 'skia-canvas';");
// Handle import("./x/y/z") syntax.
text = text.replace(/(import\s*\(\s*['"])(.*)(?=['"])/g, '$1$2.js');
fs.writeFile(filepath, text, function (err) {
if (err) {
throw err;
}
});
});
});
});
// Removed CommonJS export rewriting to keep ESM output intact

View File

@ -34,14 +34,4 @@ files.then((filePaths) => {
});
});
const indexFiles = ['lib/index.js', 'lib/index-node.js', 'lib/Core.js'];
indexFiles.forEach((filepath) => {
fs.readFile(filepath, 'utf8', (err, text) => {
text = text.replace('exports.default =', 'module.exports =');
fs.writeFile(filepath, text, function (err) {
if (err) {
throw err;
}
});
});
});
// Removed CommonJS export rewriting to keep ESM output intact

View File

@ -11,16 +11,12 @@ export default {
sourcemap: false,
freeze: false,
},
// { file: pkg.module, format: 'es', sourcemap: true }
],
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
external: [],
watch: {
include: 'src/**',
},
plugins: [
// Allow json resolution
// json(),
// Compile TypeScript files
typescript({
useTsconfigDeclarationDir: true,
@ -32,14 +28,5 @@ export default {
},
},
}),
// // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs)
// commonjs(),
// // Allow node_modules resolution, so you can use 'external' to control
// // which external modules to include in the bundle
// // https://github.com/rollup/rollup-plugin-node-resolve#usage
// resolve(),
// Resolve source maps to the original source
// sourceMaps()
],
};

View File

@ -184,6 +184,11 @@ export const Konva = {
// insert Konva into global namespace (window)
// it is required for npm packages
_injectGlobal(Konva) {
if (typeof glob.Konva !== 'undefined') {
console.error(
'Severa Konva instances detected. It is not recommended to use multiple Konva instances in the same environment.'
);
}
glob.Konva = Konva;
},
};

View File

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

View File

@ -2,6 +2,26 @@ import { Konva } from './Global';
import { Context } from './Context';
import { IRect, RGB, Vector2d } from './types';
const NODE_ERROR = `Konva.js unsupported environment.
Looks like you are trying to use Konva.js in Node.js environment. because "document" object is undefined.
To use Konva.js in Node.js environment, you need to use the "canvas-backend" or "skia-backend" module.
bash: npm install canvas
js: import "konva/canvas-backend";
or
bash: npm install skia-canvas
js: import "konva/skia-backend";
`;
const ensureBrowser = () => {
if (typeof document === 'undefined') {
throw new Error(NODE_ERROR);
}
};
/*
* Last updated November 2011
* By Simon Sarris
@ -424,7 +444,7 @@ const OBJECT_ARRAY = '[object Array]',
yellowgreen: [154, 205, 5],
},
RGB_REGEX = /rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)/;
let animQueue: Array<Function> = [];
let animQueue: Array<Function> = [];
const req =
(typeof requestAnimationFrame !== 'undefined' && requestAnimationFrame) ||
@ -505,6 +525,7 @@ export const Util = {
}
},
createCanvasElement() {
ensureBrowser();
const canvas = document.createElement('canvas');
// on some environments canvas.style is readonly
try {
@ -513,6 +534,7 @@ export const Util = {
return canvas;
},
createImageElement() {
ensureBrowser();
return document.createElement('img');
},
_isInDocument(el: any) {
@ -995,6 +1017,11 @@ export const Util = {
height: number,
cornerRadius: number | number[]
) {
// if negative dimensions, abs width/height and move rectangle
let xOrigin = width < 0 ? width : 0;
let yOrigin = height < 0 ? height : 0;
width = Math.abs(width);
height = Math.abs(height);
let topLeft = 0;
let topRight = 0;
let bottomLeft = 0;
@ -1011,35 +1038,85 @@ export const Util = {
bottomRight = Math.min(cornerRadius[2] || 0, width / 2, height / 2);
bottomLeft = Math.min(cornerRadius[3] || 0, width / 2, height / 2);
}
context.moveTo(topLeft, 0);
context.lineTo(width - topRight, 0);
context.moveTo(xOrigin + topLeft, yOrigin);
context.lineTo(xOrigin + width - topRight, yOrigin);
context.arc(
width - topRight,
topRight,
xOrigin + width - topRight,
yOrigin + topRight,
topRight,
(Math.PI * 3) / 2,
0,
false
);
context.lineTo(width, height - bottomRight);
context.lineTo(xOrigin + width, yOrigin + height - bottomRight);
context.arc(
width - bottomRight,
height - bottomRight,
xOrigin + width - bottomRight,
yOrigin + height - bottomRight,
bottomRight,
0,
Math.PI / 2,
false
);
context.lineTo(bottomLeft, height);
context.lineTo(xOrigin + bottomLeft, yOrigin + height);
context.arc(
bottomLeft,
height - bottomLeft,
xOrigin + bottomLeft,
yOrigin + height - bottomLeft,
bottomLeft,
Math.PI / 2,
Math.PI,
false
);
context.lineTo(0, topLeft);
context.arc(topLeft, topLeft, topLeft, Math.PI, (Math.PI * 3) / 2, false);
context.lineTo(xOrigin, yOrigin + topLeft);
context.arc(
xOrigin + topLeft,
yOrigin + topLeft,
topLeft,
Math.PI,
(Math.PI * 3) / 2,
false
);
},
drawRoundedPolygonPath(
context: Context,
points: Vector2d[],
sides: number,
radius: number,
cornerRadius: number | number[]
) {
radius = Math.abs(radius);
for (let i = 0; i < sides; i++) {
const prev = points[(i - 1 + sides) % sides];
const curr = points[i];
const next = points[(i + 1) % sides];
const vec1 = {x: curr.x - prev.x, y: curr.y - prev.y};
const vec2 = {x: next.x - curr.x, y: next.y - curr.y};
const len1 = Math.hypot(vec1.x, vec1.y);
const len2 = Math.hypot(vec2.x, vec2.y);
let currCornerRadius;
if (typeof cornerRadius === 'number') {
currCornerRadius = cornerRadius;
} else {
currCornerRadius = i < cornerRadius.length ? cornerRadius[i] : 0;
}
const maxCornerRadius = radius * Math.cos(Math.PI / sides);
// cornerRadius creates perfect circle at 1/2 radius
currCornerRadius = maxCornerRadius * Math.min(1, (currCornerRadius / radius) * 2);
const normalVec1 = {x: vec1.x / len1, y: vec1.y / len1};
const normalVec2 = {x: vec2.x / len2, y: vec2.y / len2};
const p1 = {
x: curr.x - normalVec1.x * currCornerRadius,
y: curr.y - normalVec1.y * currCornerRadius,
};
const p2 = {
x: curr.x + normalVec2.x * currCornerRadius,
y: curr.y + normalVec2.y * currCornerRadius,
};
if (i === 0) {
context.moveTo(p1.x, p1.y);
} else {
context.lineTo(p1.x, p1.y);
}
context.arcTo(curr.x, curr.y, p2.x, p2.y, currCornerRadius);
}
}
};

View File

@ -17,6 +17,7 @@ import { Ring } from './shapes/Ring';
import { Sprite } from './shapes/Sprite';
import { Star } from './shapes/Star';
import { Text } from './shapes/Text';
import { MultiStyledText } from './shapes/MultiStyledText';
import { TextPath } from './shapes/TextPath';
import { Transformer } from './shapes/Transformer';
import { Wedge } from './shapes/Wedge';
@ -59,6 +60,7 @@ export const Konva = Core.Util._assign(Core, {
Star,
Text,
TextPath,
MultiStyledText,
Transformer,
Wedge,
/**

26
src/canvas-backend.ts Normal file
View File

@ -0,0 +1,26 @@
import { Konva } from './_CoreInternals';
import * as Canvas from 'canvas';
const canvas = Canvas['default'] || Canvas;
global.DOMMatrix = canvas.DOMMatrix;
Konva.Util['createCanvasElement'] = () => {
const node = canvas.createCanvas(300, 300) as any;
if (!node['style']) {
node['style'] = {};
}
return node;
};
// create image in Node env
Konva.Util.createImageElement = () => {
const node = new canvas.Image() as any;
return node;
};
// this line is not part of the public API
// but will be used in tests
Konva.Util['Canvas'] = Canvas;
export default Konva;

View File

@ -0,0 +1,671 @@
import { Context } from '../Context'
import { Factory } from '../Factory'
import { _registerNode } from '../Global'
import { Shape, ShapeConfig } from '../Shape'
import { GetSet } from '../types'
import { Util } from '../Util'
import { getNumberOrAutoValidator, getNumberValidator, getBooleanValidator, getStringValidator } from '../Validators'
let dummyContext: Context & CanvasRenderingContext2D
function getDummyContext() {
if (dummyContext) {
return dummyContext
}
dummyContext = Util.createCanvasElement().getContext('2d') as any
return dummyContext
}
function normalizeFontFamily(fontFamily: string) {
return fontFamily
.split(',')
.map((family) => {
family = family.trim();
const hasSpace = family.indexOf(' ') >= 0;
const hasQuotes = family.indexOf('"') >= 0 || family.indexOf("'") >= 0;
if (hasSpace && !hasQuotes) {
family = `"${family}"`;
}
return family;
})
.join(', ');
}
export interface TextStyle {
start: number // start position of the style
end?: number // end position of the style, if undefined it means until the end
fontFamily: string
fontSize: number
fontStyle: 'normal' | 'italic' | 'bold' | 'italic bold' | 'bold italic'
fontVariant: 'normal' | 'small-caps'
textDecoration: '' | 'underline' | 'line-through' | 'underline line-through'
fill: string
stroke: string
}
type TextPart = {
text: string
width: number
style: Omit<TextStyle, 'start' | 'end'>
}
export interface MultiStyledTextConfig extends ShapeConfig {
text?: string;
textStyles?: TextStyle[]
align?: string;
verticalAlign?: string;
padding?: number;
lineHeight?: number;
letterSpacing?: number;
wrap?: string;
ellipsis?: boolean;
}
export class MultiStyledText extends Shape<MultiStyledTextConfig> {
public className = 'MultiStyledText'
public align!: GetSet<'left' | 'center' | 'right' | 'justify', this>
public letterSpacing!: GetSet<number, this>
public verticalAlign!: GetSet<'top' | 'middle' | 'bottom', this>
public padding!: GetSet<number, this>
public lineHeight!: GetSet<number, this>
public text!: GetSet<string, this>
public textStyles!: GetSet<TextStyle[], this>
public wrap!: GetSet<'word' | 'char' | 'none', this>
public ellipsis!: GetSet<boolean, this>
private textLines: {
width: number
totalHeight: number
parts: TextPart[]
}[] = []
private linesWidth!: number
private linesHeight!: number
// used when drawing
private drawState!: {
x: number
y: number
text: string
}
constructor(config?: MultiStyledTextConfig) {
super(config)
// update text data for certain attr changes
for (const attr of [
'padding', 'wrap', 'lineHeight', 'letterSpacing', 'textStyles', 'width', 'height', 'text'
]) {
this.on(`${attr}Change.konva`, this.computeTextParts)
}
this.computeTextParts()
}
private formatFont (part: Pick<TextPart, 'style'>) {
return `${part.style.fontStyle} ${part.style.fontVariant} ${part.style.fontSize}px ${normalizeFontFamily(part.style.fontFamily)}`
}
private measurePart (part: Omit<TextPart, 'width'>) {
const context = getDummyContext()
context.save()
context.font = this.formatFont(part)
const width = context.measureText(part.text).width
context.restore()
return width
}
private computeTextParts () {
this.textLines = []
const lines = this.text().split('\n')
const maxWidth = this.attrs.width
const maxHeight = this.attrs.height
const hasFixedWidth = maxWidth !== 'auto' && maxWidth !== undefined
const hasFixedHeight = maxHeight !== 'auto' && maxHeight !== undefined
const shouldWrap = this.wrap() !== 'none'
const wrapAtWord = this.wrap() !== 'char' && shouldWrap
const shouldAddEllipsis = this.ellipsis()
const styles = this.textStyles()
const ellipsis = '…'
const additionalWidth = shouldAddEllipsis ? this.measurePart({ text: ellipsis, style: styles[styles.length - 1] }) : 0;
const stylesByChar = Array.from(this.text()).map((char, index) => {
return {
char,
style: styles.find((style) => index >= style.start && (typeof style.end === 'undefined' || style.end >= index))!
}
})
const findParts = (start: number, end: number) => {
// find matching characters
const chars = stylesByChar.slice(start, end)
// group them by style
const parts: TextPart[] = []
for (const char of chars) {
const similarGroupIndex = parts.findIndex((part) => part.style === char.style)
if (similarGroupIndex === -1) {
parts.push({ text: char.char, width: 0, style: char.style })
continue
}
parts[similarGroupIndex].text += char.char
}
return parts
}
const measureSubstring = (start: number, end: number) => {
return measureParts(findParts(start, end))
}
const measureParts = (parts: TextPart[]) => {
return parts.reduce((size, part) => {
part.width = this.measurePart(part)
return size + part.width
}, 0)
}
const measureHeightParts = (parts: TextPart[]) => {
return Math.max(...parts.map((part) => {
return part.style.fontSize * this.lineHeight()
}))
}
const addLine = (width: number, height: number, parts: TextPart[]) => {
// if element height is fixed, abort if adding one more line would overflow
// so we don't add this line, the loop will be broken anyway
if (hasFixedHeight && (currentHeight + height) > maxHeight) {
return
}
this.textLines.push({
width,
parts: parts.map((part) => {
// compute size if not already computed during part creation
part.width = part.width === 0 ? this.measurePart(part) : part.width
return part
}),
totalHeight: height
})
}
let currentHeight = 0
let charCount = 0
for (let line of lines) {
let lineWidth = measureSubstring(charCount, charCount + line.length)
let lineHeight: number
if (hasFixedWidth && lineWidth > maxWidth) {
/*
* if width is fixed and line does not fit entirely
* break the line into multiple fitting lines
*/
let cursor = 0
while (line.length > 0) {
/*
* use binary search to find the longest substring that
* that would fit in the specified width
*/
var low = 0,
high = line.length,
match = '',
matchWidth = 0
while (low < high) {
var mid = (low + high) >>> 1,
substr = line.slice(0, mid + 1),
substrWidth = measureSubstring(charCount + cursor, charCount + cursor + mid + 1) + additionalWidth
if (substrWidth <= maxWidth) {
low = mid + 1
match = substr
matchWidth = substrWidth
} else {
high = mid
}
}
/*
* 'low' is now the index of the substring end
* 'match' is the substring
* 'matchWidth' is the substring width in px
*/
if (match) {
// a fitting substring was found
if (wrapAtWord) {
// try to find a space or dash where wrapping could be done
let wrapIndex: number
var nextChar = line[match.length]
var nextIsSpaceOrDash = nextChar === ' ' || nextChar === '-'
if (nextIsSpaceOrDash && matchWidth <= maxWidth) {
wrapIndex = match.length
} else {
wrapIndex = Math.max(match.lastIndexOf(' '), match.lastIndexOf('-')) + 1
}
if (wrapIndex > 0) {
// re-cut the substring found at the space/dash position
low = wrapIndex
match = match.slice(0, low)
matchWidth = measureSubstring(charCount + cursor, charCount + cursor + low)
}
}
// match = match.trimRight()
const parts = findParts(charCount + cursor, charCount + cursor + low)
lineHeight = measureHeightParts(parts)
addLine(measureParts(parts), lineHeight, parts)
currentHeight += lineHeight
if (
!shouldWrap ||
(hasFixedHeight && currentHeight + lineHeight > maxHeight)
) {
const lastLine = this.textLines[this.textLines.length - 1]
if (lastLine) {
if (shouldAddEllipsis) {
const lastPart = lastLine.parts[lastLine.parts.length - 1]
const lastPartWidthWithEllipsis = this.measurePart({ ...lastPart, text: `${lastPart.text}${ellipsis}` })
const haveSpace = lastPartWidthWithEllipsis < maxWidth
if (!haveSpace) {
lastPart.text = lastPart.text.slice(0, lastPart.text.length - 3)
}
lastLine.parts.splice(lastLine.parts.length - 1, 1)
lastLine.parts.push({
...lastPart,
width: lastPartWidthWithEllipsis,
text: `${lastPart.text}${ellipsis}`
})
}
}
/*
* stop wrapping if wrapping is disabled or if adding
* one more line would overflow the fixed height
*/
break
}
line = line.slice(low)
cursor += low
// line = line.trimLeft()
if (line.length > 0) {
// Check if the remaining text would fit on one line
const parts = findParts(charCount + cursor, charCount + cursor + line.length)
lineWidth = measureParts(parts)
if (lineWidth <= maxWidth) {
// if it does, add the line and break out of the loop
const height = measureHeightParts(parts)
addLine(lineWidth, height, parts)
currentHeight += height
break
}
}
} else {
// not even one character could fit in the element, abort
break
}
}
} else {
const parts = findParts(charCount, charCount + line.length)
lineHeight = measureHeightParts(parts)
addLine(lineWidth, lineHeight, parts)
}
// if element height is fixed, abort if adding one more line would overflow
// so we stop here to avoid processing useless lines
if (hasFixedHeight && (currentHeight + lineHeight!) > maxHeight) {
break
}
charCount += line.length
currentHeight += lineHeight!
}
this.linesHeight = this.textLines.reduce((size, line) => size + line.totalHeight, 0)
this.linesWidth = Math.max(...this.textLines.map((line) => line.width, 0))
}
public getHeight (): number {
const isAuto = this.attrs.height === 'auto' || this.attrs.height === undefined
if (!isAuto) {
return this.attrs.height
}
return this.linesHeight + this.padding() * 2
}
public getWidth (): number {
const isAuto = this.attrs.width === 'auto' || this.attrs.width === undefined
if (!isAuto) {
return this.attrs.width
}
return this.linesWidth + this.padding() * 2
}
/**
* @description This method is called when the shape should render
* on canvas
*/
protected _sceneFunc(context: Context & CanvasRenderingContext2D) {
if (this.text().length === 0) {
return
}
const totalWidth = this.getWidth()
const totalHeight = this.getHeight()
context.setAttr('textBaseline', 'middle')
context.setAttr('textAlign', 'left')
// handle vertical alignment
const padding = this.padding()
let alignY = 0
if (this.verticalAlign() === 'middle') {
alignY = (totalHeight - this.linesHeight - padding * 2) / 2;
} else if (this.verticalAlign() === 'bottom') {
alignY = totalHeight - this.linesHeight - padding * 2;
}
context.translate(padding, alignY + padding)
let y = this.textLines[0].totalHeight / 2
let lineIndex = 0
for (const line of this.textLines) {
const isLastLine = lineIndex === this.textLines.length - 1
let lineX = 0
let lineY = 0
context.save()
// horizontal alignment
if (this.align() === 'right') {
lineX += totalWidth - line.width - padding * 2
} else if (this.align() === 'center') {
lineY += (totalWidth - line.width - padding * 2) / 2
}
for (const part of line.parts) {
// style
if (part.style.textDecoration.includes('underline')) {
context.save();
context.beginPath()
context.moveTo(
lineX,
y + lineY + Math.round(part.style.fontSize / 2)
)
const spacesNumber = part.text.split(' ').length - 1
const oneWord = spacesNumber === 0
const lineWidth =
this.align() === 'justify' && isLastLine && !oneWord
? totalWidth - padding * 2
: part.width
context.lineTo(
lineX + Math.round(lineWidth),
y + lineY + Math.round(part.style.fontSize / 2)
)
// I have no idea what is real ratio
// just /15 looks good enough
context.lineWidth = part.style.fontSize / 15
context.strokeStyle = part.style.fill
context.stroke()
context.restore()
}
if (part.style.textDecoration.includes('line-through')) {
context.save()
context.beginPath()
context.moveTo(lineX, y + lineY)
const spacesNumber = part.text.split(' ').length - 1
const oneWord = spacesNumber === 0
const lineWidth =
this.align() === 'justify' && isLastLine && !oneWord
? totalWidth - padding * 2
: part.width
context.lineTo(
lineX + Math.round(lineWidth),
y + lineY
)
context.lineWidth = part.style.fontSize / 15
context.strokeStyle = part.style.fill
context.stroke()
context.restore()
}
this.fill(part.style.fill)
this.stroke(part.style.stroke)
context.setAttr('font', this.formatFont(part))
// text
if (this.letterSpacing() !== 0 || this.align() === 'justify') {
const spacesNumber = part.text.split(' ').length - 1
var array = Array.from(part.text)
for (let li = 0; li < array.length; li++) {
const letter = array[li]
// skip justify for the last line
if (letter === ' ' && lineIndex !== this.textLines.length - 1 && this.align() === 'justify') {
lineX += (totalWidth - padding * 2 - line.width) / spacesNumber;
}
this.drawState = {
x: lineX,
y: y + lineY,
text: letter
}
context.fillStrokeShape(this)
lineX += this.measurePart({ ...part, text: letter }) + this.letterSpacing()
}
} else {
this.drawState = {
x: lineX,
y: y + lineY,
text: part.text
}
context.fillStrokeShape(this)
lineX += part.width + this.letterSpacing()
}
}
context.restore()
if (typeof this.textLines[lineIndex + 1] !== 'undefined') {
y += this.textLines[lineIndex + 1].totalHeight
}
++lineIndex
}
}
/**
* @description This method is called by context.fillStrokeShape(this)
* to fill the shape
*/
public _fillFunc = (context: Context) => {
context.fillText(this.drawState.text, this.drawState.x, this.drawState.y)
}
/**
* @description This method is called by context.fillStrokeShape(this)
* to stroke the shape
*/
public _strokeFunc = (context: Context) => {
context.strokeText(this.drawState.text, this.drawState.x, this.drawState.y, undefined)
}
/**
* @description This method should render on canvas a rect with
* the width and the height of the text shape
*/
protected _hitFunc(context: Context & CanvasRenderingContext2D) {
context.beginPath()
context.rect(0, 0, this.getWidth(), this.getHeight())
context.closePath()
context.fillStrokeShape(this)
}
// for text we can't disable stroke scaling
// if we do, the result will be unexpected
public getStrokeScaleEnabled() {
return true
}
}
_registerNode(MultiStyledText)
/**
* get/set width of text area, which includes padding.
* @name Konva.Text#width
* @method
* @param {Number} width
* @returns {Number}
* @example
* // get width
* var width = text.width();
*
* // set width
* text.width(20);
*
* // set to auto
* text.width('auto');
* text.width() // will return calculated width, and not "auto"
*/
Factory.overWriteSetter(MultiStyledText, 'width', getNumberOrAutoValidator())
/**
* get/set the height of the text area, which takes into account multi-line text, line heights, and padding.
* @name Konva.Text#height
* @method
* @param {Number} height
* @returns {Number}
* @example
* // get height
* var height = text.height();
*
* // set height
* text.height(20);
*
* // set to auto
* text.height('auto');
* text.height() // will return calculated height, and not "auto"
*/
Factory.overWriteSetter(MultiStyledText, 'height', getNumberOrAutoValidator())
/**
* get/set padding
* @name Konva.Text#padding
* @method
* @param {Number} padding
* @returns {Number}
* @example
* // get padding
* var padding = text.padding();
*
* // set padding to 10 pixels
* text.padding(10);
*/
Factory.addGetterSetter(MultiStyledText, 'padding', 0, getNumberValidator())
/**
* get/set horizontal align of text. Can be 'left', 'center', 'right' or 'justify'
* @name Konva.Text#align
* @method
* @param {String} align
* @returns {String}
* @example
* // get text align
* var align = text.align();
*
* // center text
* text.align('center');
*
* // align text to right
* text.align('right');
*/
Factory.addGetterSetter(MultiStyledText, 'align', 'left')
/**
* get/set vertical align of text. Can be 'top', 'middle', 'bottom'.
* @name Konva.Text#verticalAlign
* @method
* @param {String} verticalAlign
* @returns {String}
* @example
* // get text vertical align
* var verticalAlign = text.verticalAlign();
*
* // center text
* text.verticalAlign('middle');
*/
Factory.addGetterSetter(MultiStyledText, 'verticalAlign', 'top')
/**
* get/set line height. The default is 1.
* @name Konva.Text#lineHeight
* @method
* @param {Number} lineHeight
* @returns {Number}
* @example
* // get line height
* var lineHeight = text.lineHeight();
*
* // set the line height
* text.lineHeight(2);
*/
Factory.addGetterSetter(MultiStyledText, 'lineHeight', 1, getNumberValidator())
/**
* get/set wrap. Can be "word", "char", or "none". Default is "word".
* In "word" wrapping any word still can be wrapped if it can't be placed in the required width
* without breaks.
* @name Konva.Text#wrap
* @method
* @param {String} wrap
* @returns {String}
* @example
* // get wrap
* var wrap = text.wrap();
*
* // set wrap
* text.wrap('word');
*/
Factory.addGetterSetter(MultiStyledText, 'wrap', 'word')
/**
* get/set ellipsis. Can be true or false. Default is false. If ellipses is true,
* Konva will add "..." at the end of the text if it doesn't have enough space to write characters.
* That is possible only when you limit both width and height of the text
* @name Konva.Text#ellipsis
* @method
* @param {Boolean} ellipsis
* @returns {Boolean}
* @example
* // get ellipsis param, returns true or false
* var ellipsis = text.ellipsis();
*
* // set ellipsis
* text.ellipsis(true);
*/
Factory.addGetterSetter(MultiStyledText, 'ellipsis', false, getBooleanValidator())
/**
* set letter spacing property. Default value is 0.
* @name Konva.Text#letterSpacing
* @method
* @param {Number} letterSpacing
*/
Factory.addGetterSetter(MultiStyledText, 'letterSpacing', 0, getNumberValidator())
/**
* get/set text
* @name Konva.Text#text
* @method
* @param {String} text
* @returns {String}
* @example
* // get text
* var text = text.text();
*
* // set text
* text.text('Hello world!');
*/
Factory.addGetterSetter(MultiStyledText, 'text', '', getStringValidator())
/**
* get/set textStyles
* @name Konva.Text#textStyles
* @method
* @param {TextStyle[]} textStyles
* @returns {String}
* @example
* // set styles
* text.textStyles([{ start: 0, fontFamily: 'Roboto' }]);
*/
const defaultStyle: TextStyle = {
start: 0,
fill: 'black',
stroke: 'black',
fontFamily: 'Arial',
fontSize: 12,
fontStyle: 'normal',
fontVariant: 'normal',
textDecoration: ''
}
Factory.addGetterSetter(MultiStyledText, 'textStyles', [defaultStyle])

View File

@ -1,13 +1,15 @@
import { Factory } from '../Factory';
import { Shape, ShapeConfig } from '../Shape';
import { GetSet, Vector2d } from '../types';
import { getNumberValidator } from '../Validators';
import { getNumberOrArrayOfNumbersValidator, getNumberValidator } from '../Validators';
import { _registerNode } from '../Global';
import { Context } from '../Context';
import { Util } from '../Util';
export interface RegularPolygonConfig extends ShapeConfig {
sides: number;
radius: number;
cornerRadius?: number | number[];
}
/**
* RegularPolygon constructor. Examples include triangles, squares, pentagons, hexagons, etc.
@ -15,6 +17,7 @@ export interface RegularPolygonConfig extends ShapeConfig {
* @memberof Konva
* @augments Konva.Shape
* @param {Object} config
* @param {Number} [config.cornerRadius]
* @param {Number} config.sides
* @param {Number} config.radius
* @@shapeParams
@ -32,13 +35,20 @@ export interface RegularPolygonConfig extends ShapeConfig {
*/
export class RegularPolygon extends Shape<RegularPolygonConfig> {
_sceneFunc(context: Context) {
const points = this._getPoints();
const points = this._getPoints(),
radius = this.radius(),
sides = this.sides(),
cornerRadius = this.cornerRadius();
context.beginPath();
context.moveTo(points[0].x, points[0].y);
for (let n = 1; n < points.length; n++) {
context.lineTo(points[n].x, points[n].y);
if (!cornerRadius) {
context.moveTo(points[0].x, points[0].y);
for (let n = 1; n < points.length; n++) {
context.lineTo(points[n].x, points[n].y);
}
} else {
Util.drawRoundedPolygonPath(context, points, sides, radius, cornerRadius);
}
context.closePath();
@ -91,6 +101,7 @@ export class RegularPolygon extends Shape<RegularPolygonConfig> {
radius: GetSet<number, this>;
sides: GetSet<number, this>;
cornerRadius: GetSet<number | number[], this>;
}
RegularPolygon.prototype.className = 'RegularPolygon';
@ -127,3 +138,26 @@ Factory.addGetterSetter(RegularPolygon, 'radius', 0, getNumberValidator());
* shape.sides(10);
*/
Factory.addGetterSetter(RegularPolygon, 'sides', 0, getNumberValidator());
/**
* get/set corner radius
* @method
* @name Konva.RegularPolygon#cornerRadius
* @param {Number} cornerRadius
* @returns {Number}
* @example
* // get corner radius
* var cornerRadius = poly.cornerRadius();
*
* // set corner radius
* poly.cornerRadius(10);
*
* // set different corner radius values (pentagon)
* poly.cornerRadius([0, 10, 20, 30, 40]);
*/
Factory.addGetterSetter(
RegularPolygon,
'cornerRadius',
0,
getNumberOrArrayOfNumbersValidator(4)
);

View File

@ -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<TextConfig> {
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<TextConfig> {
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<TextConfig> {
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<TextConfig> {
text: GetSet<string, this>;
wrap: GetSet<string, this>;
ellipsis: GetSet<boolean, this>;
charRenderFunc: GetSet<null | ((props: CharRenderProps) => 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);

36
src/skia-backend.ts Normal file
View File

@ -0,0 +1,36 @@
import { Konva } from './_CoreInternals';
import { Canvas, DOMMatrix, Image } from 'skia-canvas';
global.DOMMatrix = DOMMatrix as any;
// @ts-ignore
Canvas.prototype.toDataURL = Canvas.prototype.toDataURLSync;
Konva.Util['createCanvasElement'] = () => {
const node = new Canvas(300, 300) as any;
if (!node['style']) {
node['style'] = {};
}
node.toString = () => '[object HTMLCanvasElement]';
const ctx = node.getContext('2d');
// Override the getter to return the canvas node directly
// because in skia-canvas canvas is using weak ref to the canvas node
// and somehow on many tests it fails to get the canvas node
Object.defineProperty(ctx, 'canvas', {
get: () => node,
});
return node;
};
// create image in Node env
Konva.Util.createImageElement = () => {
const node = new Image() as any;
node.toString = () => '[object HTMLImageElement]';
return node;
};
// this line is not part of the public API
// but will be used in tests
Konva.Util['isSkia'] = true;
export default Konva;

View File

@ -1,5 +1,6 @@
// try to import only core
const Konva = require('../');
const Konva = require('../').default;
require('../lib/canvas-backend');
// just do a simple action
const stage = new Konva.Stage();

View File

@ -4,15 +4,15 @@ function equal(val1, val2, message) {
}
}
// try to import only core
// try to import only core from built lib
import Konva from '../lib/Core.js';
import '../lib/canvas-backend.js';
import { Rect } from '../lib/shapes/Rect.js';
import '../lib/index-node.js';
equal(Rect !== undefined, true, 'Rect is defined');
equal(Konva.Rect, Rect, 'Rect is injected');
// // just do a simple action
// just do a simple action
const stage = new Konva.Stage();
stage.toDataURL();

View File

@ -1,6 +1,6 @@
import { assert } from 'chai';
import { addStage, Konva, loadImage } from '../unit/test-utils';
import { addStage, Konva, loadImage, showHit } from '../unit/test-utils';
describe('Manual', function () {
// ======================================================

View File

@ -0,0 +1,21 @@
export async function mochaGlobalSetup() {
// Load node-canvas polyfills on the compiled test output
// Path from this file (test/) to compiled file (.test-temp/src/...)
try {
await import(
new URL('../.test-temp/src/canvas-backend.js', import.meta.url)
);
} catch (e) {
// If not compiled yet or path missing, keep going; tests that need it will fail clearly
}
globalThis.Path2D ??= class Path2D {
constructor(path) {
this.path = path;
}
get [Symbol.toStringTag]() {
return `Path2D`;
}
};
}

View File

@ -1,11 +0,0 @@
export function mochaGlobalSetup() {
globalThis.Path2D ??= class Path2D {
constructor(path) {
this.path = path
}
get [Symbol.toStringTag]() {
return `Path2D`;
}
}
}

View File

@ -0,0 +1,13 @@
export async function mochaGlobalSetup() {
await import(new URL('../.test-temp/src/skia-backend.js', import.meta.url));
globalThis.Path2D ??= class Path2D {
constructor(path) {
this.path = path;
}
get [Symbol.toStringTag]() {
return `Path2D`;
}
};
}

View File

@ -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();
</script>
</body>
</html>

View File

@ -4,7 +4,7 @@
"noEmitOnError": false,
"moduleResolution": "node",
"lib": ["ES2015", "dom"],
"module": "CommonJS",
"module": "ESNext",
"skipLibCheck": true,
"noImplicitAny": false,
"allowJs": true,

View File

@ -86,8 +86,6 @@ describe('Group', function () {
const trace = layer.getContext().getTrace();
console.log(trace);
assert.equal(
trace,
'clearRect(0,0,578,200);save();transform(1,0,0,1,0,0);beginPath();rect(0,0,0,0);clip();transform(1,0,0,1,0,0);restore();'

View File

@ -327,7 +327,7 @@ describe('Image', function () {
layer.add(image);
layer.draw();
assert.equal(image instanceof Konva.Image, true);
var nativeImg = image.image();
var nativeImg = image.image() as HTMLImageElement;
assert.equal(nativeImg instanceof Image, true);
assert.equal(nativeImg.src.indexOf(src) !== -1, true);
assert.equal(nativeImg.complete, true);

View File

@ -366,7 +366,7 @@ describe('Label', function () {
layer.add(label);
assert.equal(counter, 4);
tag.pointerDirection('bottom');
tag.pointerDirection('down');
assert.equal(counter, 5);
tag.pointerWidth(30);
assert.equal(counter, 6);

View File

@ -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);

View File

@ -2068,7 +2068,7 @@ describe('MouseEvents', function () {
type: 'mouseenter',
};
stage._pointerenter(evt);
stage._pointerenter(evt as PointerEvent);
assert.equal(mouseenterCount, 1, 'mouseenterCount should be 1');
});
@ -2268,7 +2268,7 @@ describe('MouseEvents', function () {
type: 'mouseenter',
};
stage._pointerenter(evt);
stage._pointerenter(evt as PointerEvent);
simulateMouseMove(stage, {
x: 10,
@ -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;

View File

@ -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);
});

View File

@ -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) {
@ -2558,11 +2552,18 @@ describe('Node', function () {
});
it('make sure we can create non existing node type', function () {
const oldWarn = console.warn;
let called = false;
console.warn = function () {
called = true;
};
var json =
'{"attrs":{},"className":"Layer","children":[{"attrs":{},"className":"Group","children":[{"attrs":{"x":289,"y":100,"radius":70,"fill":"green","stroke":"black","strokeWidth":4,"name":"myCircle","draggable":true},"className":"WeirdShape"}]}]}';
var layer = Konva.Node.create(json);
assert.deepEqual(layer.find('Shape').length, 1);
console.warn = oldWarn;
assert.equal(called, true);
});
// ======================================================
@ -3484,6 +3485,7 @@ describe('Node', function () {
assert.equal(rect.findAncestor('#group'), group);
assert.equal(rect.findAncestor('Group'), group);
// @ts-expect-error - test for no selector
assert.equal(rect.findAncestor(), null, 'return null if no selector');
});

View File

@ -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);
}

View File

@ -234,4 +234,28 @@ describe('Rect', function () {
'clearRect(0,0,578,200);save();transform(1,0,0,1,50,50);beginPath();moveTo(0,0);lineTo(90,0);arc(90,10,10,4.712,0,false);lineTo(100,80);arc(80,80,20,0,1.571,false);lineTo(30,100);arc(30,70,30,1.571,3.142,false);lineTo(0,0);arc(0,0,0,3.142,4.712,false);closePath();fillStyle=black;fill();restore();clearRect(0,0,578,200);save();transform(1,0,0,1,50,50);beginPath();moveTo(0,0);lineTo(90,0);arc(90,10,10,4.712,0,false);lineTo(100,80);arc(80,80,20,0,1.571,false);lineTo(30,100);arc(30,70,30,1.571,3.142,false);lineTo(0,0);arc(0,0,0,3.142,4.712,false);closePath();fillStyle=black;fill();restore();'
);
});
// ======================================================
it('corner radius with negative width and height', function () {
var stage = addStage();
var layer = new Konva.Layer();
var rect = new Konva.Rect({
x: 100,
y: 100,
width: -100,
height: -100,
fill: 'black',
cornerRadius: [0, 10, 20, 30],
});
layer.add(rect);
stage.add(layer);
layer.draw();
var trace = layer.getContext().getTrace();
assert.equal(
trace,
'clearRect(0,0,578,200);save();transform(1,0,0,1,100,100);beginPath();moveTo(-100,-100);lineTo(-10,-100);arc(-10,-90,10,4.712,0,false);lineTo(0,-20);arc(-20,-20,20,0,1.571,false);lineTo(-70,0);arc(-70,-30,30,1.571,3.142,false);lineTo(-100,-100);arc(-100,-100,0,3.142,4.712,false);closePath();fillStyle=black;fill();restore();clearRect(0,0,578,200);save();transform(1,0,0,1,100,100);beginPath();moveTo(-100,-100);lineTo(-10,-100);arc(-10,-90,10,4.712,0,false);lineTo(0,-20);arc(-20,-20,20,0,1.571,false);lineTo(-70,0);arc(-70,-30,30,1.571,3.142,false);lineTo(-100,-100);arc(-100,-100,0,3.142,4.712,false);closePath();fillStyle=black;fill();restore();'
);
});
});

View File

@ -5,6 +5,8 @@ import {
Konva,
cloneAndCompareLayer,
assertAlmostEqual,
createCanvas,
compareLayerAndCanvas,
} from './test-utils';
describe('RegularPolygon', function () {
@ -206,4 +208,59 @@ describe('RegularPolygon', function () {
assertAlmostEqual(box.width, 91.60254037844388);
assertAlmostEqual(box.height, 80.00000000000003);
});
// ======================================================
it('limit corner radius', function () {
var stage = addStage();
var layer = new Konva.Layer();
var sides = 5;
var radius = 50;
var poly = new Konva.RegularPolygon({
x: 100,
y: 100,
sides: sides,
radius: radius,
fill: 'black',
cornerRadius: 25,
});
var resultCircleRadius = radius * Math.cos(Math.PI / sides);
layer.add(poly);
stage.add(layer);
// corner radius creates perfect circle at 1/2 radius
var canvas = createCanvas();
var context = canvas.getContext('2d');
context.beginPath();
context.arc(100, 100, resultCircleRadius, 0, Math.PI * 2);
context.fillStyle = 'black';
context.fill();
compareLayerAndCanvas(layer, canvas, 200);
});
// ======================================================
it('negative polygon radius with cornerRadius', function () {
var stage = addStage();
var layer = new Konva.Layer();
var poly = new Konva.RegularPolygon({
x: 100,
y: 100,
sides: 5,
radius: -100,
fill: 'black',
cornerRadius: 20,
});
layer.add(poly);
stage.add(layer);
layer.draw();
var trace = layer.getContext().getTrace();
assert.equal(
trace,
'clearRect(0,0,578,200);save();transform(1,0,0,1,100,100);beginPath();moveTo(26.18,80.979);arcTo(0,100,-26.18,80.979,32.361);lineTo(-68.925,49.923);arcTo(-95.106,30.902,-85.106,0.125,32.361);lineTo(-68.779,-50.125);arcTo(-58.779,-80.902,-26.418,-80.902,32.361);lineTo(26.418,-80.902);arcTo(58.779,-80.902,68.779,-50.125,32.361);lineTo(85.106,0.125);arcTo(95.106,30.902,68.925,49.923,32.361);closePath();fillStyle=black;fill();restore();clearRect(0,0,578,200);save();transform(1,0,0,1,100,100);beginPath();moveTo(26.18,80.979);arcTo(0,100,-26.18,80.979,32.361);lineTo(-68.925,49.923);arcTo(-95.106,30.902,-85.106,0.125,32.361);lineTo(-68.779,-50.125);arcTo(-58.779,-80.902,-26.418,-80.902,32.361);lineTo(26.418,-80.902);arcTo(58.779,-80.902,68.779,-50.125,32.361);lineTo(85.106,0.125);arcTo(95.106,30.902,68.925,49.923,32.361);closePath();fillStyle=black;fill();restore();'
);
});
});

View File

@ -1160,6 +1160,8 @@ describe('Shape', function () {
// no we should hit the rect
assert.equal(stage.getIntersection({ x: 5, y: 5 }), rect);
const oldWarn = console.warn;
console.warn = function () {};
rect.strokeHitEnabled(false);
assert.equal(rect.hitStrokeWidth(), 0);
@ -1171,15 +1173,7 @@ describe('Shape', function () {
rect.hitStrokeWidth(0);
assert.equal(rect.strokeHitEnabled(), false);
// var trace = layer
// .getHitCanvas()
// .getContext()
// .getTrace(true);
// assert.equal(
// trace,
// 'clearRect();save();transform();beginPath();rect();closePath();save();fillStyle;fill();restore();restore();'
// );
console.warn = oldWarn;
});
it('enable hitStrokeWidth even if we have no stroke on scene', function () {

View File

@ -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'
);
@ -551,6 +551,11 @@ describe('Stage', function () {
// ======================================================
it('Should not throw on clip for stage', function () {
// no asserts, because we check throw
const oldWarn = console.warn;
let called = false;
console.warn = function () {
called = true;
};
var stage = addStage({
clipFunc: function () {},
});
@ -566,6 +571,8 @@ describe('Stage', function () {
layer.add(text);
stage.add(layer);
console.warn = oldWarn;
assert.equal(called, true);
});
// ======================================================
@ -1424,7 +1431,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');

View File

@ -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;
@ -1786,9 +1780,42 @@ describe('Text', function () {
layer.draw();
Konva._fixTextRendering = false;
const trace =
'clearRect(0,0,578,200);clearRect(0,0,578,200);save();transform(1,0,0,1,0,0);font=normal normal 100px Arial;textBaseline=alphabetic;textAlign=left;translate(0,0);save();fillStyle=black;fillText(hello,0,85);restore();restore();';
if (Konva.Util['isSkia']) {
const trace =
'clearRect(0,0,578,200);clearRect(0,0,578,200);save();transform(1,0,0,1,0,0);font=normal normal 100px Arial;textBaseline=alphabetic;textAlign=left;translate(0,0);save();fillStyle=black;fillText(hello,0,84.668);restore();restore();';
assert.equal(layer.getContext().getTrace(), trace);
} else {
const trace =
'clearRect(0,0,578,200);clearRect(0,0,578,200);save();transform(1,0,0,1,0,0);font=normal normal 100px Arial;textBaseline=alphabetic;textAlign=left;translate(0,0);save();fillStyle=black;fillText(hello,0,85);restore();restore();';
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

@ -877,7 +877,7 @@ describe('TextPath', function () {
var rect = textpath.getClientRect();
assert.equal(Math.round(rect.width), 299);
assert.equal(Math.round(rect.height), 171);
assert.equal(Math.abs(Math.round(rect.height) - 171) < 2, true);
});
it.skip('check vertical text path', function () {

View File

@ -1,5 +1,6 @@
import { assert } from 'chai';
import { Transformer } from '../../src/shapes/Transformer';
import type { Rect } from '../../src/shapes/Rect';
import {
addStage,
@ -4842,8 +4843,15 @@ describe('Transformer', function () {
const tr = new Konva.Transformer();
layer.add(tr);
const oldError = console.error;
let called = false;
console.error = function () {
called = true;
};
tr.nodes([layer]);
assert.equal(tr.nodes().length, 0);
console.error = oldError;
assert.equal(called, true);
});
it('anchorStyleFunc', function () {
@ -4866,7 +4874,7 @@ describe('Transformer', function () {
});
layer.add(tr);
// manual check of correct position of node
var handler = tr.findOne<Konva.Rect>('.bottom-right');
var handler = tr.findOne<Rect>('.bottom-right');
assert.equal(handler.fill(), 'white');
tr.anchorStyleFunc((anchor) => {

View File

@ -1,4 +1,5 @@
import { createCanvas, Canvas } from 'canvas';
import KonvaModule from '../../src/index';
export const Konva = KonvaModule;
var TYPE_ARRAY = /\[object Array\]/i,
TYPE_CANVAS = /\[object (Canvas|HTMLCanvasElement)\]/i,
@ -6,15 +7,24 @@ var TYPE_ARRAY = /\[object Array\]/i,
TYPE_CONTEXT = /\[object CanvasRenderingContext2D\]/i,
TYPE_IMAGE = /\[object (Image|HTMLImageElement)\]/i,
TYPE_IMAGE_DATA = /\[object ImageData\]/i,
UNDEFINED = 'undefined',
canvas = getCanvas(),
context = canvas.getContext('2d');
UNDEFINED = 'undefined';
// Creation
function getCanvas(width?, height?) {
return createCanvas(width, height);
return Konva.Util.createCanvasElement();
}
let singleCanvas;
function getSingleCanvas() {
if (!singleCanvas) {
singleCanvas = getCanvas();
}
return singleCanvas;
}
function getImageData(width, height) {
const canvas = getSingleCanvas();
const context = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
context.clearRect(0, 0, width, height);
@ -26,7 +36,7 @@ function isImage(object) {
return isType(object, TYPE_IMAGE);
}
function isCanvas(object) {
return isType(object, TYPE_CANVAS) || object instanceof Canvas;
return isType(object, TYPE_CANVAS);
}
function isContext(object) {
return isType(object, TYPE_CONTEXT);
@ -49,10 +59,7 @@ function isImageType(object) {
);
}
function isType(object, type) {
return (
typeof object === 'object' &&
!!Object.prototype.toString.apply(object).match(type)
);
return typeof object === 'object' && !!object.toString().match(type);
}
// Type Conversion
@ -61,6 +68,8 @@ function copyImageData(imageData) {
width = imageData.width,
data = imageData.data;
const canvas = getSingleCanvas();
const context = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
const newImageData = context.getImageData(0, 0, width, height);
@ -89,6 +98,8 @@ function toImageData(object) {
function toImageDataFromImage(image) {
const height = image.height,
width = image.width;
const canvas = getSingleCanvas();
const context = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
context.clearRect(0, 0, width, height);
@ -189,8 +200,7 @@ function diffUnequal(a, b, options) {
bData = b.data,
cData = c.data,
align = options && options.align;
var rowOffset,
columnOffset;
var rowOffset, columnOffset;
for (let i = cData.length - 1; i > 0; i = i - 4) {
cData[i] = 255;
@ -239,10 +249,10 @@ function diffUnequal(a, b, options) {
function checkType(...args) {
for (let i = 0; i < args.length; i++) {
if (!isImageType(args[i])) {
throw {
name: 'ImageTypeError',
message: 'Submitted object was not an image.',
};
// throw {
// name: 'ImageTypeError',
// message: 'Submitted object was not an image.',
// };
}
}
}

View File

@ -1,10 +1,9 @@
import { assert } from 'chai';
import KonvaModule from '../../src/index';
import '../../src/index-node';
export const Konva = KonvaModule;
import * as canvas from 'canvas';
// import * as canvas from 'canvas';
Konva.enableTrace = true;
Konva.showWarnings = true;
@ -84,12 +83,14 @@ export function loadImage(url, callback) {
url = (document.getElementById(url) as HTMLImageElement).src;
}
return canvas
.loadImage(url)
.then(callback)
.catch((e) => {
console.error(e);
});
const image = Konva.Util.createImageElement();
image.onload = () => {
callback(image);
};
image.onerror = (e) => {
console.error('Error loading image', url, e);
};
image.src = url;
}
export function getPixelRatio() {
@ -171,7 +172,7 @@ export function compareLayers(layer1: Layer, layer2: Layer, tol?, secondTol?) {
}
export function createCanvas() {
var node = canvas.createCanvas(300, 300);
var node = Konva.Util.createCanvasElement();
node.width = 578 * Konva.pixelRatio;
node.height = 200 * Konva.pixelRatio;
node.getContext('2d').scale(Konva.pixelRatio, Konva.pixelRatio);

View File

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