| @@ -16,19 +16,94 @@ type Layer = { | |||||
| future: Line[][] | future: Line[][] | ||||
| image?: ImageItem } | 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 = { | const Mode = { | ||||
| Pen: 'Pen', | Pen: 'Pen', | ||||
| Rubber: 'Rubber', | Rubber: 'Rubber', | ||||
| Image: 'Image' } as const | |||||
| Image: 'Image', | |||||
| Paint: 'Paint' } as const | |||||
| type Mode = (typeof Mode)[keyof typeof Mode] | 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 => ( | const isLayer = (obj: unknown): obj is Layer => ( | ||||
| typeof obj === 'object' | typeof obj === 'object' | ||||
| && obj !== null | && obj !== null | ||||
| @@ -147,6 +222,51 @@ export default () => { | |||||
| fileInputRef.current.value = '' | 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[]) => { | const updateActiveLayerLines = (lines: Line[]) => { | ||||
| setLayers (prev => ( | setLayers (prev => ( | ||||
| prev.map (layer => (layer.id === activeLayerId | prev.map (layer => (layer.id === activeLayerId | ||||
| @@ -258,6 +378,18 @@ export default () => { | |||||
| setImages (prev => ({ ...prev, [layer.id]: image })) | 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]) | }, [layers]) | ||||
| @@ -330,6 +462,7 @@ export default () => { | |||||
| onChange={() => setMode (Mode.Pen)} /> | onChange={() => setMode (Mode.Pen)} /> | ||||
| ペン | ペン | ||||
| </label> | </label> | ||||
| <label> | <label> | ||||
| <input type="radio" | <input type="radio" | ||||
| name="mode" | name="mode" | ||||
| @@ -338,6 +471,15 @@ export default () => { | |||||
| onChange={() => setMode (Mode.Rubber)} /> | onChange={() => setMode (Mode.Rubber)} /> | ||||
| 消しゴム | 消しゴム | ||||
| </label> | </label> | ||||
| <label> | |||||
| <input type="radio" | |||||
| name="mode" | |||||
| value="paint" | |||||
| checked={mode === Mode.Paint} | |||||
| onChange={() => setMode (Mode.Paint)} /> | |||||
| 塗りつぶし | |||||
| </label> | |||||
| </div> | </div> | ||||
| <div> | <div> | ||||
| @@ -421,18 +563,19 @@ export default () => { | |||||
| }} | }} | ||||
| width={stageWidth} | width={stageWidth} | ||||
| height={stageHeight} | 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> | <Layer> | ||||
| <Rect x={0} | <Rect x={0} | ||||
| y={0} | y={0} | ||||
| width={stageWidth} | width={stageWidth} | ||||
| height={stageHeight} | height={stageHeight} | ||||
| fill="white" /> | |||||
| fill="white" | |||||
| listening={false} /> | |||||
| </Layer> | </Layer> | ||||
| {layers.map (layer => ( | {layers.map (layer => ( | ||||
| @@ -444,16 +587,30 @@ export default () => { | |||||
| scaleX={stageWidth / images[layer.id].width} | scaleX={stageWidth / images[layer.id].width} | ||||
| scaleY={stageHeight / images[layer.id].height} />)} | 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>))} | </Layer>))} | ||||
| </Stage> | </Stage> | ||||
| </div> | </div> | ||||