みてるぞ 4 days ago
parent
commit
22a55f265c
1 changed files with 180 additions and 23 deletions
  1. +180
    -23
      frontend/src/components/threads/ThreadCanvas.tsx

+ 180
- 23
frontend/src/components/threads/ThreadCanvas.tsx View File

@@ -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>


Loading…
Cancel
Save