This commit is contained in:
Generated
+98
@@ -11,10 +11,12 @@
|
|||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"camelcase-keys": "^9.1.3",
|
"camelcase-keys": "^9.1.3",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"konva": "^9.3.22",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-accessible-accordion": "^5.0.1",
|
"react-accessible-accordion": "^5.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-konva": "^19.0.7",
|
||||||
"react-router-dom": "^7.7.0"
|
"react-router-dom": "^7.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1477,6 +1479,15 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-reconciler": {
|
||||||
|
"version": "0.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.0.tgz",
|
||||||
|
"integrity": "sha512-+WHarFkJevhH1s655qeeSEf/yxFST0dVRsmSqUgxG8mMOKqycgYBv2wVpyubBY7MX8KiX5FQ03rNIwrxfm7Bmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.37.0",
|
"version": "8.37.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
|
||||||
@@ -3141,6 +3152,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/its-fine": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react-reconciler": "^0.28.9"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/its-fine/node_modules/@types/react-reconciler": {
|
||||||
|
"version": "0.28.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
|
||||||
|
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jackspeak": {
|
"node_modules/jackspeak": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
@@ -3244,6 +3276,26 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/konva": {
|
||||||
|
"version": "9.3.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz",
|
||||||
|
"integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/lavrton"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/konva"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/lavrton"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -3902,6 +3954,52 @@
|
|||||||
"react": "*"
|
"react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-konva": {
|
||||||
|
"version": "19.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.0.7.tgz",
|
||||||
|
"integrity": "sha512-uYWCpSv4ajLymTh8S8fV9396fHDX7eDTWiLGkYlBuawud5MoNiuGjapPhA5Avdy/Jfh9P2KaWuNf4i9PI1F9HQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/lavrton"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/konva"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/lavrton"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react-reconciler": "^0.32.0",
|
||||||
|
"its-fine": "^2.0.0",
|
||||||
|
"react-reconciler": "0.32.0",
|
||||||
|
"scheduler": "0.26.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
|
||||||
|
"react": "^18.3.1 || ^19.0.0",
|
||||||
|
"react-dom": "^18.3.1 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-reconciler": {
|
||||||
|
"version": "0.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz",
|
||||||
|
"integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.26.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"camelcase-keys": "^9.1.3",
|
"camelcase-keys": "^9.1.3",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"konva": "^9.3.22",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-accessible-accordion": "^5.0.1",
|
"react-accessible-accordion": "^5.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-konva": "^19.0.7",
|
||||||
"react-router-dom": "^7.7.0"
|
"react-router-dom": "^7.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+21
-9
@@ -1,6 +1,11 @@
|
|||||||
import cn from 'classnames'
|
import cn from 'classnames'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { BrowserRouter, Link, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter,
|
||||||
|
Link,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import bgmSrc from '@/assets/music.mp3'
|
import bgmSrc from '@/assets/music.mp3'
|
||||||
import ThreadListPage from '@/pages/threads/ThreadListPage'
|
import ThreadListPage from '@/pages/threads/ThreadListPage'
|
||||||
@@ -18,13 +23,16 @@ const colours = ['bg-fuchsia-500 dark:bg-fuchsia-900',
|
|||||||
|
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const [bgm] = useState (new Audio (bgmSrc))
|
const bgmRef = useRef<HTMLAudioElement | null> (null)
|
||||||
|
|
||||||
const [colourIndex, setColourIndex] = useState (0)
|
const [colourIndex, setColourIndex] = useState (0)
|
||||||
const [mute, setMute] = useState (false)
|
const [mute, setMute] = useState (false)
|
||||||
const [playing, setPlaying] = useState (false)
|
const [playing, setPlaying] = useState (false)
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
bgm.loop = true
|
bgmRef.current = new Audio (bgmSrc)
|
||||||
|
bgmRef.current.loop = true
|
||||||
|
bgmRef.current.volume = 1
|
||||||
|
|
||||||
const playBGM = async () => {
|
const playBGM = async () => {
|
||||||
if (playing)
|
if (playing)
|
||||||
@@ -32,9 +40,7 @@ export default () => {
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bgm.play ()
|
await bgmRef.current.play ()
|
||||||
bgm.loop = true
|
|
||||||
|
|
||||||
setPlaying (true)
|
setPlaying (true)
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -64,7 +70,7 @@ export default () => {
|
|||||||
<div className={cn ('w-screen min-h-screen',
|
<div className={cn ('w-screen min-h-screen',
|
||||||
colours[colourIndex],
|
colours[colourIndex],
|
||||||
'transition-colors duration-[3s] ease-linear')}>
|
'transition-colors duration-[3s] ease-linear')}>
|
||||||
<div className="mx-auto max-w-[960px]">
|
<div className="mx-auto max-w-[960px] px-4">
|
||||||
<header className="pt-6 mb-8">
|
<header className="pt-6 mb-8">
|
||||||
<h1 className="text-center">
|
<h1 className="text-center">
|
||||||
<Link to="/"
|
<Link to="/"
|
||||||
@@ -80,7 +86,7 @@ export default () => {
|
|||||||
{playing && (
|
{playing && (
|
||||||
<a href="#" onClick={ev => {
|
<a href="#" onClick={ev => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
setMute (bgm.muted = !(mute))
|
setMute (bgmRef.current.muted = !(mute))
|
||||||
}}>
|
}}>
|
||||||
{mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'}
|
{mute ? 'やっぱり BGM が恋しい人用' : 'BGM がうるさい人用'}
|
||||||
</a>)}
|
</a>)}
|
||||||
@@ -88,6 +94,12 @@ export default () => {
|
|||||||
</header>
|
</header>
|
||||||
<main className="mb-8">
|
<main className="mb-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{() => {
|
||||||
|
const { pathname } = useLocation ()
|
||||||
|
useEffect (() => {
|
||||||
|
scrollTo (0, 0)
|
||||||
|
}, [pathname])
|
||||||
|
}}
|
||||||
<Route path="/" element={<Navigate to="/threads" replace />} />
|
<Route path="/" element={<Navigate to="/threads" replace />} />
|
||||||
<Route path="/threads" element={<ThreadListPage />} />
|
<Route path="/threads" element={<ThreadListPage />} />
|
||||||
<Route path="/threads/:id" element={<ThreadDetailPage />} />
|
<Route path="/threads/:id" element={<ThreadDetailPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { FaRedo, FaUndo } from 'react-icons/fa'
|
||||||
|
import { Layer, Line, Rect, Stage, Image } from 'react-konva'
|
||||||
|
|
||||||
|
import type { KonvaEventObject } from 'konva'
|
||||||
|
import type { ChangeEventHandler } from 'react'
|
||||||
|
|
||||||
|
type ImageItem = { src: string }
|
||||||
|
|
||||||
|
type Layer = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lines: Line[]
|
||||||
|
history: Line[][]
|
||||||
|
future: Line[][]
|
||||||
|
image?: ImageItem }
|
||||||
|
|
||||||
|
type Line = {
|
||||||
|
mode: Mode
|
||||||
|
points: number[]
|
||||||
|
stroke: string
|
||||||
|
strokeWidth: number }
|
||||||
|
|
||||||
|
const Mode = {
|
||||||
|
Pen: Symbol (),
|
||||||
|
Rubber: Symbol (),
|
||||||
|
Image: Symbol () } as const
|
||||||
|
type Mode = keyof Mode
|
||||||
|
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const drawingRef = useRef (false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement> (null)
|
||||||
|
const stageRef = useRef<Stage | null> (null)
|
||||||
|
|
||||||
|
const [activeLayerId, setActiveLayerId] = useState<string | null> (null)
|
||||||
|
const [colour, setColour] = useState ('#000000')
|
||||||
|
const [images, setImages] = useState<Record<string, window.Image[]>> ({ })
|
||||||
|
const [layers, setLayers] = useState<Layer[]> ([])
|
||||||
|
const [layerCnt, setLayerCnt] = useState (0)
|
||||||
|
const [mode, setMode] = useState<Mode> (Mode.Pen)
|
||||||
|
const [pointSize, setPointSize] = useState (3)
|
||||||
|
const [stageWidth, setStageWidth] = useState (480)
|
||||||
|
const [stageHeight, setStageHeight] = useState (480)
|
||||||
|
|
||||||
|
const activeLayer = layers.find (l => l.id === activeLayerId)
|
||||||
|
|
||||||
|
const handleMouseDown = (ev: KonvaEventObject<MouseEvent>) => {
|
||||||
|
drawingRef.current = true
|
||||||
|
const pos = ev.target.getStage ()?.getPointerPosition ()
|
||||||
|
if (!(pos))
|
||||||
|
return
|
||||||
|
const lines: Line[] = [
|
||||||
|
...activeLayer.lines,
|
||||||
|
{ mode,
|
||||||
|
points: [pos.x, pos.y],
|
||||||
|
stroke: colour,
|
||||||
|
strokeWidth: pointSize }]
|
||||||
|
updateActiveLayerLines (lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (ev: KonvaEventObject<MouseEvent>) => {
|
||||||
|
if (!(drawingRef.current))
|
||||||
|
return
|
||||||
|
const stage = ev.target.getStage ()
|
||||||
|
const point = stage?.getPointerPosition ()
|
||||||
|
if (!(point))
|
||||||
|
return
|
||||||
|
|
||||||
|
const lastLine = activeLayer.lines[activeLayer.lines.length - 1]
|
||||||
|
const updatedLine = { ...lastLine,
|
||||||
|
points: [...lastLine.points, point.x, point.y] }
|
||||||
|
const newLines = [...activeLayer.lines.slice (0, -1), updatedLine]
|
||||||
|
updateActiveLayerLines (newLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
drawingRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (activeLayer.lines.length === 0)
|
||||||
|
return
|
||||||
|
const newLines = [...activeLayer.lines]
|
||||||
|
const last = newLines.pop()!
|
||||||
|
updateActiveLayerHistory ([...activeLayer.history, activeLayer.lines])
|
||||||
|
updateActiveLayerFuture ([])
|
||||||
|
updateActiveLayerLines (newLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
if (activeLayer.history.length === 0)
|
||||||
|
return
|
||||||
|
const prev = activeLayer.history[activeLayer.history.length - 1]
|
||||||
|
updateActiveLayerLines (prev)
|
||||||
|
updateActiveLayerHistory (activeLayer.history.slice (0, -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportImage: ChangeEventHandler<HTMLInputElement> = ev => {
|
||||||
|
const file = ev.target.files?.[0]
|
||||||
|
if (!(file))
|
||||||
|
return
|
||||||
|
|
||||||
|
const reader = new FileReader ()
|
||||||
|
reader.onload = () => {
|
||||||
|
const src = reader.result?.toString ()
|
||||||
|
if (!(src))
|
||||||
|
return
|
||||||
|
addLayer ({ src })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL (file)
|
||||||
|
|
||||||
|
if (fileInputRef.current)
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateActiveLayerLines = (lines: Line[]) => {
|
||||||
|
setLayers (prev => (
|
||||||
|
prev.map (layer => (layer.id === activeLayerId
|
||||||
|
? { ...layer, lines }
|
||||||
|
: layer))))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateActiveLayerHistory = (history: Line[][]) => {
|
||||||
|
setLayers (prev => (
|
||||||
|
prev.map (layer => (layer.id === activeLayerId
|
||||||
|
? { ...layer, history }
|
||||||
|
: layer))))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateActiveLayerFuture = (future: Line[][]) => {
|
||||||
|
setLayers (prev => (
|
||||||
|
prev.map (layer => (layer.id === activeLayerId
|
||||||
|
? { ...layer, future }
|
||||||
|
: layer))))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addLayer = (image?: ImageItem) => {
|
||||||
|
const layer: Layer = { id: nanoid (),
|
||||||
|
name: `Layer ${ layerCnt + 1 }`,
|
||||||
|
lines: [],
|
||||||
|
history: [],
|
||||||
|
future: [],
|
||||||
|
image }
|
||||||
|
setLayers(prev => [...prev, layer])
|
||||||
|
setActiveLayerId (layer.id)
|
||||||
|
setLayerCnt (n => n + 1)
|
||||||
|
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveLayer = (direction: number) => {
|
||||||
|
setLayers(prev => {
|
||||||
|
const idx = prev.findIndex (l => l.id === activeLayerId)
|
||||||
|
if (idx < 0)
|
||||||
|
return prev
|
||||||
|
const target = idx + direction
|
||||||
|
if (target < 0 || target >= prev.length)
|
||||||
|
return prev
|
||||||
|
const next = [...prev]
|
||||||
|
const [item] = next.splice(idx, 1)
|
||||||
|
next.splice(target, 0, item)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const paint = JSON.parse (localStorage.getItem ('paint'))
|
||||||
|
if (!(paint instanceof Array))
|
||||||
|
throw new Error
|
||||||
|
setLayers (paint)
|
||||||
|
setActiveLayerId (paint[0].id)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
const layer: Layer = { id: nanoid (),
|
||||||
|
name: `Layer ${ layerCnt + 1 }`,
|
||||||
|
lines: [],
|
||||||
|
history: [],
|
||||||
|
future: [] }
|
||||||
|
setLayers([layer])
|
||||||
|
setActiveLayerId (layer.id)
|
||||||
|
setLayerCnt (n => n + 1)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
localStorage.setItem ('paint', JSON.stringify (layers))
|
||||||
|
|
||||||
|
layers.forEach (layer => {
|
||||||
|
if (layer.image && !(layer.id in images))
|
||||||
|
{
|
||||||
|
const image = new window.Image
|
||||||
|
image.src = layer.image.src
|
||||||
|
image.onload = () => {
|
||||||
|
setImages (prev => ({ ...prev, [layer.id]: image }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [layers])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (!(confirm ('作成中の絵を消してもよろしいですか?')))
|
||||||
|
return
|
||||||
|
|
||||||
|
setLayers (layers.map (layer => ({
|
||||||
|
...layer,
|
||||||
|
lines: [],
|
||||||
|
hishory: [],
|
||||||
|
future: [] })))
|
||||||
|
}}>
|
||||||
|
初期化
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => fileInputRef.current?.click ()}>
|
||||||
|
画像
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportImage} />
|
||||||
|
|
||||||
|
<button onClick={() => {
|
||||||
|
const stage = stageRef.current
|
||||||
|
if (!(stage))
|
||||||
|
return
|
||||||
|
const mimeType = 'image/png'
|
||||||
|
const dataURL = stage.toDataURL ({ mimeType, pixelRatio: 2 })
|
||||||
|
|
||||||
|
const $a = document.createElement ('a')
|
||||||
|
$a.href = dataURL
|
||||||
|
$a.download = `kekec-bbs_${ (new Date).toLocaleString ('ja-JP-u-ca-japanese').replaceAll (/[\/:]/g, '-').replaceAll (/\s/g, '_') }.png`
|
||||||
|
document.body.appendChild ($a)
|
||||||
|
$a.click ()
|
||||||
|
$a.remove ()
|
||||||
|
}}>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>モード:</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio"
|
||||||
|
name="mode"
|
||||||
|
value="pen"
|
||||||
|
checked={mode === Mode.Pen}
|
||||||
|
onChange={() => setMode (Mode.Pen)} />
|
||||||
|
ペン
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio"
|
||||||
|
name="mode"
|
||||||
|
value="rubber"
|
||||||
|
checked={mode === Mode.Rubber}
|
||||||
|
onChange={() => setMode (Mode.Rubber)} />
|
||||||
|
消しゴム
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button onClick={handleUndo} disabled={activeLayer?.lines.length === 0}>
|
||||||
|
<FaUndo />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleRedo} disabled={activeLayer?.history.length === 0}>
|
||||||
|
<FaRedo />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>色:</label>
|
||||||
|
<input type="color"
|
||||||
|
value={colour}
|
||||||
|
onChange={ev => setColour (ev.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>サイズ:</label>
|
||||||
|
<input type="range"
|
||||||
|
min={1}
|
||||||
|
max={57}
|
||||||
|
value={pointSize}
|
||||||
|
onChange={ev => setPointSize (+ev.target.value)} />
|
||||||
|
{pointSize}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>レイア:</label>
|
||||||
|
{layers.map (layer => (
|
||||||
|
<label key={layer.id}>
|
||||||
|
<input type="radio"
|
||||||
|
name="layer"
|
||||||
|
value={layer.id}
|
||||||
|
checked={layer.id === activeLayerId}
|
||||||
|
onChange={() => setActiveLayerId (layer.id)} />
|
||||||
|
{layer.name}
|
||||||
|
</label>))}
|
||||||
|
<button onClick={() => {
|
||||||
|
if (layers.length === 1)
|
||||||
|
{
|
||||||
|
alert ('何を消そうというのだ.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(confirm ('選択中のレイアを削除するがよろしいか?')))
|
||||||
|
return
|
||||||
|
|
||||||
|
let idx = layers.findIndex (l => l.id === activeLayerId) - 1
|
||||||
|
if (idx < 0)
|
||||||
|
idx = 0
|
||||||
|
const newLayers = layers.filter (l => l.id !== activeLayerId)
|
||||||
|
setActiveLayerId (newLayers[idx].id)
|
||||||
|
setLayers (newLayers)
|
||||||
|
}}>
|
||||||
|
削除
|
||||||
|
</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
moveLayer (-1)
|
||||||
|
}}>
|
||||||
|
下へ
|
||||||
|
</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
moveLayer (1)
|
||||||
|
}}>
|
||||||
|
上へ
|
||||||
|
</button>
|
||||||
|
<button onClick={() => addLayer ()}>
|
||||||
|
追加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex items-center justify-center mb-16">
|
||||||
|
<div className="border border-black dark:border-white">
|
||||||
|
<Stage ref={node => {
|
||||||
|
stageRef.current = node
|
||||||
|
return node
|
||||||
|
}}
|
||||||
|
width={stageWidth}
|
||||||
|
height={stageHeight}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onTouchStart={handleMouseDown}
|
||||||
|
onTouchMove={handleMouseMove}
|
||||||
|
onTouchEnd={handleMouseUp}>
|
||||||
|
<Layer>
|
||||||
|
<Rect x={0}
|
||||||
|
y={0}
|
||||||
|
width={stageWidth}
|
||||||
|
height={stageHeight}
|
||||||
|
fill="white" />
|
||||||
|
</Layer>
|
||||||
|
|
||||||
|
{layers.map (layer => (
|
||||||
|
<Layer key={layer.id}>
|
||||||
|
{images[layer.id] && (
|
||||||
|
<Image image={images[layer.id]}
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
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>))}
|
||||||
|
</Stage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { FaThumbsDown, FaThumbsUp } from 'react-icons/fa'
|
import { FaThumbsDown, FaThumbsUp } from 'react-icons/fa'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import ThreadCanvas from '@/components/threads/ThreadCanvas'
|
||||||
import { API_BASE_URL } from '@/config'
|
import { API_BASE_URL } from '@/config'
|
||||||
|
|
||||||
|
|
||||||
@@ -12,8 +13,21 @@ export default () => {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState (true)
|
const [loading, setLoading] = useState (true)
|
||||||
const [posts, setPosts] = useState<Post[]> ([])
|
const [posts, setPosts] = useState<Post[]> ([])
|
||||||
|
const [thread, setThread] = useState<Thread | null> (null)
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
const fetchThread = async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const res = await axios.get (`${ API_BASE_URL }/threads/${ id }`)
|
||||||
|
setThread (toCamel (res.data as any, { deep: true }) as Thread)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
setThread (null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchPosts = async () => {
|
const fetchPosts = async () => {
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
try
|
try
|
||||||
@@ -26,21 +40,29 @@ export default () => {
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
;
|
setPosts ([])
|
||||||
}
|
}
|
||||||
setLoading (false)
|
setLoading (false)
|
||||||
}
|
}
|
||||||
|
|
||||||
setPosts ([])
|
fetchThread ()
|
||||||
fetchPosts ()
|
fetchPosts ()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(thread))
|
||||||
|
return
|
||||||
|
document.title = `${ thread.name } - キケッツチャンネル お絵描き掲示板`
|
||||||
|
}, [thread])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ThreadCanvas />
|
||||||
{loading ? 'Loading...' : (
|
{loading ? 'Loading...' : (
|
||||||
posts.length > 0
|
posts.length > 0
|
||||||
? posts.map (post => (
|
? posts.map (post => (
|
||||||
<div className="bg-white dark:bg-gray-800 p-3 m-4
|
<div key={post.id}
|
||||||
|
className="bg-white dark:bg-gray-800 p-3 m-4
|
||||||
border border-gray-400 rounded-xl
|
border border-gray-400 rounded-xl
|
||||||
text-center">
|
text-center">
|
||||||
<div className="flex justify-between items-center px-2 py-1">
|
<div className="flex justify-between items-center px-2 py-1">
|
||||||
@@ -55,7 +77,7 @@ export default () => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`)
|
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`)
|
||||||
setPosts (prev => prev.map (p => (p.id == post.id
|
setPosts (prev => prev.map (p => (p.id === post.id
|
||||||
? { ...p, bad: p.bad + 1 }
|
? { ...p, bad: p.bad + 1 }
|
||||||
: p)))
|
: p)))
|
||||||
}
|
}
|
||||||
@@ -83,7 +105,7 @@ export default () => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`)
|
await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`)
|
||||||
setPosts (prev => prev.map (p => (p.id == post.id
|
setPosts (prev => prev.map (p => (p.id === post.id
|
||||||
? { ...p, good: p.good + 1 }
|
? { ...p, good: p.good + 1 }
|
||||||
: p)))
|
: p)))
|
||||||
}
|
}
|
||||||
@@ -95,7 +117,7 @@ export default () => {
|
|||||||
{post.good} <FaThumbsUp className="inline" />
|
{post.good} <FaThumbsUp className="inline" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white inline-block">
|
<div className="bg-white inline-block border border-black dark:border-white">
|
||||||
<img src={`${ API_BASE_URL }${ post.imageUrl }`} />
|
<img src={`${ API_BASE_URL }${ post.imageUrl }`} />
|
||||||
</div>
|
</div>
|
||||||
</div>))
|
</div>))
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
document.title = 'キケッツチャンネル お絵描き掲示板'
|
||||||
fetchThreads ()
|
fetchThreads ()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -66,7 +67,8 @@ export default () => {
|
|||||||
{loading ? 'Loading...' : (
|
{loading ? 'Loading...' : (
|
||||||
threads.length > 0
|
threads.length > 0
|
||||||
? threads.map (thread => (
|
? threads.map (thread => (
|
||||||
<div className="bg-white dark:bg-gray-800 p-3 m-4
|
<div key={thread.id}
|
||||||
|
className="bg-white dark:bg-gray-800 p-3 m-4
|
||||||
border border-gray-400 rounded-xl">
|
border border-gray-400 rounded-xl">
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/threads/${ thread.id }`}>
|
<Link to={`/threads/${ thread.id }`}>
|
||||||
@@ -76,7 +78,7 @@ export default () => {
|
|||||||
{thread.description?.replaceAll ('\r\n', '\n')
|
{thread.description?.replaceAll ('\r\n', '\n')
|
||||||
.replaceAll ('\r', '\n')
|
.replaceAll ('\r', '\n')
|
||||||
.split ('\n')
|
.split ('\n')
|
||||||
.map (l => <p>{l}</p>)}
|
.map ((l, i) => <p key={i}>{l}</p>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 justify-between text-sm
|
<div className="grid grid-cols-3 justify-between text-sm
|
||||||
|
|||||||
Reference in New Issue
Block a user