diff --git a/frontend/src/components/threads/ThreadCanvas.tsx b/frontend/src/components/threads/ThreadCanvas.tsx index 81b1fd8..13ec98d 100644 --- a/frontend/src/components/threads/ThreadCanvas.tsx +++ b/frontend/src/components/threads/ThreadCanvas.tsx @@ -16,19 +16,94 @@ type Layer = { future: Line[][] image?: ImageItem } -type Line = { - mode: Mode - points: number[] - stroke: string - strokeWidth: number } +type Line = + | { mode: Mode.Pen | Mode.Rubber + points: number[] + stroke: string + strokeWidth: number } + | { mode: Mode.Paint + points: number[] + stroke: string, + imageSrc: string } const Mode = { Pen: 'Pen', Rubber: 'Rubber', - Image: 'Image' } as const + Image: 'Image', + Paint: 'Paint' } as const type Mode = (typeof Mode)[keyof typeof Mode] +const colourDiffMax = (c1: readonly number[], c2: readonly number[]) => ( + Math.max (...[0, 1, 2, 3].map (i => Math.abs (c1[i] - c2[i])))) + + +const floodFill = ( + imgData: ImageData, + sx: number, + sy: number, + fill: { r: number + g: number + b: number + a: number }, + tolerance: number = 16) => { + const { data, width: W, height: H } = imgData + const start = getRGBA (data, sx, sy, W) + const visited = new Uint8Array (W * H) + + if (colourDiffMax (start, [fill.r, flil.g, fill.b, fill.a]) <= tolerance) + return + + const stack: [number, number][] = [[sx, sy]] + while (stack.length > 0) + { + const [x, y] = stack.pop ()! + let lx = x + while (lx >= 0 + && !(visited[y * W + lx]) + && colourDiffMax (getRGBA (data, lx, y, W), start) <= tolerance) + --lx + ++lx + let rx = x + while (rx < W + && !(visited[y * W + rx]) + && colourDiffMax (getRGBA (data, rx, y, W), start) <= tolerance) + ++rx + for (let i = lx; i < rx; ++i) + { + const idx = y * W + i + visited[idx] = 1 + const p = idx * 4 + data[p] = fill.r + data[p + 1] = fill.g + data[p + 2] = fill.b + data[p + 3] = fill.a + if (y > 0 + && !(visited[(y - 1) * W + i]) + && colourDiffMax (getRGBA (data, i, y - 1, W), start) <= tolerance) + stack.push ([i, y - 1]) + if (y < H - 1 + && !(visited[(y + 1) * W + i]) + && colourDiffMax (getRGBA (data, i, y + 1, W), start) <= tolerance) + stack.push ([i, y + 1]) + } + } +} + + +const getRGBA = (data: Uint8ClampedArray, x: number, y: number, w: number) => { + const i = (y * w + x) * 4 + return [data[i], data[i + 1], data[i + 2], data[i + 3]] as const +} + + +const hexToRgba = (hex: string, alpha = 255): [number, number, number, number] => { + const m = /^#?([0-9a-f]{6})$/i.exec (hex) + const n = parseInt (m[1], 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255, alpha] +} + + const isLayer = (obj: unknown): obj is Layer => ( typeof obj === 'object' && obj !== null @@ -147,6 +222,51 @@ export default () => { fileInputRef.current.value = '' } + const handlePaint = async (ev: Konva.KonvaEventObject) => { + const layer = activeLayer + if (!(layer) || !(stageRef.current)) + return + + const stage = stageRef.current + const pos = stage.getPointerPosition () + if (!(pos)) + return + + const dataURL = stage.toDataURL ({ mimeType: 'image/png', pixelRatio: 1 }) + + const img = new window.Image + img.crossOrigin = 'anonymous' + await new Promise (res => { + img.onload = () => res () + img.src = dataURL + }) + + const $off = document.createElement ('canvas') + $off.width = stageWidth + $off.height = stageHeight + const ctx = $off.getContext ('2d') + ctx.drawImage (img, 0, 0, stageWidth, stageHeight) + + const imgData = ctx.getImageData (0, 0, stageWidth, stageHeight) + const [r, g, b, a] = hexToRgba (colour, 255) + floodFill (imgData, Math.floor (pos.x), Math.floor (pos.y), { r, g, b, a }) + ctx.putImageData (imgData, 0, 0) + + const prevImageSrc = layer.image?.src + const nextSrc = $off.toDataURL ('image/png') + + updateActiveLayerHistory ([...layer.history, layer.lines]) + updateActiveLayerFuture ([]) + + setLayers (prev => prev.map (l => l.id === activeLayerId ? ({ + ...l, + lines: [...l.lines, + { mode: Mode.Paint, + points: [pos.x, pos.y], + stroke: colour, + imageSrc: nextSrc }] }) : l)) + } + const updateActiveLayerLines = (lines: Line[]) => { setLayers (prev => ( prev.map (layer => (layer.id === activeLayerId @@ -258,6 +378,18 @@ export default () => { setImages (prev => ({ ...prev, [layer.id]: image })) } } + + layer.lines.forEach (line => { + if (line.mode === Mode.Paint && !(line.imageSrc in images)) + { + const image = new window.Image + image.crossOrigin = 'anonymous' + image.src = line.imageSrc + image.onload = () => { + setImages (prev => ({ ...prev, [line.imageSrc]: image })) + } + } + }) }) }, [layers]) @@ -330,6 +462,7 @@ export default () => { onChange={() => setMode (Mode.Pen)} /> ペン + + +
@@ -421,18 +563,19 @@ export default () => { }} width={stageWidth} height={stageHeight} - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - onTouchStart={handleMouseDown} - onTouchMove={handleMouseMove} - onTouchEnd={handleMouseUp}> + onMouseDown={mode === Mode.Paint ? handlePaint : handleMouseDown} + onMouseMove={mode === Mode.Paint ? undefined : handleMouseMove} + onMouseUp={mode === Mode.Paint ? undefined : handleMouseUp} + onTouchStart={mode === Mode.Paint ? handlePaint : handleMouseDown} + onTouchMove={mode === Mode.Paint ? undefined : handleMouseMove} + onTouchEnd={mode === Mode.Paint ? undefined : handleMouseUp}> + fill="white" + listening={false} /> {layers.map (layer => ( @@ -444,16 +587,30 @@ export default () => { scaleX={stageWidth / images[layer.id].width} scaleY={stageHeight / images[layer.id].height} />)} - {layer.lines.map ((line, i) => ( - ))} + {layer.lines.map ((line, i) => { + switch (line.mode) + { + case Mode.Pen: + case Mode.Rubber: + return ( + ) + case Mode.Paint: + return ( + ) + } + })} ))}