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