From 1d23f5a78bbc24198a48f1f56b6db308ebe3234c Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 6 Nov 2026 16:07:33 +0200 Subject: [PATCH 1/4] feat(bucket): add the package which finds the area closed around a point @react-sketch/bucket renders the objects of a canvas, finds the area of the pixels connected to a point, grows it under the lines around it, and makes its outline a path (a FillPath, saved and loaded as a BucketFill). The area has to be closed within the canvas, and the fill goes over the object the area is in and under the ones which close it. The package is private and built with the other two by build:packages. --- package.json | 7 +- packages/bucket/README.md | 60 +++++++++ packages/bucket/package.json | 19 +++ packages/bucket/src/FillPath.ts | 13 ++ packages/bucket/src/bucketFill.ts | 118 ++++++++++++++++ packages/bucket/src/flood.ts | 200 ++++++++++++++++++++++++++++ packages/bucket/src/index.ts | 14 ++ packages/bucket/src/outline.ts | 175 ++++++++++++++++++++++++ packages/bucket/src/render.ts | 93 +++++++++++++ packages/bucket/tsconfig.build.json | 9 ++ packages/bucket/tsconfig.json | 17 +++ packages/react-sketch/package.json | 1 + 12 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 packages/bucket/README.md create mode 100644 packages/bucket/package.json create mode 100644 packages/bucket/src/FillPath.ts create mode 100644 packages/bucket/src/bucketFill.ts create mode 100644 packages/bucket/src/flood.ts create mode 100644 packages/bucket/src/index.ts create mode 100644 packages/bucket/src/outline.ts create mode 100644 packages/bucket/src/render.ts create mode 100644 packages/bucket/tsconfig.build.json create mode 100644 packages/bucket/tsconfig.json diff --git a/package.json b/package.json index 8ac51bc..78787be 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,13 @@ }, "scripts": { "start": "yarn workspace showcase start", + "build:bucket": "yarn workspace @react-sketch/bucket build", "build:eraser": "yarn workspace @react-sketch/eraser build", "build:laserpointer": "yarn workspace @react-sketch/laserpointer build", - "build:packages": "yarn build:eraser && yarn build:laserpointer", + "build:packages": "yarn build:bucket && yarn build:eraser && yarn build:laserpointer", "build": "yarn build:packages && yarn workspace react-sketch build && yarn workspace showcase build", - "typecheck": "yarn workspace @react-sketch/eraser typecheck && yarn workspace @react-sketch/laserpointer typecheck && yarn build:packages && yarn workspace react-sketch typecheck && yarn workspace showcase typecheck", - "clean": "node -e \"for (const d of ['packages/eraser/dist', 'packages/laserpointer/dist', 'packages/react-sketch/dist', 'packages/showcase/build']) require('fs').rmSync(d, { recursive: true, force: true })\"", + "typecheck": "yarn workspace @react-sketch/bucket typecheck && yarn workspace @react-sketch/eraser typecheck && yarn workspace @react-sketch/laserpointer typecheck && yarn build:packages && yarn workspace react-sketch typecheck && yarn workspace showcase typecheck", + "clean": "node -e \"for (const d of ['packages/bucket/dist', 'packages/eraser/dist', 'packages/laserpointer/dist', 'packages/react-sketch/dist', 'packages/showcase/build']) require('fs').rmSync(d, { recursive: true, force: true })\"", "lint": "eslint \"packages/**/*.{ts,tsx}\"", "lint:fix": "eslint --fix \"packages/**/*.{ts,tsx}\"", "format": "prettier --write .", diff --git a/packages/bucket/README.md b/packages/bucket/README.md new file mode 100644 index 0000000..2924d2e --- /dev/null +++ b/packages/bucket/README.md @@ -0,0 +1,60 @@ +# @react-sketch/bucket + +The bucket fill of react-sketch: it finds the area which is closed around a point of a canvas, by what is +drawn on it, and makes the fill of that area. + +The package is private. `react-sketch` bundles it, so nothing is installed for it by the users of the +library. It needs `fabric` 7.4 or later. + +```ts +import { bucketFill } from "@react-sketch/bucket"; + +const filled = bucketFill(canvas, canvas.getViewportPoint(event), { + color: "#2f9e44", +}); +if (filled) canvas.insertAt(filled.index, filled.fill); +``` + +`bucketFill(canvas, point, options)` returns the fill and where it goes among the objects of the canvas, or +`null` when there is nothing to fill. It does not change the canvas: the caller adds the fill, so that it is an +action of its own (the tool of `react-sketch` does it, and the history of the sketch records it). + +- `point` is a pixel of the canvas as it is seen, with the zoom and the position of its view + (`canvas.getViewportPoint(event)`). +- The options are `color` (any color the canvas knows, and not a transparent one), `tolerance` (how different + a pixel can be from the one pressed, 0 to 255, 32 by default), `overlap` (how many pixels the fill goes under + the lines around it, 1 by default) and `simplify` (how far, in pixels, the outline of the fill can be from + the pixels it is taken from, 0.5 by default). + +## How it works + +1. The objects of the canvas are drawn on a canvas of their own, one pixel for each pixel of the canvas (not of + the screen). The background color and the background image are not drawn, the overlay image is, and the + fills are not: what closes an area is what is drawn, whatever its color. +2. The area is the one of the pixels of the color of the pixel pressed which are connected to it (up, down, left + or right, so a line of pixels which touch at their corners closes it). A color has its alpha applied before + it is compared, so a faint pixel of a red line is as near to a transparent one as a faint pixel of a black one. +3. When the area reaches the edge of the canvas, it is not closed, and there is no fill. +4. The area grows by `overlap` pixels, over the pixels which an object above the fill draws, and no further. The + edge of a line is smooth, so an area which stops at the line leaves a thin seam: the fill goes under the line. +5. The outline of the area is followed pixel edge by pixel edge (one loop for the outer edge and one for each + hole, so a circle in a rectangle is a ring), and the steps of a diagonal or a curve are made a line. +6. The fill is a `FillPath` (a fabric `Path`, of the type `BucketFill`) in the coordinates of the objects, so + it scales with the canvas and is saved by `toJSON()`. Importing the package registers the type, which is + what loads it. + +It goes over the topmost object which is drawn at the point pressed (an older fill of the area, or a filled +rectangle the area is in) and under the objects which are above that one: the lines which close the area. + +## Limits + +- The area has to be closed within the canvas as it is shown. When the view is zoomed in so that a rectangle is + partly out of it, the inside reaches the edge and there is no fill. +- The pixels of a canvas which is tainted (an image of a server without CORS headers) cannot be read. The + images are left out then, and the area is closed by the other objects. +- A line with a gap lets the color out, however small the gap: a dashed line does not close an area. + +## Pieces + +`floodFill`, `grow`, `traceOutline`, `simplify` and `outlinePath` are exported too. They work on the pixels of +an image (`{ data, width, height }`, as `getImageData` returns them) and do not need a canvas. diff --git a/packages/bucket/package.json b/packages/bucket/package.json new file mode 100644 index 0000000..c834a15 --- /dev/null +++ b/packages/bucket/package.json @@ -0,0 +1,19 @@ +{ + "name": "@react-sketch/bucket", + "version": "1.2.0", + "private": true, + "description": "The bucket fill for fabric, which fills the area closed by the objects around a point with a color, used by react-sketch", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "fabric": "^7.4.0" + }, + "devDependencies": { + "fabric": "^7.4.0" + } +} diff --git a/packages/bucket/src/FillPath.ts b/packages/bucket/src/FillPath.ts new file mode 100644 index 0000000..879e682 --- /dev/null +++ b/packages/bucket/src/FillPath.ts @@ -0,0 +1,13 @@ +import { Path, classRegistry } from "fabric"; + +/** + * The fill of an area: a path of the color, which is under the objects + * around the area, and over the ones it is in. It is a class of its own, so + * that a fill is known among the objects (it is saved with its type, and + * loaded as one). + */ +export class FillPath extends Path { + static type = "BucketFill"; +} + +classRegistry.setClass(FillPath); diff --git a/packages/bucket/src/bucketFill.ts b/packages/bucket/src/bucketFill.ts new file mode 100644 index 0000000..ba14bc6 --- /dev/null +++ b/packages/bucket/src/bucketFill.ts @@ -0,0 +1,118 @@ +import * as fabric from "fabric"; +import { FillPath } from "./FillPath"; +import { DEFAULT_TOLERANCE, floodFill, grow, type Pixels } from "./flood"; +import { outlinePath } from "./outline"; +import { isFill, paintedBy, renderPixels } from "./render"; + +export interface BucketFillOptions { + /** The color of the fill, any color the canvas knows. Nothing is filled with a transparent one */ + color: string; + /** + * How different a pixel can be from the one pressed, from 0 to 255, and still + * be of the area. The pixels of the edge of a line are neither the color of the + * line nor the one of the area, so some tolerance lets the area go up to them. + * Defaults to 32 + */ + tolerance?: number; + /** + * How many pixels the fill goes under the lines around it, so that the edge of + * a line has no seam. Defaults to 1 + */ + overlap?: number; + /** + * How far (in pixels) the outline of the fill can be from the pixels it is + * taken from, to make the steps of a diagonal or of a curve a line. Defaults to 0.5 + */ + simplify?: number; +} + +export interface BucketFillResult { + /** The fill, which is not on the canvas yet */ + fill: FillPath; + /** + * Where the fill goes among the objects of the canvas (`canvas.insertAt`): + * over the object the area is in, and under the ones around it + */ + index: number; +} + +/** + * The fill of the area of the canvas which is closed around a point. + * + * The area is the one of the pixels with the color of the pixel at the point, + * which are connected to it, in what the canvas shows of its objects (without + * its background). What closes it can be any object: a line, a rectangle, a + * circle, a polygon, a stroke of the pencil, or the overlay image. The + * background is not looked at, so a background image does not make an area. + * + * The area has to be closed within the canvas, as seen with the zoom and the + * position of the view. When it reaches the edge, which is what an area does + * where nothing closes it, there is no fill. + * + * The fill is an object the same as the others: it is added by the caller, it + * can be saved and loaded, scaled with the canvas and removed. It goes over the + * object the area is in, and under the ones which close it. + * + * @param canvas the canvas to fill on + * @param point the pixel pressed, in canvas pixels (`canvas.getViewportPoint(event)`) + * @returns the fill, or null when there is nothing to fill: the point is out of + * the canvas, the area is not closed, or the color is transparent + */ +export const bucketFill = ( + canvas: fabric.StaticCanvas, + point: { x: number; y: number }, + options: BucketFillOptions +): BucketFillResult | null => { + const { + color, + tolerance = DEFAULT_TOLERANCE, + overlap = 1, + simplify = 0.5, + } = options; + if (new fabric.Color(color).getAlpha() === 0) return null; + const x = Math.floor(point.x); + const y = Math.floor(point.y); + if (!(x >= 0 && y >= 0 && x < canvas.getWidth() && y < canvas.getHeight())) { + return null; + } + + // The fills do not close an area, they are what it is filled with + const layers = (include: (object: fabric.FabricObject) => boolean): Pixels => { + try { + return renderPixels(canvas, { include }); + } catch { + // An image of a server without CORS headers taints the canvas, so its + // pixels cannot be read. The area is closed by the other objects then. + return renderPixels(canvas, { + include: (object) => + include(object) && !(object instanceof fabric.FabricImage), + overlay: false, + }); + } + }; + const drawn = layers((object) => !isFill(object)); + const region = floodFill(drawn, x, y, tolerance); + if (!region || region.open) return null; + + // Over what the area is in (the older fills too), under what is on top of it + const index = paintedBy(canvas, x, y) + 1; + const above = new Set(canvas.getObjects().slice(index)); + const cover = index + ? layers((object) => !isFill(object) && above.has(object)) + : drawn; + const grown = grow(region, cover, overlap); + + const view = fabric.util.invertTransform(canvas.viewportTransform); + const data = outlinePath(grown, simplify, ({ x: px, y: py }) => + new fabric.Point(px, py).transform(view) + ); + if (!data) return null; + + const fill = new FillPath(data, { + fill: color, + stroke: null, + strokeWidth: 0, + fillRule: "evenodd", + }); + return { fill, index }; +}; diff --git a/packages/bucket/src/flood.ts b/packages/bucket/src/flood.ts new file mode 100644 index 0000000..387cf1f --- /dev/null +++ b/packages/bucket/src/flood.ts @@ -0,0 +1,200 @@ +/** + * The pixels of an image, as `getImageData` of a canvas returns them: four + * bytes (red, green, blue, alpha) for each pixel, row by row + */ +export interface Pixels { + data: Uint8ClampedArray | Uint8Array; + width: number; + height: number; +} + +/** + * An area of an image + */ +export interface Region { + /** 1 for the pixels of the area and 0 for the others, row by row */ + mask: Uint8Array; + width: number; + height: number; + /** The bounds of the area, the first and the last pixel it has in a row and in a column */ + left: number; + top: number; + right: number; + bottom: number; + /** The number of pixels of the area */ + area: number; + /** + * Whether the area reaches the edge of the image. Then it is not known to + * be closed, as what closes it can be out of the image. + */ + open: boolean; +} + +/** How different a pixel can be from the one pressed, and be of the same area */ +export const DEFAULT_TOLERANCE = 32; + +/** + * Find the area of the pixels which are of the same color as the pixel at + * (x, y), and are connected to it, left, right, up or down. + * + * Colors are compared with their alpha applied, so a faint pixel of a red + * line is as close to a transparent pixel as a faint pixel of a black one. + * Two colors are of the same area when none of their four channels is further + * apart than the tolerance (0 to 255). + * + * @returns the area, or null when the point is out of the image + */ +export const floodFill = ( + { data, width, height }: Pixels, + x: number, + y: number, + tolerance: number = DEFAULT_TOLERANCE +): Region | null => { + x = Math.floor(x); + y = Math.floor(y); + if (!(x >= 0 && y >= 0 && x < width && y < height)) return null; + + const seed = (y * width + x) * 4; + const seedAlpha = data[seed + 3]; + const seedRed = (data[seed] * seedAlpha) / 255; + const seedGreen = (data[seed + 1] * seedAlpha) / 255; + const seedBlue = (data[seed + 2] * seedAlpha) / 255; + + /** Whether the pixel (its index in the image) has the color of the area */ + const similar = (pixel: number): boolean => { + const at = pixel * 4; + const alpha = data[at + 3]; + return ( + Math.abs(alpha - seedAlpha) <= tolerance && + Math.abs((data[at] * alpha) / 255 - seedRed) <= tolerance && + Math.abs((data[at + 1] * alpha) / 255 - seedGreen) <= tolerance && + Math.abs((data[at + 2] * alpha) / 255 - seedBlue) <= tolerance + ); + }; + + const mask = new Uint8Array(width * height); + const region: Region = { + mask, + width, + height, + left: x, + top: y, + right: x, + bottom: y, + area: 0, + open: false, + }; + + // Fills a run of pixels of a row at once, then looks for the runs above and + // below it which are not filled yet + const stack = [x, y]; + while (stack.length) { + const row = stack.pop() as number; + const column = stack.pop() as number; + const start = row * width; + if (mask[start + column] || !similar(start + column)) continue; + + let first = column; + while (first > 0 && !mask[start + first - 1] && similar(start + first - 1)) { + first--; + } + let last = column; + while ( + last < width - 1 && + !mask[start + last + 1] && + similar(start + last + 1) + ) { + last++; + } + mask.fill(1, start + first, start + last + 1); + + region.area += last - first + 1; + region.left = Math.min(region.left, first); + region.right = Math.max(region.right, last); + region.top = Math.min(region.top, row); + region.bottom = Math.max(region.bottom, row); + if (first === 0 || last === width - 1 || row === 0 || row === height - 1) { + region.open = true; + } + + for (const next of [row - 1, row + 1]) { + if (next < 0 || next >= height) continue; + const nextStart = next * width; + let inRun = false; + for (let i = first; i <= last; i++) { + const open = !mask[nextStart + i] && similar(nextStart + i); + if (open && !inRun) stack.push(i, next); + inRun = open; + } + } + } + + return region; +}; + +/** + * Grow an area by `radius` pixels in every direction, over the pixels which + * are painted in `cover` (the ones with some alpha), and no further. + * + * It is how a fill goes under the lines around it: the edge of a line is + * smooth, its pixels are neither the color of the line nor the one of the area, + * so an area which stops at them leaves a thin seam. Grown under the lines it + * does not, and it does not spread to where nothing is drawn over it either. + */ +export const grow = (region: Region, cover: Pixels, radius: number): Region => { + if (radius <= 0) return region; + const { mask, width, height } = region; + const left = Math.max(region.left - radius, 0); + const top = Math.max(region.top - radius, 0); + const right = Math.min(region.right + radius, width - 1); + const bottom = Math.min(region.bottom + radius, height - 1); + + // The pixels at most `radius` away in a row, then in a column: the pixels + // at most `radius` away in both + const rows = new Uint8Array(width * height); + for (let y = top; y <= bottom; y++) { + for (let x = left; x <= right; x++) { + const from = Math.max(x - radius, 0); + const to = Math.min(x + radius, width - 1); + for (let i = from; i <= to; i++) { + if (mask[y * width + i]) { + rows[y * width + x] = 1; + break; + } + } + } + } + + const grown = mask.slice(); + const bounds = { ...region, mask: grown, area: 0 }; + let first = true; + for (let y = top; y <= bottom; y++) { + for (let x = left; x <= right; x++) { + const pixel = y * width + x; + if (!grown[pixel]) { + const from = Math.max(y - radius, 0); + const to = Math.min(y + radius, height - 1); + let reached = false; + for (let j = from; j <= to && !reached; j++) { + reached = rows[j * width + x] === 1; + } + // Only where something is drawn over the fill + if (reached && cover.data[pixel * 4 + 3] > 0) grown[pixel] = 1; + } + if (grown[pixel]) { + bounds.area++; + if (first) { + bounds.left = bounds.right = x; + bounds.top = bounds.bottom = y; + first = false; + } else { + bounds.left = Math.min(bounds.left, x); + bounds.right = Math.max(bounds.right, x); + bounds.top = Math.min(bounds.top, y); + bounds.bottom = Math.max(bounds.bottom, y); + } + } + } + } + return bounds; +}; diff --git a/packages/bucket/src/index.ts b/packages/bucket/src/index.ts new file mode 100644 index 0000000..8dbfc9d --- /dev/null +++ b/packages/bucket/src/index.ts @@ -0,0 +1,14 @@ +export { + bucketFill, + type BucketFillOptions, + type BucketFillResult, +} from "./bucketFill"; +export { FillPath } from "./FillPath"; +export { + DEFAULT_TOLERANCE, + floodFill, + grow, + type Pixels, + type Region, +} from "./flood"; +export { outlinePath, simplify, traceOutline, type Point } from "./outline"; diff --git a/packages/bucket/src/outline.ts b/packages/bucket/src/outline.ts new file mode 100644 index 0000000..a93e891 --- /dev/null +++ b/packages/bucket/src/outline.ts @@ -0,0 +1,175 @@ +import type { Region } from "./flood"; + +export interface Point { + x: number; + y: number; +} + +/** The directions of an edge between two pixel corners, clockwise: right, down, left, up */ +const DX = [1, 0, -1, 0]; +const DY = [0, 1, 0, -1]; + +/** + * Trace the outline of an area: one closed loop for its outer edge, and one + * for the edge of each hole in it. The points are the corners of the pixels, + * so a pixel at (x, y) is the square from (x, y) to (x + 1, y + 1). + * + * The loops of the outer edges run clockwise (on a screen, where y goes down) + * and the ones of the holes counterclockwise. Pixels which touch at a corner + * only are separate loops, so a fill does not have a pinch where they meet. + */ +export const traceOutline = (region: Region): Point[][] => { + const { mask, width, height, left, top, right, bottom } = region; + const stride = width + 1; + const filled = (x: number, y: number): boolean => + x >= 0 && y >= 0 && x < width && y < height && mask[y * width + x] === 1; + + // The edges between a pixel of the area and one which is not, with the area + // on the right of the way they go, indexed by the corner they start from + const startX: number[] = []; + const startY: number[] = []; + const direction: number[] = []; + const from = new Map(); + const edge = (x: number, y: number, way: number): void => { + const key = y * stride + x; + const list = from.get(key); + if (list) list.push(startX.length); + else from.set(key, [startX.length]); + startX.push(x); + startY.push(y); + direction.push(way); + }; + for (let y = top; y <= bottom; y++) { + for (let x = left; x <= right; x++) { + if (!filled(x, y)) continue; + if (!filled(x, y - 1)) edge(x, y, 0); + if (!filled(x + 1, y)) edge(x + 1, y, 1); + if (!filled(x, y + 1)) edge(x + 1, y + 1, 2); + if (!filled(x - 1, y)) edge(x, y + 1, 3); + } + } + + // Follow the edges, from one to the next, until they are back where they + // started. Where two edges leave a corner, the one which turns right keeps + // to the pixel the loop is around, so the loops stay apart. + const used = new Uint8Array(startX.length); + const loops: Point[][] = []; + for (let first = 0; first < startX.length; first++) { + if (used[first]) continue; + const loop: Point[] = []; + let previous = -1; + let current = first; + while (current !== -1) { + used[current] = 1; + // Only the corners where the way changes are points of the loop + if (previous === -1 || direction[previous] !== direction[current]) { + loop.push({ x: startX[current], y: startY[current] }); + } + const way = direction[current]; + const x = startX[current] + DX[way]; + const y = startY[current] + DY[way]; + const candidates = (from.get(y * stride + x) ?? []).filter((e) => !used[e]); + previous = current; + current = -1; + for (const turn of [1, 0, 3]) { + const found = candidates.find((e) => direction[e] === (way + turn) % 4); + if (found !== undefined) { + current = found; + break; + } + } + } + // The first corner is one only if the way changes where the loop closes + if (loop.length > 1 && direction[previous] === direction[first]) loop.shift(); + loops.push(loop); + } + return loops; +}; + +/** Distance of the point from the line through a and b, and from a when they are the same */ +const distance = (point: Point, a: Point, b: Point): number => { + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy); + if (length === 0) return Math.hypot(point.x - a.x, point.y - a.y); + return Math.abs(dx * (a.y - point.y) - (a.x - point.x) * dy) / length; +}; + +/** Ramer-Douglas-Peucker on a line, which keeps the first and the last point */ +const simplifyLine = (points: Point[], tolerance: number): Point[] => { + const keep = new Uint8Array(points.length); + keep[0] = keep[points.length - 1] = 1; + const ranges: [number, number][] = [[0, points.length - 1]]; + while (ranges.length) { + const [start, end] = ranges.pop() as [number, number]; + let farthest = -1; + let most = tolerance; + for (let i = start + 1; i < end; i++) { + const away = distance(points[i], points[start], points[end]); + if (away > most) { + most = away; + farthest = i; + } + } + if (farthest !== -1) { + keep[farthest] = 1; + ranges.push([start, farthest], [farthest, end]); + } + } + return points.filter((_, i) => keep[i]); +}; + +/** + * Take the points of a closed loop which a straight line cannot do without: + * the ones the loop is further than `tolerance` pixels from the line between + * the points on both sides of them. The steps of a diagonal or of a curve + * of pixels become a line. + * + * @returns the points, fewer than 3 when the loop is too flat to be one + */ +export const simplify = (loop: Point[], tolerance: number): Point[] => { + if (tolerance <= 0 || loop.length < 4) return loop; + // A closed loop has no ends to keep, so it is cut in two where the point + // is the furthest from the first one + let far = 0; + let most = -1; + loop.forEach((point, i) => { + const away = Math.hypot(point.x - loop[0].x, point.y - loop[0].y); + if (away > most) { + most = away; + far = i; + } + }); + const one = simplifyLine(loop.slice(0, far + 1), tolerance); + const other = simplifyLine([...loop.slice(far), loop[0]], tolerance); + return [...one.slice(0, -1), ...other.slice(0, -1)]; +}; + +/** + * The outline of an area as the data of a path, in the coordinates the + * `place` function gives to the pixel corners. The loops which are too small + * to have an area are left out. + * + * @param simplifyBy see {@link simplify} + * @param place maps a corner of a pixel to the point it is on the path + */ +export const outlinePath = ( + region: Region, + simplifyBy: number, + place: (point: Point) => Point = (point) => point +): string => { + const parts: string[] = []; + for (const loop of traceOutline(region)) { + const points = simplify(loop, simplifyBy); + if (points.length < 3) continue; + const text = points.map((point) => { + const { x, y } = place(point); + return `${round(x)} ${round(y)}`; + }); + parts.push(`M ${text.join(" L ")} Z`); + } + return parts.join(" "); +}; + +/** Two decimals are enough for a point, and keep the data short */ +const round = (value: number): number => Math.round(value * 100) / 100; diff --git a/packages/bucket/src/render.ts b/packages/bucket/src/render.ts new file mode 100644 index 0000000..2761ad6 --- /dev/null +++ b/packages/bucket/src/render.ts @@ -0,0 +1,93 @@ +import * as fabric from "fabric"; +import { FillPath } from "./FillPath"; +import type { Pixels } from "./flood"; + +interface Rendering { + /** Which objects are drawn */ + include: (object: fabric.FabricObject) => boolean; + /** Whether the overlay image is drawn. Defaults to true */ + overlay?: boolean; + /** The part of the canvas, in canvas pixels. Defaults to all of it */ + area?: { left: number; top: number; width: number; height: number }; +} + +/** + * The pixels of the objects of the canvas, as they are seen: with the zoom and + * the position of the view, one pixel for each pixel of the canvas (not of the + * screen). The background (the color and the image) is not drawn, so an area is + * closed by what is drawn on the canvas only, whatever is behind it. + * + * It throws when the canvas is tainted, as it is by an image of a server without + * CORS headers. + */ +export const renderPixels = ( + canvas: fabric.StaticCanvas, + { include, overlay = true, area }: Rendering +): Pixels => { + const { backgroundColor, backgroundImage, overlayColor, overlayImage } = canvas; + canvas.backgroundColor = ""; + canvas.backgroundImage = undefined; + canvas.overlayColor = ""; + if (!overlay) canvas.overlayImage = undefined; + let element: HTMLCanvasElement; + try { + element = canvas.toCanvasElement(1, { + ...area, + filter: (object) => include(object as fabric.FabricObject), + }); + } finally { + canvas.backgroundColor = backgroundColor; + canvas.backgroundImage = backgroundImage; + canvas.overlayColor = overlayColor; + canvas.overlayImage = overlayImage; + } + const { width, height } = element; + const context = element.getContext("2d") as CanvasRenderingContext2D; + const { data } = context.getImageData(0, 0, width, height); + return { data, width, height }; +}; + +/** Whether the object is a fill, which does not close an area */ +export const isFill = (object: fabric.FabricObject): boolean => + object instanceof FillPath; + +/** + * The index of the topmost object which paints the pixel (in canvas pixels), or + * -1 when there is none + */ +export const paintedBy = ( + canvas: fabric.StaticCanvas, + x: number, + y: number +): number => { + const objects = canvas.getObjects(); + const view = fabric.util.invertTransform(canvas.viewportTransform); + const point = new fabric.Point(x + 0.5, y + 0.5).transform(view); + for (let i = objects.length - 1; i >= 0; i--) { + const object = objects[i]; + if (!object.visible) continue; + // The bounds of the object, with a margin for the edge of its line + const { left, top, width, height } = object.getBoundingRect(); + const margin = 2; + if ( + point.x < left - margin || + point.y < top - margin || + point.x > left + width + margin || + point.y > top + height + margin + ) { + continue; + } + try { + const { data } = renderPixels(canvas, { + include: (other) => other === object, + overlay: false, + area: { left: x, top: y, width: 1, height: 1 }, + }); + if (data[3] > 0) return i; + } catch { + // Its pixels cannot be read (a tainted canvas), and it is where it is + return i; + } + } + return -1; +}; diff --git a/packages/bucket/tsconfig.build.json b/packages/bucket/tsconfig.build.json new file mode 100644 index 0000000..000607d --- /dev/null +++ b/packages/bucket/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "rootDir": "src", + "outDir": "dist" + }, + "exclude": ["dist", "node_modules"] +} diff --git a/packages/bucket/tsconfig.json b/packages/bucket/tsconfig.json new file mode 100644 index 0000000..46d5116 --- /dev/null +++ b/packages/bucket/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "node16", + "moduleResolution": "node16", + "lib": ["dom", "dom.iterable", "esnext"], + "types": [], + "strict": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/react-sketch/package.json b/packages/react-sketch/package.json index 63a08a5..28bf4b6 100644 --- a/packages/react-sketch/package.json +++ b/packages/react-sketch/package.json @@ -46,6 +46,7 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { + "@react-sketch/bucket": "1.2.0", "@react-sketch/eraser": "1.2.2", "@react-sketch/laserpointer": "1.1.0", "@testing-library/dom": "^10.4.2", From 21ac93ca951f987fff0348f23c3a01e7e04c7b48 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 6 Nov 2026 16:07:37 +0200 Subject: [PATCH 2/4] feat: add the bucket fill tool tool="bucket-fill" (Tools.BucketFill, Types.Tool.BUCKET_FILL) fills the area which is closed around a click with fillColor, or lineColor while fillColor is transparent. A click where the area is not closed does nothing. The fill goes under the lines which close the area, so the history keeps the place of an object which is not on top: undo of a removal and redo of an addition put it back where it was, and not over the lines. --- .../SketchField/SketchField.spec.tsx | 3 +- .../SketchField/history/SketchHistory.ts | 26 +- .../tools/BucketFill/BucketFill.spec.tsx | 306 ++++++++++ .../tools/BucketFill/BucketFill.ts | 79 +++ .../tools/BucketFill/algorithm.spec.ts | 412 +++++++++++++ .../tools/BucketFill/bucketFill.spec.ts | 567 ++++++++++++++++++ .../SketchField/tools/BucketFill/index.ts | 1 + .../components/SketchField/tools/factory.ts | 4 + packages/react-sketch/src/index.spec.tsx | 1 + packages/react-sketch/src/types.ts | 7 +- 10 files changed, 1398 insertions(+), 8 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.spec.tsx create mode 100644 packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/BucketFill/algorithm.spec.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/BucketFill/bucketFill.spec.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/BucketFill/index.ts diff --git a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx index 9dd6b9f..e1b8d7f 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx @@ -1508,8 +1508,9 @@ describe("SketchField", () => { expect(ids(canvas)).toEqual(["rect2"]); expect(sketch.hasSelection()).toBe(false); + // It is back where it was, under the object which is above it sketch.undo(); - expect(ids(canvas)).toEqual(["rect2", "rect1"]); + expect(ids(canvas)).toEqual(["rect1", "rect2"]); }); it("removes the object again on redo", () => { diff --git a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts index 0be7ae0..8502711 100644 --- a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts +++ b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts @@ -3,11 +3,14 @@ import { UndoHistory } from "./index"; import type { Erasure } from "../tools/Eraser"; /** - * One undoable action of the sketch + * One undoable action of the sketch. `index` is where the object is among the + * others, for the actions which put it back (a fill goes under the objects which + * close it, and it is not put back on top of them). It is not there for an object + * which is on top. */ type HistoryEntry = - | { action: "add"; object: fabric.FabricObject } - | { action: "remove"; object: fabric.FabricObject } + | { action: "add"; object: fabric.FabricObject; index?: number } + | { action: "remove"; object: fabric.FabricObject; index?: number } | { action: "modify"; object: fabric.FabricObject; before: string; after: string } | { action: "erase"; erasures: Erasure[] }; @@ -57,7 +60,7 @@ export class SketchHistory { onObjectAdded(object: fabric.FabricObject): void { this.snapshots.set(object, stateOf(object)); this.pending = object; - this.entries.keep({ action: "add", object }); + this.entries.keep({ action: "add", object, index: this.placeOf(object) }); } /** @@ -111,11 +114,21 @@ export class SketchHistory { */ remove(objects: fabric.FabricObject[]): void { objects.forEach((object) => { - this.entries.keep({ action: "remove", object }); + this.entries.keep({ action: "remove", object, index: this.placeOf(object) }); this.canvas.remove(object); }); } + /** + * Where the object is among the objects of the canvas, unless it is on top + * (the place of an object which is put back on top is the end of the canvas) + */ + private placeOf(object: fabric.FabricObject): number | undefined { + const objects = this.canvas.getObjects(); + const index = objects.indexOf(object); + return index === -1 || index === objects.length - 1 ? undefined : index; + } + /** * Undo the last action * @@ -191,7 +204,8 @@ export class SketchHistory { case "remove": // redo of an add, and undo of a remove, put the object back if ((entry.action === "add") === forward) { - this.canvas.add(object); + if (entry.index === undefined) this.canvas.add(object); + else this.canvas.insertAt(entry.index, object); } else { this.canvas.remove(object); } diff --git a/packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.spec.tsx new file mode 100644 index 0000000..1863e50 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.spec.tsx @@ -0,0 +1,306 @@ +import { createRef } from "react"; +import { act, render } from "@testing-library/react"; +import * as fabric from "fabric"; +import { bucketFill, FillPath } from "@react-sketch/bucket"; +import SketchField from "../../SketchField"; +import { SketchProperties, Tool, Tools } from "../../../../types"; + +// The pixels of a fill are the matter of bucketFill.spec: here it is what the tool and the history do with one +jest.mock("@react-sketch/bucket", () => ({ + ...jest.requireActual("@react-sketch/bucket"), + bucketFill: jest.fn(), +})); + +const bucketFillMock = bucketFill as jest.Mock; + +type Point = [number, number]; +type Identified = fabric.FabricObject & { id?: string }; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { + ...view, + sketch, + canvas: sketch["canvas"] as fabric.Canvas, + update: (next: SketchProperties) => + view.rerender(), + }; +}; + +const fire = (canvas: fabric.Canvas, name: string, event: object): void => + (canvas as unknown as fabric.Observable>).fire(name, event); + +const mouseEvent = (type: string, [x, y]: Point): { e: MouseEvent } => ({ + e: new MouseEvent(type, { clientX: x, clientY: y }), +}); + +/** Press the mouse at the point and release it, as a click does */ +const click = (canvas: fabric.Canvas, point: Point = [30, 40]): void => { + fire(canvas, "mouse:down", mouseEvent("mousedown", point)); + fire(canvas, "mouse:up", mouseEvent("mouseup", point)); +}; + +/** A fill, with an id to tell it among the objects */ +const aFill = (id = "fill", color = "#0000ff"): FillPath => { + const path = new FillPath("M 0 0 L 10 0 L 10 10 Z", { fill: color }); + (path as Identified).id = id; + return path; +}; + +/** The bucket finds an area, and it is to go at the index among the objects */ +const fillsAt = (index: number, id?: string): void => { + bucketFillMock.mockReturnValueOnce({ fill: aFill(id), index }); +}; + +const ids = (canvas: fabric.Canvas): (string | undefined)[] => + canvas.getObjects().map((object: Identified) => object.id); + +/** Two rectangles under the tool of rectangles, then the bucket is the tool */ +const withTwoRectangles = (props: SketchProperties = {}) => { + const view = mount({ ...props, tool: Tool.RECTANGLE }); + ["rect1", "rect2"].forEach((id, i) => { + fire(view.canvas, "mouse:down", mouseEvent("mousedown", [0, 0])); + fire(view.canvas, "mouse:move", mouseEvent("mousemove", [10 + i, 10 + i])); + fire(view.canvas, "mouse:up", mouseEvent("mouseup", [10 + i, 10 + i])); + (view.canvas.getObjects().at(-1) as Identified).id = id; + }); + view.update(props); + return view; +}; + +describe("BucketFill", () => { + beforeEach(() => { + bucketFillMock.mockReset(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("is the bucket fill tool, by the name of the enumeration and the one of 0.5.x", () => { + expect(Tool.BUCKET_FILL).toBe("bucket-fill"); + expect(Tools.BucketFill).toBe(Tool.BUCKET_FILL); + expect(mount().sketch["tool"].type()).toBe(Tool.BUCKET_FILL); + }); + + describe("the color", () => { + const colorOf = (props: SketchProperties): string => { + const { canvas } = mount(props); + click(canvas); + return bucketFillMock.mock.calls[0][2].color; + }; + + it("is the fill color", () => { + expect(colorOf({ fillColor: "blue", lineColor: "green" })).toBe("blue"); + }); + + it("is the line color while the fill color is transparent, as it is by default", () => { + expect(colorOf({ lineColor: "green" })).toBe("green"); + expect(colorOf({ fillColor: "transparent", lineColor: "green" })).toBe( + "green" + ); + expect(colorOf({ fillColor: "rgba(0, 0, 0, 0)", lineColor: "green" })).toBe( + "green" + ); + }); + + it("is black when both are transparent", () => { + expect(colorOf({ fillColor: "transparent", lineColor: "transparent" })).toBe( + "black" + ); + }); + + it("follows the properties when they change", () => { + const { canvas, update } = mount({ fillColor: "blue" }); + update({ fillColor: "red" }); + + click(canvas); + + expect(bucketFillMock.mock.calls[0][2].color).toBe("red"); + }); + }); + + describe("a click", () => { + it("asks for the fill of the point, on the canvas", () => { + const { canvas } = mount({ fillColor: "blue" }); + + click(canvas, [30, 40]); + + expect(bucketFillMock).toHaveBeenCalledTimes(1); + const [on, point, options] = bucketFillMock.mock.calls[0]; + expect(on).toBe(canvas); + expect(point).toEqual(expect.objectContaining({ x: 30, y: 40 })); + expect(options).toEqual({ color: "blue" }); + }); + + it("puts the fill on the canvas, where it is to go among the objects", () => { + const { canvas } = withTwoRectangles(); + fillsAt(1); + + click(canvas); + + expect(ids(canvas)).toEqual(["rect1", "fill", "rect2"]); + }); + + it("is one action of the sketch, which is told to the application", () => { + const onObjectAdded = jest.fn(); + const onChange = jest.fn(); + const { canvas } = mount({ onObjectAdded, onChange }); + fillsAt(0); + + click(canvas); + act(() => jest.advanceTimersByTime(20)); + + expect(onObjectAdded).toHaveBeenCalledTimes(1); + expect((onObjectAdded.mock.calls[0][0].target as Identified).id).toBe("fill"); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("does nothing where there is nothing to fill", () => { + const onObjectAdded = jest.fn(); + const onChange = jest.fn(); + const { canvas, sketch } = mount({ onObjectAdded, onChange }); + bucketFillMock.mockReturnValue(null); + + click(canvas); + act(() => jest.advanceTimersByTime(20)); + + expect(bucketFillMock).toHaveBeenCalledTimes(1); + expect(canvas.getObjects()).toHaveLength(0); + expect(onObjectAdded).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(sketch.canUndo()).toBe(false); + }); + + it("does nothing without the event of the pointer", () => { + const { canvas } = mount(); + + fire(canvas, "mouse:down", {}); + + expect(bucketFillMock).not.toHaveBeenCalled(); + }); + + it("fills what the click is on, as the objects are frozen and are not picked", () => { + const { canvas } = withTwoRectangles(); + fillsAt(0); + + click(canvas); + + expect(canvas.selection).toBe(false); + canvas.getObjects().forEach((object) => { + expect(object.selectable).toBe(false); + expect(object.evented).toBe(false); + }); + }); + }); + + describe("undo and redo", () => { + it("takes the fill off with undo, and puts it back where it was with redo", () => { + const { canvas, sketch } = withTwoRectangles(); + fillsAt(0); + click(canvas); + expect(ids(canvas)).toEqual(["fill", "rect1", "rect2"]); + + sketch.undo(); + expect(ids(canvas)).toEqual(["rect1", "rect2"]); + + // Not over the rectangles, which would hide the lines + sketch.redo(); + expect(ids(canvas)).toEqual(["fill", "rect1", "rect2"]); + }); + + it("keeps the place of a fill which is between objects", () => { + const { canvas, sketch } = withTwoRectangles(); + fillsAt(1); + click(canvas); + + sketch.undo(); + sketch.redo(); + + expect(ids(canvas)).toEqual(["rect1", "fill", "rect2"]); + }); + + it("puts the fills back in their places, one after the other", () => { + const { canvas, sketch } = withTwoRectangles(); + fillsAt(0, "first"); + click(canvas); + fillsAt(1, "second"); + click(canvas); + expect(ids(canvas)).toEqual(["first", "second", "rect1", "rect2"]); + + sketch.undo(); + sketch.undo(); + expect(ids(canvas)).toEqual(["rect1", "rect2"]); + sketch.redo(); + sketch.redo(); + + expect(ids(canvas)).toEqual(["first", "second", "rect1", "rect2"]); + }); + + it("puts a fill which was removed back in its place with undo", () => { + const { canvas, sketch } = withTwoRectangles(); + fillsAt(0); + click(canvas); + canvas.setActiveObject(canvas.getObjects()[0]); + + sketch.removeSelected(); + expect(ids(canvas)).toEqual(["rect1", "rect2"]); + sketch.undo(); + + expect(ids(canvas)).toEqual(["fill", "rect1", "rect2"]); + }); + + it("puts the objects which are on top back on top, as before", () => { + const { canvas, sketch } = withTwoRectangles(); + canvas.setActiveObject(canvas.getObjects()[1]); + + sketch.removeSelected(); + sketch.undo(); + + expect(ids(canvas)).toEqual(["rect1", "rect2"]); + }); + }); + + describe("the cursor", () => { + const cursorOf = (canvas: fabric.Canvas): string => canvas.defaultCursor; + /** The colors in a cursor are in hexadecimal, in whichever case */ + const drawnOf = (canvas: fabric.Canvas): string => + decodeURIComponent(cursorOf(canvas)).toLowerCase(); + + it("is a bucket with a drop of the color, which falls back to the crosshair", () => { + const { canvas } = mount({ fillColor: "#0000ff" }); + + const cursor = cursorOf(canvas); + + expect(cursor).toMatch( + /^url\("data:image\/svg\+xml,.+"\) \d+ \d+, crosshair$/ + ); + expect(drawnOf(canvas)).toContain('fill="#0000ff"'); + }); + + it("has the color which fills, the line color when the fill color is transparent", () => { + const { canvas } = mount({ lineColor: "#00ff00" }); + + expect(drawnOf(canvas)).toContain('fill="#00ff00"'); + }); + + it("follows the color", () => { + const { canvas, update } = mount({ fillColor: "#0000ff" }); + + update({ fillColor: "#ff0000" }); + + expect(drawnOf(canvas)).toContain('fill="#ff0000"'); + }); + + it("is the one of the pointer again with another tool", () => { + const { canvas, update } = mount({ fillColor: "#0000ff" }); + + update({ tool: Tool.RECTANGLE }); + + expect(cursorOf(canvas)).toBe("default"); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.ts b/packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.ts new file mode 100644 index 0000000..a159a88 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/BucketFill/BucketFill.ts @@ -0,0 +1,79 @@ +import * as fabric from "fabric"; +import { bucketFill } from "@react-sketch/bucket"; +import { CanvasEvent } from "../../events"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; + +/** Whether the color is one which paints nothing */ +const isTransparent = (color?: string): boolean => + !color || new fabric.Color(color).getAlpha() === 0; + +/** + * The color to fill with: the fill color, and the line color while the fill + * color is transparent (its default), as there is nothing to fill with then + */ +const fillWith = ({ fillColor, lineColor }: SketchProperties): string => { + if (!isTransparent(fillColor)) return fillColor as string; + return isTransparent(lineColor) ? "black" : (lineColor as string); +}; + +/** + * The cursor of the bucket: a bucket with a drop of the color it fills with, + * outlined in black and white so that it shows on any color. The hot spot is + * the drop, where the bucket pours. + */ +const bucketCursor = (color: string): string => { + const parsed = new fabric.Color(color); + const body = ``; + const handle = ``; + const bucket = `${body}${handle}`; + const dropPath = + "M5 22C5 22 1.5 26.5 1.5 28.5A3.5 3.5 0 0 0 8.5 28.5C8.5 26.5 5 22 5 22Z"; + const drop = (attributes: string) => ``; + const svg = + `` + + // The white outline, under the black one + `${bucket}${drop("")}` + + `${bucket}` + + drop( + `fill="#${parsed.toHex()}" fill-opacity="${parsed.getAlpha()}" stroke="black" stroke-width="1.2"` + ) + + ``; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`; +}; + +/** + * Fills the area closed around the point of a click with the color: by lines, + * rectangles, circles, polygons, strokes of the pencil, or the overlay image, + * as long as the area is closed within the canvas. A click where the area is + * not closed, or where there is nothing to fill, does nothing. + * + * The fill is an object of the sketch, a path under the objects which close it, + * so it is one undo step, it is saved by `toJSON()`, and it scales with the canvas. + */ +export class BucketFill extends FabricCanvasTool { + private color = "black"; + + constructor(canvas: fabric.Canvas) { + super(canvas); + } + + configureCanvas(props: SketchProperties): void { + this.setInteractive(false); + this.color = fillWith(props); + this.canvas.defaultCursor = bucketCursor(this.color); + } + + type(): Tool { + return Tool.BUCKET_FILL; + } + + doMouseDown(event: CanvasEvent): void { + if (!event.e) return; + const point = this.canvas.getViewportPoint(event.e); + const filled = bucketFill(this.canvas, point, { color: this.color }); + if (!filled) return; + this.canvas.insertAt(filled.index, filled.fill); + this.canvas.requestRenderAll(); + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/BucketFill/algorithm.spec.ts b/packages/react-sketch/src/components/SketchField/tools/BucketFill/algorithm.spec.ts new file mode 100644 index 0000000..0bee164 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/BucketFill/algorithm.spec.ts @@ -0,0 +1,412 @@ +import { + floodFill, + grow, + outlinePath, + simplify, + traceOutline, + type Pixels, + type Point, + type Region, +} from "@react-sketch/bucket"; + +type Color = [number, number, number, number]; + +const CLEAR: Color = [0, 0, 0, 0]; +const BLACK: Color = [0, 0, 0, 255]; + +/** An image of the given size, each pixel painted by `paint` (transparent where it gives nothing) */ +const image = ( + width: number, + height: number, + paint: (x: number, y: number) => Color | undefined +): Pixels => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + data.set(paint(x, y) ?? CLEAR, (y * width + x) * 4); + } + } + return { data, width, height }; +}; + +/** The outline of a rectangle of the given thickness, which has its first pixel at (left, top) */ +const outline = + (left: number, top: number, right: number, bottom: number, thickness = 1) => + (x: number, y: number): Color | undefined => { + const inside = x >= left && x <= right && y >= top && y <= bottom; + const hollow = + x >= left + thickness && + x <= right - thickness && + y >= top + thickness && + y <= bottom - thickness; + return inside && !hollow ? BLACK : undefined; + }; + +/** A region made of the pixels of a picture drawn with "#" */ +const regionOf = (...rows: string[]): Region => { + const height = rows.length; + const width = rows[0].length; + const mask = new Uint8Array(width * height); + let area = 0; + rows.forEach((row, y) => + [...row].forEach((char, x) => { + if (char === "#") { + mask[y * width + x] = 1; + area++; + } + }) + ); + return { + mask, + width, + height, + left: 0, + top: 0, + right: width - 1, + bottom: height - 1, + area, + open: false, + }; +}; + +/** Twice the area the loop encloses, positive for the loops which run clockwise on a screen */ +const signedArea = (loop: Point[]): number => + loop.reduce((sum, a, i) => { + const b = loop[(i + 1) % loop.length]; + return sum + (a.x * b.y - b.x * a.y); + }, 0) / 2; + +describe("floodFill", () => { + const box = image(20, 20, outline(4, 4, 15, 15)); + + it("finds the area which a rectangle closes, and the line is not in it", () => { + const region = floodFill(box, 10, 10) as Region; + + expect(region.open).toBe(false); + expect(region.area).toBe(10 * 10); + expect([region.left, region.top, region.right, region.bottom]).toEqual([ + 5, 5, 14, 14, + ]); + expect(region.mask[10 * 20 + 10]).toBe(1); + expect(region.mask[4 * 20 + 10]).toBe(0); + }); + + it("is open when the area reaches the edge of the image", () => { + const region = floodFill(box, 1, 1) as Region; + + expect(region.open).toBe(true); + expect(region.area).toBe(20 * 20 - 12 * 12); + }); + + it("is open when it reaches the edge at one side only", () => { + // A box which is not closed at the top, where it is at the edge of the image + const cup = image(10, 10, (x, y) => + ((x === 2 || x === 7) && y <= 6) || (y === 6 && x >= 2 && x <= 7) + ? BLACK + : undefined + ); + + const region = floodFill(cup, 4, 3) as Region; + + expect(region.area).toBe(4 * 6); + expect(region.open).toBe(true); + }); + + it("finds nothing outside the image", () => { + expect(floodFill(box, -1, 5)).toBeNull(); + expect(floodFill(box, 5, 20)).toBeNull(); + expect(floodFill(box, 20, 5)).toBeNull(); + }); + + it("takes the pixel which is pressed as it is, so the line itself is an area", () => { + const region = floodFill(box, 4, 8) as Region; + + expect(region.area).toBe(12 * 12 - 10 * 10); + expect(region.open).toBe(false); + }); + + it("does not go diagonally through a line of pixels which touch at their corners", () => { + const diagonal = image(10, 10, (x, y) => (x === y ? BLACK : undefined)); + + const region = floodFill(diagonal, 7, 2) as Region; + + expect(region.mask[2 * 10 + 7]).toBe(1); + expect(region.mask[7 * 10 + 2]).toBe(0); + }); + + describe("tolerance", () => { + const faint = (alpha: number) => + image(3, 1, (x) => (x === 1 ? [255, 0, 0, alpha] : undefined)); + + it("joins the pixels which differ less than the tolerance, and no others", () => { + expect((floodFill(faint(30), 0, 0, 32) as Region).area).toBe(3); + expect((floodFill(faint(33), 0, 0, 32) as Region).area).toBe(1); + }); + + it("does not join colors which differ at all with none", () => { + expect((floodFill(faint(1), 0, 0, 0) as Region).area).toBe(1); + expect((floodFill(faint(0), 0, 0, 0) as Region).area).toBe(3); + }); + + it("applies the alpha before it compares, so a faint pixel is as near to a transparent one whatever its color", () => { + const red = image(2, 1, (x) => (x === 1 ? [255, 0, 0, 20] : undefined)); + const black = image(2, 1, (x) => (x === 1 ? [0, 0, 0, 20] : undefined)); + + expect((floodFill(red, 0, 0, 32) as Region).area).toBe(2); + expect((floodFill(black, 0, 0, 32) as Region).area).toBe(2); + }); + + it("compares each channel", () => { + const colors = image(2, 1, (x) => + x === 1 ? [0, 0, 100, 255] : [0, 0, 60, 255] + ); + + expect((floodFill(colors, 0, 0, 39) as Region).area).toBe(1); + expect((floodFill(colors, 0, 0, 40) as Region).area).toBe(2); + }); + }); +}); + +describe("grow", () => { + const box = image(20, 20, outline(4, 4, 15, 15, 3)); + const region = floodFill(box, 10, 10) as Region; + + it("goes under the line, as far as the radius, and no further", () => { + // The line is 3 pixels thick, so the area of 6 by 6 has 2 pixels of it around + expect(region.area).toBe(6 * 6); + + const grown = grow(region, box, 2); + + expect([grown.left, grown.top, grown.right, grown.bottom]).toEqual([ + 5, 5, 14, 14, + ]); + expect(grown.area).toBe(10 * 10); + expect(grown.mask[4 * 20 + 10]).toBe(0); + expect(grown.mask[5 * 20 + 10]).toBe(1); + }); + + it("does not leave the line, where nothing is drawn over the fill", () => { + const thin = image(20, 20, outline(4, 4, 15, 15)); + const inner = floodFill(thin, 10, 10) as Region; + + const grown = grow(inner, thin, 3); + + // The line is one pixel thick: the fill goes under it, and not out of it + expect(grown.left).toBe(4); + expect(grown.right).toBe(15); + expect(grown.area).toBe(12 * 12); + }); + + it("does not grow over a pixel with nothing drawn on it", () => { + const none = image(20, 20, () => undefined); + + expect(grow(region, none, 2).area).toBe(region.area); + }); + + it("is the region itself with a radius of 0", () => { + expect(grow(region, box, 0)).toBe(region); + }); + + it("does not change the region it is given", () => { + const before = region.area; + grow(region, box, 2); + + expect(region.area).toBe(before); + expect(region.mask[4 * 20 + 10]).toBe(0); + }); + + it("stays in the image", () => { + const covered = image(10, 10, () => BLACK); + const corner = regionOf("#.........", ...Array(9).fill("..........")); + + const grown = grow(corner, covered, 3); + + expect([grown.left, grown.top, grown.right, grown.bottom]).toEqual([0, 0, 3, 3]); + expect(grown.area).toBe(4 * 4); + }); +}); + +describe("traceOutline", () => { + it("is one loop with a point at each corner for a rectangle", () => { + const loops = traceOutline(regionOf("....", ".###", ".###", "....")); + + expect(loops).toHaveLength(1); + expect(loops[0]).toHaveLength(4); + expect(loops[0]).toEqual( + expect.arrayContaining([ + { x: 1, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 3 }, + { x: 1, y: 3 }, + ]) + ); + }); + + it("runs clockwise on a screen around an area", () => { + const [loop] = traceOutline(regionOf("##", "##")); + + expect(signedArea(loop)).toBe(4); + }); + + it("has the six corners of an L", () => { + const loops = traceOutline(regionOf("#.", "#.", "##")); + + expect(loops).toHaveLength(1); + expect(loops[0]).toHaveLength(6); + expect(signedArea(loops[0])).toBe(4); + }); + + it("has a loop for the edge of a hole, which runs the other way", () => { + const loops = traceOutline( + regionOf("#####", "#...#", "#...#", "#...#", "#####") + ); + + expect(loops).toHaveLength(2); + const areas = loops.map(signedArea).sort((a, b) => a - b); + expect(areas).toEqual([-9, 25]); + }); + + it("has a loop for each separate area", () => { + const loops = traceOutline(regionOf("##..##", "##..##")); + + expect(loops).toHaveLength(2); + expect(loops.map(signedArea)).toEqual([4, 4]); + }); + + it("keeps pixels which touch at a corner apart, there is no pinch", () => { + const loops = traceOutline(regionOf("#.", ".#")); + + expect(loops).toHaveLength(2); + loops.forEach((loop) => { + expect(loop).toHaveLength(4); + expect(signedArea(loop)).toBe(1); + }); + }); + + it("has the area of the region in all its loops", () => { + const region = regionOf( + ".#####.", + "##...##", + "#..#..#", + "#.###.#", + "##...##", + ".#####." + ); + + const total = traceOutline(region) + .map(signedArea) + .reduce((sum, area) => sum + area, 0); + + expect(total).toBe(region.area); + }); + + it("has no loop for an empty region", () => { + expect(traceOutline(regionOf("..", ".."))).toEqual([]); + }); +}); + +describe("simplify", () => { + const staircase = (steps: number): Point[] => { + const loop: Point[] = [{ x: 0, y: 0 }]; + for (let i = 0; i < steps; i++) { + loop.push({ x: i + 1, y: i }, { x: i + 1, y: i + 1 }); + } + loop.push({ x: 0, y: steps }); + return loop; + }; + + it("turns the steps of a diagonal into a line", () => { + const simple = simplify(staircase(40), 1); + + expect(simple.length).toBeLessThan(8); + expect(simple).toEqual( + expect.arrayContaining([ + { x: 0, y: 0 }, + { x: 0, y: 40 }, + ]) + ); + }); + + it("keeps a corner", () => { + const square: Point[] = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ]; + + expect(simplify(square, 0.5)).toHaveLength(4); + }); + + it("leaves the loop as it is without a tolerance", () => { + const loop = staircase(10); + + expect(simplify(loop, 0)).toBe(loop); + }); + + it("leaves a loop of three points or fewer", () => { + const triangle: Point[] = [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 0, y: 5 }, + ]; + + expect(simplify(triangle, 1)).toBe(triangle); + }); + + it("keeps a bump which is higher than the tolerance, and drops one which is not", () => { + const bump = (height: number): Point[] => [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 10, y: height }, + { x: 15, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 20 }, + { x: 0, y: 20 }, + ]; + + expect(simplify(bump(3), 1).some((p) => p.y === 3)).toBe(true); + expect(simplify(bump(0.4), 1).some((p) => p.y === 0.4)).toBe(false); + }); +}); + +describe("outlinePath", () => { + it("is the data of a path, a loop after another", () => { + const region = regionOf("#####", "#...#", "#####"); + + const data = outlinePath(region, 0); + + expect(data.match(/M/g)).toHaveLength(2); + expect(data.match(/Z/g)).toHaveLength(2); + expect(data).toMatch(/^M [\d. L-]+ Z M [\d. L-]+ Z$/); + }); + + it("puts the points where the function says", () => { + const data = outlinePath(regionOf("##", "##"), 0, ({ x, y }) => ({ + x: x * 2 + 10, + y: y * 2 + 20, + })); + + expect(data).toBe("M 10 20 L 14 20 L 14 24 L 10 24 Z"); + }); + + it("rounds the points to two decimals", () => { + const data = outlinePath(regionOf("##", "##"), 0, ({ x, y }) => ({ + x: x / 3, + y: y / 3, + })); + + expect(data).toContain("0.67 0.67"); + }); + + it("leaves out a loop which is too thin to have an area once it is simplified", () => { + const strip = regionOf("####################"); + + expect(outlinePath(strip, 0)).not.toBe(""); + expect(outlinePath(strip, 2)).toBe(""); + }); + + it("is empty for an empty region", () => { + expect(outlinePath(regionOf("..", ".."), 0.5)).toBe(""); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/BucketFill/bucketFill.spec.ts b/packages/react-sketch/src/components/SketchField/tools/BucketFill/bucketFill.spec.ts new file mode 100644 index 0000000..f6b6ab0 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/BucketFill/bucketFill.spec.ts @@ -0,0 +1,567 @@ +/** + * Real pixels: the canvas is drawn by node-canvas (the node build of fabric), + * as jsdom with the canvas mock draws nothing a fill could be found in. + * + * @jest-environment node + */ +import * as fabric from "fabric"; +import { bucketFill, FillPath, type BucketFillResult } from "@react-sketch/bucket"; + +jest.mock("fabric", () => jest.requireActual("fabric/node")); + +type Color = [number, number, number, number]; + +const BLUE: Color = [0, 0, 255, 255]; +const RED: Color = [255, 0, 0, 255]; +const GREEN: Color = [0, 255, 0, 255]; +const BLACK: Color = [0, 0, 0, 255]; + +const FROZEN = { selectable: false, evented: false }; + +/** A rectangle of the outline of the given line, from its top left corner as the rectangle tool draws it */ +const rectangle = ( + left: number, + top: number, + width: number, + height: number, + options: Partial = {} +) => + new fabric.Rect({ + originX: "left", + originY: "top", + left, + top, + width, + height, + fill: "transparent", + stroke: "black", + strokeWidth: 4, + strokeUniform: true, + ...FROZEN, + ...options, + }); + +const line = (x1: number, y1: number, x2: number, y2: number, strokeWidth = 4) => + new fabric.Line([x1, y1, x2, y2], { stroke: "black", strokeWidth, ...FROZEN }); + +/** The pixels as the sketch shows them: what is drawn, the fills too */ +const shown = (canvas: fabric.Canvas) => { + const element = canvas.toCanvasElement(1); + const { data, width } = ( + element.getContext("2d") as CanvasRenderingContext2D + ).getImageData(0, 0, element.width, element.height); + return (x: number, y: number): Color => { + const at = (y * width + x) * 4; + return [data[at], data[at + 1], data[at + 2], data[at + 3]]; + }; +}; + +/** Whether the colors are the same, a little anti-aliasing aside */ +const is = (actual: Color, expected: Color, margin = 3): boolean => + actual.every((channel, i) => Math.abs(channel - expected[i]) <= margin); + +/** Fill the area around the point and put the fill on the canvas, as the tool does */ +const fill = ( + canvas: fabric.Canvas, + x: number, + y: number, + color: string +): BucketFillResult => { + const result = bucketFill(canvas, { x, y }, { color }) as BucketFillResult; + expect(result).not.toBeNull(); + canvas.insertAt(result.index, result.fill); + return result; +}; + +/** Expect the color, a little anti-aliasing aside: the actual color is in the failure otherwise */ +const expectColor = (actual: Color, expected: Color) => + expect(is(actual, expected) ? expected : actual).toEqual(expected); + +describe("bucketFill", () => { + let canvas: fabric.Canvas; + + /** Use a canvas of another size, instead of the one of the test */ + const replaceCanvas = (width: number, height: number) => { + void canvas.dispose(); + canvas = new fabric.Canvas(undefined, { width, height }); + }; + + beforeEach(() => { + canvas = new fabric.Canvas(undefined, { width: 400, height: 300 }); + }); + + afterEach(() => { + void canvas.dispose(); + }); + + describe("a rectangle", () => { + beforeEach(() => { + canvas.add(rectangle(50, 50, 200, 150)); + }); + + it("fills the inside, up to the line, which stays on top", () => { + fill(canvas, 150, 120, "#0000ff"); + + const at = shown(canvas); + expectColor(at(150, 120), BLUE); + // Next to the line, there is no seam of the empty canvas + expectColor(at(150, 58), BLUE); + expectColor(at(58, 120), BLUE); + expectColor(at(150, 50), BLACK); + expectColor(at(50, 120), BLACK); + }); + + it("does not fill out of the line", () => { + fill(canvas, 150, 120, "#0000ff"); + + const at = shown(canvas); + expect(at(46, 120)[3]).toBe(0); + expect(at(256, 120)[3]).toBe(0); + expect(at(150, 46)[3]).toBe(0); + expect(at(20, 20)[3]).toBe(0); + }); + + it("makes a fill which is a path of its own type, under the rectangle", () => { + const { fill: path, index } = fill(canvas, 150, 120, "#0000ff"); + + expect(path).toBeInstanceOf(FillPath); + expect(path.fill).toBe("#0000ff"); + expect(path.stroke).toBeNull(); + expect(index).toBe(0); + expect(canvas.getObjects()[1]).toBeInstanceOf(fabric.Rect); + }); + + it("has the outline of the inside, which is a few points", () => { + const { fill: path } = fill(canvas, 150, 120, "#0000ff"); + + const { left, top, width, height } = path.getBoundingRect(); + // The inside of the line, and one pixel of the line itself + expect(left).toBeCloseTo(53, 0); + expect(top).toBeCloseTo(53, 0); + expect(width).toBeCloseTo(198, 0); + expect(height).toBeCloseTo(148, 0); + expect(path.path).toHaveLength(5); + }); + + it("is not closed outside of it, where the area reaches the edge of the canvas", () => { + expect(bucketFill(canvas, { x: 10, y: 10 }, { color: "red" })).toBeNull(); + }); + + it("fills the line when it is the one pressed, which is the area of its color", () => { + fill(canvas, 150, 52, "#ff0000"); + + const at = shown(canvas); + expectColor(at(150, 52), RED); + expect(at(150, 120)[3]).toBe(0); + }); + }); + + it("is not closed on an empty canvas", () => { + expect(bucketFill(canvas, { x: 100, y: 100 }, { color: "red" })).toBeNull(); + }); + + it("fills with a color which is not transparent only", () => { + canvas.add(rectangle(50, 50, 200, 150)); + + expect( + bucketFill(canvas, { x: 150, y: 120 }, { color: "transparent" }) + ).toBeNull(); + expect( + bucketFill(canvas, { x: 150, y: 120 }, { color: "rgba(0, 0, 0, 0)" }) + ).toBeNull(); + expect( + bucketFill(canvas, { x: 150, y: 120 }, { color: "rgba(0, 0, 0, 0.5)" }) + ).not.toBeNull(); + }); + + it("fills nothing for a point out of the canvas", () => { + canvas.add(rectangle(50, 50, 200, 150)); + + expect(bucketFill(canvas, { x: -5, y: 100 }, { color: "red" })).toBeNull(); + expect(bucketFill(canvas, { x: 100, y: 305 }, { color: "red" })).toBeNull(); + expect(bucketFill(canvas, { x: 400, y: 100 }, { color: "red" })).toBeNull(); + }); + + describe("what closes an area", () => { + it("is lines, as long as they meet", () => { + canvas.add( + line(50, 50, 250, 50), + line(250, 50, 250, 200), + line(250, 200, 50, 200), + line(50, 200, 50, 120) + ); + // There is a gap at the left, between the end of the last line and the first + expect(bucketFill(canvas, { x: 150, y: 120 }, { color: "red" })).toBeNull(); + + canvas.add(line(50, 130, 50, 50)); + fill(canvas, 150, 120, "#ff0000"); + + expectColor(shown(canvas)(150, 120), RED); + }); + + it("is not a dashed line, which has gaps", () => { + canvas.add(rectangle(50, 50, 200, 150, { strokeDashArray: [12, 12] })); + + expect(bucketFill(canvas, { x: 150, y: 120 }, { color: "red" })).toBeNull(); + }); + + it("is a circle, a polygon and a curve of the pencil", () => { + canvas.add( + new fabric.Circle({ + originX: "left", + originY: "top", + left: 20, + top: 20, + radius: 50, + fill: "transparent", + stroke: "black", + strokeWidth: 4, + strokeUniform: true, + ...FROZEN, + }), + new fabric.Polygon( + [ + { x: 160, y: 130 }, + { x: 220, y: 20 }, + { x: 280, y: 130 }, + ], + { + fill: "transparent", + stroke: "black", + strokeWidth: 4, + strokeUniform: true, + ...FROZEN, + } + ), + new fabric.Path("M 50 280 C 50 160, 250 160, 250 280 Z", { + fill: "", + stroke: "black", + strokeWidth: 5, + strokeLineCap: "round", + ...FROZEN, + }) + ); + + fill(canvas, 70, 70, "#ff0000"); + fill(canvas, 220, 90, "#00ff00"); + fill(canvas, 150, 240, "#0000ff"); + + const at = shown(canvas); + expectColor(at(70, 70), RED); + expectColor(at(220, 90), GREEN); + expectColor(at(150, 240), BLUE); + }); + + it("is the objects only, and not the background", () => { + canvas.backgroundColor = "#eeeeee"; + canvas.add(rectangle(50, 50, 200, 150)); + + fill(canvas, 150, 120, "#0000ff"); + + expect(canvas.backgroundColor).toBe("#eeeeee"); + expectColor(shown(canvas)(150, 120), BLUE); + }); + + it("is not an object which is not visible", () => { + const box = rectangle(50, 50, 200, 150); + canvas.add(box); + box.visible = false; + + expect(bucketFill(canvas, { x: 150, y: 120 }, { color: "red" })).toBeNull(); + }); + + it("is the overlay image, which is an outline to draw within", async () => { + const outline = new fabric.Canvas(undefined, { width: 300, height: 300 }); + outline.add(rectangle(60, 60, 150, 150)); + const overlay = await fabric.FabricImage.fromURL( + outline.toDataURL({ format: "png", multiplier: 1 }), + {}, + { originX: "left", originY: "top", left: 0, top: 0 } + ); + void outline.dispose(); + canvas.overlayImage = overlay; + + fill(canvas, 130, 130, "#00ffff"); + + expect(canvas.overlayImage).toBe(overlay); + expect(shown(canvas)(130, 130)[1]).toBeGreaterThan(200); + }); + }); + + describe("an area with something in it", () => { + it("is around it, so a circle in a rectangle leaves a ring", () => { + canvas.add( + rectangle(20, 20, 300, 220), + new fabric.Circle({ + originX: "left", + originY: "top", + left: 120, + top: 70, + radius: 60, + fill: "transparent", + stroke: "black", + strokeWidth: 4, + strokeUniform: true, + ...FROZEN, + }) + ); + + fill(canvas, 40, 40, "#00aa00"); + + const at = shown(canvas); + expectColor(at(40, 40), [0, 170, 0, 255]); + // The inside of the circle is another area + expect(at(180, 130)[3]).toBe(0); + fill(canvas, 180, 130, "#ffaa00"); + expectColor(shown(canvas)(180, 130), [255, 170, 0, 255]); + }); + + it("is one of the four which two lines make in a rectangle", () => { + canvas.add( + rectangle(50, 50, 200, 150), + line(150, 50, 150, 200, 3), + line(50, 125, 250, 125, 3) + ); + + fill(canvas, 100, 90, "#ff0000"); + fill(canvas, 200, 160, "#0000ff"); + + const at = shown(canvas); + expectColor(at(100, 90), RED); + expectColor(at(200, 160), BLUE); + expect(at(200, 90)[3]).toBe(0); + expect(at(100, 160)[3]).toBe(0); + // The lines are over the fills + expectColor(at(150, 100), BLACK); + expectColor(at(120, 125), BLACK); + }); + }); + + describe("the order of the objects", () => { + it("puts a fill over the object it is in, and under the line which closes it", () => { + canvas.add( + rectangle(50, 50, 200, 150, { fill: "#ff0000" }), + line(150, 50, 150, 200) + ); + + const { index } = fill(canvas, 100, 120, "#0000ff"); + + expect(index).toBe(1); + const at = shown(canvas); + expectColor(at(100, 120), BLUE); + expectColor(at(200, 120), RED); + expectColor(at(150, 120), BLACK); + // The fill does not spread past the edge of the rectangle it is in + expect(at(46, 120)[3]).toBe(0); + }); + + it("puts a fill over the fill of the area, so it is filled again", () => { + canvas.add(rectangle(50, 50, 200, 150)); + const first = fill(canvas, 150, 120, "#0000ff"); + + const second = fill(canvas, 150, 120, "#00ff00"); + + expect(second.index).toBe(first.index + 1); + expectColor(shown(canvas)(150, 120), GREEN); + }); + + it("does not take the fills for what closes an area", () => { + canvas.add(rectangle(50, 50, 200, 150), line(150, 50, 150, 200, 3)); + fill(canvas, 100, 120, "#0000ff"); + + // The same area, with the fill of it in the way + const again = fill(canvas, 100, 120, "#ff0000"); + + expectColor(shown(canvas)(100, 120), RED); + expect(again.fill.getBoundingRect().width).toBeLessThan(100); + }); + }); + + describe("a thin line", () => { + it("has no color of the fill on the other side of it, and no seam", () => { + replaceCanvas(200, 200); + canvas.add( + rectangle(20, 20, 140, 140, { strokeWidth: 1 }), + line(20, 20, 161, 161, 1) + ); + + fill(canvas, 120, 50, "#ff0000"); + fill(canvas, 50, 120, "#0000ff"); + + const at = shown(canvas); + let red = 0; + let blue = 0; + let wrong = 0; + for (let y = 24; y < 156; y++) { + for (let x = 24; x < 156; x++) { + const side = x - y; + if (Math.abs(side) <= 2) continue; + const [r, , b] = at(x, y); + const isRed = r > 200 && b < 60; + const isBlue = b > 200 && r < 60; + if (side > 0) { + if (isRed) red++; + else if (isBlue) wrong++; + } else if (isBlue) { + blue++; + } else if (isRed) { + wrong++; + } + } + } + expect(wrong).toBe(0); + expect(red).toBeGreaterThan(5000); + expect(blue).toBeGreaterThan(5000); + }); + }); + + describe("the view", () => { + it("is the one of the zoom and the position, and the fill is in the coordinates of the objects", () => { + canvas.add(rectangle(50, 50, 100, 80)); + // The point (100, 90) of the objects is at (160, 150) of the canvas + canvas.setViewportTransform([2, 0, 0, 2, -40, -30]); + + const { fill: path } = fill(canvas, 160, 150, "#ff00ff"); + + const { left, top, width } = path.getBoundingRect(); + expect(left).toBeCloseTo(53.5, 0); + expect(top).toBeCloseTo(53.5, 0); + expect(width).toBeCloseTo(97, 0); + expectColor(shown(canvas)(160, 150), [255, 0, 255, 255]); + + canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); + expectColor(shown(canvas)(100, 90), [255, 0, 255, 255]); + }); + + it("has to show the whole area, so there is no fill for one which is partly out of it", () => { + replaceCanvas(300, 300); + canvas.add(rectangle(50, 50, 400, 300)); + + expect(bucketFill(canvas, { x: 150, y: 120 }, { color: "red" })).toBeNull(); + + canvas.setViewportTransform([0.5, 0, 0, 0.5, 0, 0]); + expect( + bucketFill(canvas, { x: 100, y: 100 }, { color: "red" }) + ).not.toBeNull(); + }); + }); + + describe("saved and loaded", () => { + it("is saved as a BucketFill, and loaded as one in the same place among the objects", async () => { + canvas.add(rectangle(50, 50, 200, 150)); + fill(canvas, 150, 120, "#0000ff"); + const json = JSON.parse(JSON.stringify(canvas.toObject())); + + expect(json.objects.map((object: { type: string }) => object.type)).toEqual([ + "BucketFill", + "Rect", + ]); + expect(JSON.stringify(json.objects[0]).length).toBeLessThan(1000); + + const loaded = new fabric.Canvas(undefined, { width: 400, height: 300 }); + await loaded.loadFromJSON(json); + + expect(loaded.getObjects()[0]).toBeInstanceOf(FillPath); + expect(loaded.getObjects()[1]).toBeInstanceOf(fabric.Rect); + expectColor(shown(loaded)(150, 120), BLUE); + void loaded.dispose(); + }); + }); + + describe("options", () => { + it("takes the pixels of the edge of a line into the area with a tolerance, and not without one", () => { + // The line is between pixels, so the pixel at each of its edges is half of it + canvas.add(rectangle(50.5, 50.5, 200, 150)); + + const strict = bucketFill( + canvas, + { x: 150, y: 120 }, + { color: "red", tolerance: 0 } + ); + const loose = bucketFill( + canvas, + { x: 150, y: 120 }, + { color: "red", tolerance: 200 } + ); + + const widthOf = (result: BucketFillResult | null) => + (result as BucketFillResult).fill.getBoundingRect().width; + expect(widthOf(loose)).toBeGreaterThan(widthOf(strict) + 1); + }); + + it("goes under the line by the overlap, or not at all", () => { + canvas.add(rectangle(50, 50, 200, 150)); + + const none = bucketFill( + canvas, + { x: 150, y: 120 }, + { color: "red", overlap: 0 } + ); + const some = bucketFill( + canvas, + { x: 150, y: 120 }, + { color: "red", overlap: 3 } + ); + + const widthOf = (result: BucketFillResult | null) => + (result as BucketFillResult).fill.getBoundingRect().width; + expect(widthOf(some)).toBeCloseTo(widthOf(none) + 2 * 3, 0); + }); + + it("keeps more points of the outline with less simplifying", () => { + canvas.add( + new fabric.Circle({ + originX: "left", + originY: "top", + left: 50, + top: 50, + radius: 100, + fill: "transparent", + stroke: "black", + strokeWidth: 3, + strokeUniform: true, + ...FROZEN, + }) + ); + + const exact = bucketFill( + canvas, + { x: 150, y: 150 }, + { color: "red", simplify: 0 } + ); + const smooth = bucketFill( + canvas, + { x: 150, y: 150 }, + { color: "red", simplify: 1 } + ); + + const points = (result: BucketFillResult | null) => + (result as BucketFillResult).fill.path.length; + expect(points(smooth)).toBeLessThan(points(exact) / 2); + expect(points(smooth)).toBeGreaterThan(10); + }); + }); + + it("fills a big area in a moment", () => { + replaceCanvas(1600, 1000); + canvas.add( + new fabric.Circle({ + originX: "left", + originY: "top", + left: 300, + top: 100, + radius: 400, + fill: "transparent", + stroke: "black", + strokeWidth: 3, + ...FROZEN, + }) + ); + + const start = performance.now(); + const result = bucketFill(canvas, { x: 700, y: 500 }, { color: "#123456" }); + + expect(result).not.toBeNull(); + expect(performance.now() - start).toBeLessThan(2000); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/BucketFill/index.ts b/packages/react-sketch/src/components/SketchField/tools/BucketFill/index.ts new file mode 100644 index 0000000..a014841 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/BucketFill/index.ts @@ -0,0 +1 @@ +export { BucketFill } from "./BucketFill"; diff --git a/packages/react-sketch/src/components/SketchField/tools/factory.ts b/packages/react-sketch/src/components/SketchField/tools/factory.ts index cfa3f19..e2e7aea 100644 --- a/packages/react-sketch/src/components/SketchField/tools/factory.ts +++ b/packages/react-sketch/src/components/SketchField/tools/factory.ts @@ -2,6 +2,7 @@ import * as fabric from "fabric"; import { SketchProperties, Tool } from "../../../types"; import { FabricCanvasTool } from "./index"; import { Arrow } from "./Arrow"; +import { BucketFill } from "./BucketFill"; import { Circle } from "./Circle"; import { DefaultTool } from "./DefaultTool"; import { Eraser } from "./Eraser"; @@ -59,6 +60,9 @@ export const create_tool = ( case Tool.ERASER: toolInstance = new Eraser(canvas); break; + case Tool.BUCKET_FILL: + toolInstance = new BucketFill(canvas); + break; case Tool.LASER_POINTER: toolInstance = new LaserPointer(canvas); break; diff --git a/packages/react-sketch/src/index.spec.tsx b/packages/react-sketch/src/index.spec.tsx index f54873a..92a622d 100644 --- a/packages/react-sketch/src/index.spec.tsx +++ b/packages/react-sketch/src/index.spec.tsx @@ -21,6 +21,7 @@ describe("exports", () => { DefaultTool: "default-tool", Highlighter: "highlighter", Eraser: "eraser", + BucketFill: "bucket-fill", LaserPointer: "laser-pointer", }); }); diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 4ab12ae..749affb 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -16,6 +16,7 @@ export enum Tool { PAN = "pan", HIGHLIGHTER = "highlighter", ERASER = "eraser", + BUCKET_FILL = "bucket-fill", LASER_POINTER = "laser-pointer", DEFAULT_TOOL = "default-tool", } @@ -37,6 +38,7 @@ export const Tools = { DefaultTool: Tool.DEFAULT_TOOL, Highlighter: Tool.HIGHLIGHTER, Eraser: Tool.ERASER, + BucketFill: Tool.BUCKET_FILL, LaserPointer: Tool.LASER_POINTER, } as const; @@ -176,7 +178,10 @@ export interface SketchProperties { * polygon draw it, the highlighter, eraser and laser pointer do not. */ lineStyle?: LineStyle; - /** The fill color of the shape when applicable. Defaults to "transparent" */ + /** + * The fill color of the shape when applicable. Defaults to "transparent". The + * bucket fill fills with it, and with `lineColor` while it is transparent + */ fillColor?: string; /** The background color of the sketch, in rgba or hex format. Defaults to "transparent" */ backgroundColor?: string; From 1a4061b34ff0863ed90ebc0929baf0e977564f99 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 6 Nov 2026 16:07:39 +0200 Subject: [PATCH 3/4] feat(showcase): add the bucket fill to the tools The bucket fill button (key F) fills with the fill color, whether the shapes are filled or not, and its properties show the fill color only. --- packages/showcase/src/actions.ts | 3 + .../showcase/src/components/Properties.tsx | 150 ++++++++++-------- .../showcase/src/components/Workspace.tsx | 10 +- packages/showcase/src/tools.tsx | 8 + 4 files changed, 102 insertions(+), 69 deletions(-) diff --git a/packages/showcase/src/actions.ts b/packages/showcase/src/actions.ts index 816e6d6..f39478c 100644 --- a/packages/showcase/src/actions.ts +++ b/packages/showcase/src/actions.ts @@ -56,6 +56,9 @@ export const NO_LINE_STYLE: string[] = [ Types.Tool.LASER_POINTER, ]; +/** The tools which fill an area with the fill color, and draw no line: only that color matters to them */ +export const FILL_ONLY: string[] = [Types.Tool.BUCKET_FILL]; + /** The tools which draw nothing, so their line, color and style do not matter */ export const NO_PROPERTIES: string[] = [Types.Tool.SELECT, Types.Tool.PAN]; diff --git a/packages/showcase/src/components/Properties.tsx b/packages/showcase/src/components/Properties.tsx index 00517c1..0bbca47 100644 --- a/packages/showcase/src/components/Properties.tsx +++ b/packages/showcase/src/components/Properties.tsx @@ -16,6 +16,7 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { observer } from "mobx-react-lite"; import { + FILL_ONLY, NO_LINE_STYLE, NO_PROPERTIES, copy, @@ -53,6 +54,87 @@ const Action = ({ label, disabled, onClick, children }: ActionProps) => ( ); +/** + * The colors, the weight and the style of the line of what the tool draws. The tools which + * fill an area have the fill color only, which is never none for them. + */ +const DrawingProperties = observer(({ tool }: { tool: string }): ReactElement => { + const fillOnly = FILL_ONLY.includes(tool); + return ( + + {!fillOnly && ( + color && (CONFIG.lineColor = color)} + /> + )} + { + if (color) CONFIG.fillColor = color; + if (!fillOnly) CONFIG.fillWithColor = color !== null; + }} + /> + {!fillOnly && ( + <> + + { + if (value !== null) CONFIG.lineWidth = value; + }}> + {QUICK_WIDTHS.map(({ value, label }) => ( + + + + ))} + + } + endIcon={} + onChange={(value) => (CONFIG.lineWidth = value)} + /> + + + + Line style + + + + + )} + + + ); +}); + /** * The line, the colors and the style of what the tool draws, and the actions on the * selected objects. It floats at the left of the canvas, and the tools which draw @@ -104,73 +186,7 @@ const Properties = observer((): ReactElement => { - {drawing && ( - - color && (CONFIG.lineColor = color)} - /> - { - CONFIG.fillWithColor = color !== null; - if (color) CONFIG.fillColor = color; - }} - /> - - { - if (value !== null) CONFIG.lineWidth = value; - }}> - {QUICK_WIDTHS.map(({ value, label }) => ( - - - - ))} - - } - endIcon={} - onChange={(value) => (CONFIG.lineWidth = value)} - /> - - - - Line style - - - - - - )} + {drawing && } Selected objects diff --git a/packages/showcase/src/components/Workspace.tsx b/packages/showcase/src/components/Workspace.tsx index 995c7c4..739a9ee 100644 --- a/packages/showcase/src/components/Workspace.tsx +++ b/packages/showcase/src/components/Workspace.tsx @@ -1,6 +1,6 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; -import { SketchField, type Types } from "react-sketch"; +import { SketchField, Types } from "react-sketch"; import Box from "@mui/material/Box"; import { observer } from "mobx-react-lite"; import defaultValue from "../data/defaultValue"; @@ -31,6 +31,12 @@ const showView = (view: Types.SketchView) => { CONFIG.view = view; }; +/** The fill color of the tool: the bucket fills with it, whether the shapes are filled or not */ +const fillColorOfTool = (): string => + CONFIG.fillWithColor || CONFIG.selectedTool === Types.Tool.BUCKET_FILL + ? CONFIG.fillColor + : "transparent"; + /** * The size of the element, as it changes. Nothing is reported before the first * measure, which is made before the page is painted. @@ -157,7 +163,7 @@ const Workspace = observer((): ReactElement => { minZoom={CONFIG.minZoom} maxZoom={CONFIG.maxZoom} onViewChange={showView} - fillColor={CONFIG.fillWithColor ? CONFIG.fillColor : "transparent"} + fillColor={fillColorOfTool()} backgroundColor={ CONFIG.fillWithBackgroundColor ? CONFIG.backgroundColor : "transparent" } diff --git a/packages/showcase/src/tools.tsx b/packages/showcase/src/tools.tsx index 0b95c1c..235caa8 100644 --- a/packages/showcase/src/tools.tsx +++ b/packages/showcase/src/tools.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from "react"; import { Types } from "react-sketch"; import ArrowIcon from "@mui/icons-material/NorthEast"; +import BucketFillIcon from "@mui/icons-material/FormatColorFill"; import CircleIcon from "@mui/icons-material/RadioButtonUnchecked"; import EraserIcon from "@mui/icons-material/AutoFixNormal"; import HighlighterIcon from "@mui/icons-material/Highlight"; @@ -107,6 +108,13 @@ export const TOOL_GROUPS: ToolInfo[][] = [ hint: "Drag over the drawing to erase it.", icon: , }, + { + value: Types.Tool.BUCKET_FILL, + label: "Bucket fill", + key: "f", + hint: "Click inside a closed shape to fill it with the fill color. The lines around it must meet: a gap lets the color out, and nothing is filled.", + icon: , + }, { value: Types.Tool.LASER_POINTER, label: "Laser pointer", From ec33af981178f01ac0bcf89416e98fa1c1c243fd Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 6 Nov 2026 16:07:41 +0200 Subject: [PATCH 4/4] docs: describe the bucket fill and list it under 1.2.0 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ README.md | 30 ++++++++++++++++++++++++++---- packages/react-sketch/README.md | 2 +- packages/showcase/README.md | 6 +++--- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b6d4a..fe1b4af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 1.2.0 + +The bucket fill tool. + +### Added + +- The bucket fill tool: `tool="bucket-fill"`, `Tools.BucketFill` and `Types.Tool.BUCKET_FILL`. A click fills + the area which is closed around it with `fillColor` (with `lineColor` while `fillColor` is transparent, its + default). Lines, rectangles, circles, polygons, strokes of the pencil, texts, the overlay image and any other + object close an area, whatever their color. The background color and the background image do not, and nor + does a dashed line, which has gaps. The area has to be closed within the canvas as it is shown (with the zoom + and the position of the view): when the color would get out, a click fills nothing. The fill is an object of + the sketch (a path, saved by `toJSON()` as a `BucketFill` and loaded by `value`, `defaultValue` and + `fromJSON()`), one undo step, and it scales with the canvas. It is under the lines which close the area, a + pixel under their edge, and over the object the area is in, so a click in a filled rectangle fills what its + lines leave of it, and an area which has a fill is filled again over it. The cursor is a bucket with a drop + of the color. +- The showcase has a Bucket fill button in the tool selector (key F), with the fill color only in its + properties, as the other properties do not matter to it. + +### Changed + +- Undo of a removal puts the object back where it was among the objects, and not on top of them. Redo of an + addition does the same for an object which was added under the others, as the fill of the bucket is, so a fill + never covers the lines which close it after an undo and a redo. + +### Packaging and tooling + +- The bucket fill is in the private package `packages/bucket` (`@react-sketch/bucket`), which `react-sketch` + bundles too, so nothing new is installed for the users of the library. `yarn build:packages` builds the three + private packages, and `yarn typecheck`, `yarn build` and `yarn test` run it first. Its tests are in + `react-sketch`: the ones with real pixels run in the node environment of Jest, with the node build of fabric. + ## 1.1.0 (2026-11-04) The eraser, laser pointer and polygon tools, pinch zoom, and features that forks of react-sketch had, written again for the TypeScript code and fabric 7. diff --git a/README.md b/README.md index c013398..d06b723 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ All properties are optional. | `lineColor` | `string` | `"black"` | Color of the line. | | `lineWidth` | `number` | `10` | Width of the line. | | `lineStyle` | `Types.LineStyle` | `"solid"` | Style of the line: `"solid"`, `"dashed"`, `"dotted"` or `"dash-dot"`. See [Line style](#line-style). | -| `fillColor` | `string` | `"transparent"` | Fill color of the shapes, where applicable (rectangle, rectangle-label, circle). | +| `fillColor` | `string` | `"transparent"` | Fill color of the shapes, where applicable (rectangle, rectangle-label, circle), and the color of the bucket fill, which uses `lineColor` while it is transparent. | | `backgroundColor` | `string` | `"transparent"` | Background color of the sketch, in hex or rgba format. | | `backgroundImage` | `string` | | URL or data URL of the background image. It is set when the component mounts and when it changes, and removed when it is gone. It takes the place of the background image of `value` and `defaultValue`. | | `backgroundImageOptions` | `Types.BackgroundOptions` | | Options of `backgroundImage`, those of `setBackgroundFromDataUrl`. Options written inline in a render do not load the image again when their content is the same. | @@ -344,6 +344,7 @@ Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the en | Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | | Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | | Eraser | `Tools.Eraser` | `eraser` | Erases parts of the objects, with the width of `lineWidth`. The cursor shows the size of the brush. The background color and the background image are not erased. See [Eraser](#eraser). | +| Bucket fill | `Tools.BucketFill` | `bucket-fill` | Fills the area which is closed around a click with `fillColor` (`lineColor` while it is transparent). See [Bucket fill](#bucket-fill). | | Laser pointer | `Tools.LaserPointer` | `laser-pointer` | A line that follows the pointer, fades out and disappears. It is not part of the drawing. See [Laser pointer](#laser-pointer). | | Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | @@ -358,8 +359,8 @@ thick line has long dashes and wide dots. ``` The pencil, line, arrow, rectangle, rectangle label, circle and polygon draw it. The head of an arrow and the -text box of a rectangle label are not dashed, and the highlighter, the eraser and the laser pointer do not -draw it. A change of `lineStyle` is for the next drawing: what is drawn already keeps its style. The style is +text box of a rectangle label are not dashed, and the highlighter, the eraser, the bucket fill and the laser +pointer do not draw it. A change of `lineStyle` is for the next drawing: what is drawn already keeps its style. The style is saved by `toJSON()` and loaded by `fromJSON()` with the object (`strokeDashArray` and `strokeLineCap`), and the polygon in progress is shown in it. @@ -430,6 +431,26 @@ A stroke is one undo step. The erased parts are kept on the objects, so `toJSON( `defaultValue` and `fromJSON()` load them. An object is only cut where the eraser crosses it, the rest of the sketch stays, and the background color and the background image are not erased. +### Bucket fill + +The bucket fill fills the area around the point of a click with `fillColor`, or with `lineColor` while +`fillColor` is transparent (its default). What closes the area is what is drawn: lines, rectangles, circles, +polygons, strokes of the pencil, texts, the overlay image, and any other object, whatever its color. The +background color and the background image do not close an area. + +The area has to be closed within the canvas, as it is shown with the zoom and the position of the view. Where +the lines do not meet, or the area reaches the edge of the canvas, the color would get out: a click there fills +nothing, and `onChange` is not called. A dashed or dotted line has gaps, so it does not close an area. + +The fill is an object of the sketch, a path of the shape of the area. It is one undo step, `onObjectAdded` and +`onChange` are called for it, `toJSON()` saves it (as a `BucketFill` object), `value`, `defaultValue` and +`fromJSON()` load it, and it scales with the canvas. It is over the object the area is in and under the objects +around it, and goes a pixel under their lines, so a line is never covered and there is no seam at its edge. A +click in an area which has a fill fills it again, over the first one. The select tool moves and removes the fills, +and the eraser cuts them, as it does the other objects. + +The fill is made by the private package [`@react-sketch/bucket`](packages/bucket). + ### Laser pointer The laser pointer points at something on the sketch: the line follows the pointer, gets thinner towards its @@ -445,7 +466,7 @@ not called when it is drawn. Switching to another tool clears what is left of th | Export | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SketchField` | The component (named export). Also usable as a type, for the `ref`. | -| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`. | +| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `BucketFill`, `LaserPointer`. | | `Types` | Namespace of the types: `Tool` (enumeration), `Tools`, `LineStyle`, `SketchProperties`, `SketchEvent`, `SketchObject`, `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions`, `TextOptions`. | | default export | The component, which also carries `SketchField` and `Tools` (as in 0.5.x). | @@ -469,6 +490,7 @@ This is a Yarn 1 workspaces monorepo. | Path | Description | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/react-sketch` | The library, published to npm as `react-sketch`. The build output is `dist/index.js` (UMD bundle, `react` and `fabric` are external) and `dist/index.d.ts` (type declarations). | +| `packages/bucket` | The bucket fill, which finds the area closed around a point and makes its fill (private, bundled into `react-sketch`). | | `packages/eraser` | The eraser brush, from `@erase2d/fabric` (private, bundled into `react-sketch`). | | `packages/laserpointer` | The laser pointer brush (private, bundled into `react-sketch`). | | `packages/showcase` | Demo application (not published), live on GitHub Pages: a canvas with floating tools, properties and settings, and the `defaultValue`, `value`, `addImg` and `backgroundImage` scenarios. | diff --git a/packages/react-sketch/README.md b/packages/react-sketch/README.md index 87f4b43..afb4f68 100644 --- a/packages/react-sketch/README.md +++ b/packages/react-sketch/README.md @@ -45,7 +45,7 @@ export const Drawing = () => { ``` Exports: `SketchField` (the component), `Tools` (`Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, -`RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`), `Types` (namespace with the types and +`RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `BucketFill`, `LaserPointer`), `Types` (namespace with the types and the `Tool` enumeration) and a default export, which is the component with `SketchField` and `Tools` attached (as in 0.5.x). diff --git a/packages/showcase/README.md b/packages/showcase/README.md index 390f733..b16395a 100644 --- a/packages/showcase/README.md +++ b/packages/showcase/README.md @@ -6,9 +6,9 @@ and is not published to npm. The canvas fills the window, and the panels float over it: - **Toolbar** (top): select, pan, rectangle, rectangle label, circle, polygon, line, arrow, pencil, highlighter, - eraser and laser pointer. The keys of the tools are on the buttons and in the shortcuts dialog. -- **Properties** (left): line color, fill color, line weight and line style of the tool, and copy, paste and delete - of the selected objects. + eraser, bucket fill and laser pointer. The keys of the tools are on the buttons and in the shortcuts dialog. +- **Properties** (left): line color, fill color, line weight and line style of the tool (the bucket fill has the fill + color only), and copy, paste and delete of the selected objects. - **Settings** (right, the gear): the **Scenario** tab (what the scenario shows and its actions), the **Canvas** tab (size, background color and image, pinch zoom, view, grid) and the **Insert** tab (text, image from a URL, overlay).