This commit is contained in:
Generated
+41
@@ -9,6 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
@@ -370,6 +372,45 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import TagLink from '@/components/TagLink'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { CSSProperties, FC, MutableRefObject } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
type Props = {
|
||||
tag: Tag
|
||||
nestLevel: number
|
||||
pathKey: string
|
||||
parentTagId?: number
|
||||
suppressClickRef: MutableRefObject<boolean> }
|
||||
|
||||
|
||||
export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Props) => {
|
||||
const dndId = `tag-node:${ pathKey }`
|
||||
|
||||
const downPosRef = useRef<{ x: number; y: number } | null> (null)
|
||||
const armedRef = useRef (false)
|
||||
|
||||
const armEatNextClick = () => {
|
||||
if (armedRef.current)
|
||||
return
|
||||
|
||||
armedRef.current = true
|
||||
suppressClickRef.current = true
|
||||
|
||||
const handler = (ev: MouseEvent) => {
|
||||
ev.preventDefault ()
|
||||
ev.stopPropagation ()
|
||||
armedRef.current = false
|
||||
suppressClickRef.current = false
|
||||
}
|
||||
|
||||
addEventListener ('click', handler, { capture: true, once: true })
|
||||
}
|
||||
|
||||
const { attributes,
|
||||
listeners,
|
||||
setNodeRef: setDragRef,
|
||||
transform,
|
||||
isDragging: dragging } = useDraggable ({ id: dndId,
|
||||
data: { kind: 'tag',
|
||||
tagId: tag.id,
|
||||
parentTagId } })
|
||||
|
||||
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
|
||||
id: dndId,
|
||||
data: { kind: 'tag', tagId: tag.id } })
|
||||
|
||||
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
||||
opacity: dragging ? .5 : 1 }
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDownCapture={e => {
|
||||
downPosRef.current = { x: e.clientX, y: e.clientY }
|
||||
}}
|
||||
onPointerMoveCapture={e => {
|
||||
const p = downPosRef.current
|
||||
if (!(p))
|
||||
return
|
||||
const dx = e.clientX - p.x
|
||||
const dy = e.clientY - p.y
|
||||
if (dx * dx + dy * dy >= 9)
|
||||
armEatNextClick ()
|
||||
}}
|
||||
onPointerUpCapture={() => {
|
||||
downPosRef.current = null
|
||||
}}
|
||||
onClickCapture={e => {
|
||||
if (suppressClickRef.current)
|
||||
{
|
||||
e.preventDefault ()
|
||||
e.stopPropagation ()
|
||||
}
|
||||
}}
|
||||
ref={node => {
|
||||
setDragRef (node)
|
||||
setDropRef (node)
|
||||
}}
|
||||
style={style}
|
||||
className={cn ('rounded select-none touch-none', over && 'ring-2 ring-offset-2')}
|
||||
{...attributes}
|
||||
{...listeners}>
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
</div>)
|
||||
}) satisfies FC<Props>
|
||||
@@ -1,14 +1,16 @@
|
||||
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import TagLink from '@/components/TagLink'
|
||||
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||
import TagSearch from '@/components/TagSearch'
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { CATEGORIES } from '@/consts'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||
|
||||
import type { Category, Post, Tag } from '@/types'
|
||||
|
||||
@@ -19,6 +21,8 @@ const renderTagTree = (
|
||||
tag: Tag,
|
||||
nestLevel: number,
|
||||
path: string,
|
||||
suppressClickRef: MutableRefObject<boolean>,
|
||||
parentTagId?: number,
|
||||
): ReactNode[] => {
|
||||
const key = `${ path }-${ tag.id }`
|
||||
|
||||
@@ -28,23 +32,176 @@ const renderTagTree = (
|
||||
layout
|
||||
transition={{ duration: .2, ease: 'easeOut' }}
|
||||
className="mb-1">
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
<DraggableDroppableTagRow
|
||||
tag={tag}
|
||||
nestLevel={nestLevel}
|
||||
pathKey={key}
|
||||
parentTagId={parentTagId}
|
||||
suppressClickRef={suppressClickRef}/>
|
||||
</motion.li>)
|
||||
|
||||
return [self,
|
||||
...((tag.children
|
||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||
.flatMap (child => renderTagTree (child, nestLevel + 1, key)))
|
||||
.flatMap (child => (
|
||||
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id))))
|
||||
?? [])]
|
||||
}
|
||||
|
||||
|
||||
const removeEverywhere = (
|
||||
list: Tag[],
|
||||
tagId: number,
|
||||
): { next: Tag[]
|
||||
picked?: Tag } => {
|
||||
let picked: Tag | undefined
|
||||
|
||||
const walk = (nodes: Tag[]): Tag[] => (
|
||||
nodes
|
||||
.map (t => {
|
||||
const children = t.children ? walk (t.children) : undefined
|
||||
return children ? { ...t, children } : t
|
||||
})
|
||||
.filter (t => {
|
||||
if (t.id === tagId)
|
||||
{
|
||||
picked = picked ?? t
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}))
|
||||
|
||||
return { next: walk (list), picked }
|
||||
}
|
||||
|
||||
|
||||
const addAsChild = (
|
||||
list: Tag[],
|
||||
parentId: number,
|
||||
picked: Tag,
|
||||
): Tag[] => {
|
||||
const walk = (nodes: Tag[]): Tag[] => (
|
||||
nodes.map (t => {
|
||||
if (t.id !== parentId)
|
||||
return t.children ? { ...t, children: walk (t.children) } : t
|
||||
|
||||
const cur = t.children ?? []
|
||||
const exists = cur.some (c => c.id === picked.id)
|
||||
const children = exists ? cur : [...cur, { ...picked, children: picked.children ?? [] }]
|
||||
|
||||
return { ...t, children }
|
||||
}))
|
||||
|
||||
return walk (list)
|
||||
}
|
||||
|
||||
|
||||
const attachChildOptimistic = (
|
||||
prev: TagByCategory,
|
||||
parentId: number,
|
||||
childId: number,
|
||||
): TagByCategory => {
|
||||
const next: TagByCategory = { ...prev }
|
||||
|
||||
let picked: Tag | undefined
|
||||
for (const cat of Object.keys (next) as (keyof typeof next)[])
|
||||
{
|
||||
const r = removeEverywhere (next[cat], childId)
|
||||
next[cat] = r.next
|
||||
picked = picked ?? r.picked
|
||||
}
|
||||
if (!(picked))
|
||||
return prev
|
||||
|
||||
for (const cat of Object.keys (next) as (keyof typeof next)[])
|
||||
next[cat] = addAsChild (next[cat], parentId, picked)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
const isDescendant = (
|
||||
root: Tag,
|
||||
targetId: number,
|
||||
): boolean => {
|
||||
if (!(root.children))
|
||||
return false
|
||||
|
||||
for (const c of root.children)
|
||||
{
|
||||
if (c.id === targetId)
|
||||
return true
|
||||
if (isDescendant (c, targetId))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
const findTag = (
|
||||
byCat: TagByCategory,
|
||||
id: number,
|
||||
): Tag | undefined => {
|
||||
const walk = (nodes: Tag[]): Tag | undefined => {
|
||||
for (const t of nodes)
|
||||
{
|
||||
if (t.id === id)
|
||||
return t
|
||||
|
||||
const found = t.children ? walk (t.children) : undefined
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const cat of Object.keys (byCat) as (keyof typeof byCat)[])
|
||||
{
|
||||
const found = walk (byCat[cat] ?? [])
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
type Props = { post: Post | null }
|
||||
|
||||
|
||||
export default (({ post }: Props) => {
|
||||
const [tags, setTags] = useState ({ } as TagByCategory)
|
||||
|
||||
const suppressClickRef = useRef (false)
|
||||
|
||||
const sensors = useSensors (
|
||||
useSensor (PointerSensor, { activationConstraint: { distance: 6 } }))
|
||||
|
||||
const onDragEnd = async (e: DragEndEvent) => {
|
||||
const childId: number | undefined = e.active.data.current?.tagId
|
||||
const parentId: number | undefined = e.over?.data.current?.tagId
|
||||
|
||||
if (!(childId) || !(parentId))
|
||||
return
|
||||
|
||||
if (childId === parentId)
|
||||
return
|
||||
|
||||
setTags (prev => {
|
||||
const child = findTag (prev, childId)
|
||||
const parent = findTag (prev, parentId)
|
||||
if (!(child) || !(parent))
|
||||
return prev
|
||||
|
||||
if (isDescendant (child, parentId))
|
||||
return prev
|
||||
|
||||
return attachChildOptimistic (prev, parentId, childId)
|
||||
})
|
||||
}
|
||||
|
||||
const categoryNames: Record<Category, string> = {
|
||||
deerjikist: 'ニジラー',
|
||||
meme: '原作・ネタ元・ミーム等',
|
||||
@@ -76,14 +233,34 @@ export default (({ post }: Props) => {
|
||||
return (
|
||||
<SidebarComponent>
|
||||
<TagSearch/>
|
||||
<motion.div layout>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={() => {
|
||||
suppressClickRef.current = true
|
||||
document.body.style.userSelect = 'none'
|
||||
getSelection?.()?.removeAllRanges?.()
|
||||
addEventListener ('click', e => {
|
||||
e.preventDefault ()
|
||||
e.stopPropagation ()
|
||||
suppressClickRef.current = false
|
||||
}, { capture: true, once: true })
|
||||
}}
|
||||
onDragCancel={() => {
|
||||
document.body.style.userSelect = ''
|
||||
}}
|
||||
onDragEnd={e => {
|
||||
onDragEnd (e)
|
||||
document.body.style.userSelect = ''
|
||||
}}>
|
||||
<motion.div key={post?.id ?? 0} layout>
|
||||
{CATEGORIES.map ((cat: Category) => cat in tags && (
|
||||
<motion.div layout className="my-3" key={cat}>
|
||||
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
|
||||
|
||||
<motion.ul layout>
|
||||
<AnimatePresence initial={false}>
|
||||
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
|
||||
{tags[cat].map (tag => (
|
||||
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</motion.div>))}
|
||||
@@ -131,5 +308,6 @@ export default (({ post }: Props) => {
|
||||
</ul>
|
||||
</div>)}
|
||||
</motion.div>
|
||||
</DndContext>
|
||||
</SidebarComponent>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
Reference in New Issue
Block a user