| @@ -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<MouseEvent | TouchEvent>) => { | |||
| 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<void> (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)} /> | |||
| ペン | |||
| </label> | |||
| <label> | |||
| <input type="radio" | |||
| name="mode" | |||
| @@ -338,6 +471,15 @@ export default () => { | |||
| onChange={() => setMode (Mode.Rubber)} /> | |||
| 消しゴム | |||
| </label> | |||
| <label> | |||
| <input type="radio" | |||
| name="mode" | |||
| value="paint" | |||
| checked={mode === Mode.Paint} | |||
| onChange={() => setMode (Mode.Paint)} /> | |||
| 塗りつぶし | |||
| </label> | |||
| </div> | |||
| <div> | |||
| @@ -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}> | |||
| <Layer> | |||
| <Rect x={0} | |||
| y={0} | |||
| width={stageWidth} | |||
| height={stageHeight} | |||
| fill="white" /> | |||
| fill="white" | |||
| listening={false} /> | |||
| </Layer> | |||
| {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) => ( | |||
| <Line key={i} | |||
| points={line.points} | |||
| stroke={line.mode === Mode.Rubber ? 'black' : line.stroke} | |||
| strokeWidth={line.strokeWidth} | |||
| tension={.5} | |||
| lineCap="round" | |||
| globalCompositeOperation={line.mode === Mode.Rubber | |||
| ? 'destination-out' | |||
| : 'source-over'} />))} | |||
| {layer.lines.map ((line, i) => { | |||
| switch (line.mode) | |||
| { | |||
| case Mode.Pen: | |||
| case Mode.Rubber: | |||
| return ( | |||
| <Line key={i} | |||
| points={line.points} | |||
| stroke={line.mode === Mode.Rubber ? 'black' : line.stroke} | |||
| strokeWidth={line.strokeWidth} | |||
| tension={.5} | |||
| lineCap="round" | |||
| globalCompositeOperation={line.mode === Mode.Rubber | |||
| ? 'destination-out' | |||
| : 'source-over'} />) | |||
| case Mode.Paint: | |||
| return ( | |||
| <Image image={images[line.imageSrc]} | |||
| x={0} | |||
| y={0} | |||
| scaleX={1} | |||
| scaleY={1} />) | |||
| } | |||
| })} | |||
| </Layer>))} | |||
| </Stage> | |||
| </div> | |||