This commit is contained in:
@@ -16,19 +16,94 @@ type Layer = {
|
|||||||
future: Line[][]
|
future: Line[][]
|
||||||
image?: ImageItem }
|
image?: ImageItem }
|
||||||
|
|
||||||
type Line = {
|
type Line =
|
||||||
mode: Mode
|
| { mode: Mode.Pen | Mode.Rubber
|
||||||
points: number[]
|
points: number[]
|
||||||
stroke: string
|
stroke: string
|
||||||
strokeWidth: number }
|
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}
|
onMouseDown={mode === Mode.Paint ? handlePaint : handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={mode === Mode.Paint ? undefined : handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={mode === Mode.Paint ? undefined : handleMouseUp}
|
||||||
onTouchStart={handleMouseDown}
|
onTouchStart={mode === Mode.Paint ? handlePaint : handleMouseDown}
|
||||||
onTouchMove={handleMouseMove}
|
onTouchMove={mode === Mode.Paint ? undefined : handleMouseMove}
|
||||||
onTouchEnd={handleMouseUp}>
|
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,7 +587,12 @@ 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) => (
|
{layer.lines.map ((line, i) => {
|
||||||
|
switch (line.mode)
|
||||||
|
{
|
||||||
|
case Mode.Pen:
|
||||||
|
case Mode.Rubber:
|
||||||
|
return (
|
||||||
<Line key={i}
|
<Line key={i}
|
||||||
points={line.points}
|
points={line.points}
|
||||||
stroke={line.mode === Mode.Rubber ? 'black' : line.stroke}
|
stroke={line.mode === Mode.Rubber ? 'black' : line.stroke}
|
||||||
@@ -453,7 +601,16 @@ export default () => {
|
|||||||
lineCap="round"
|
lineCap="round"
|
||||||
globalCompositeOperation={line.mode === Mode.Rubber
|
globalCompositeOperation={line.mode === Mode.Rubber
|
||||||
? 'destination-out'
|
? 'destination-out'
|
||||||
: 'source-over'} />))}
|
: '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>
|
||||||
|
|||||||
Reference in New Issue
Block a user