タグ D&D 時の表示位置修正 (#268) #400
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||||
|
import { buildTag } from '@/test/factories'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
const dndKit = vi.hoisted (() => ({
|
||||||
|
useDraggable: vi.fn (),
|
||||||
|
useDroppable: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock ('@dnd-kit/core', () => dndKit)
|
||||||
|
|
||||||
|
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
|
||||||
|
|
||||||
|
const renderRow = (activeDndId?: string) => {
|
||||||
|
renderWithProviders (
|
||||||
|
<DraggableDroppableTagRow
|
||||||
|
activeDndId={activeDndId}
|
||||||
|
tag={tag}
|
||||||
|
nestLevel={2}
|
||||||
|
pathKey="cat-general-7"
|
||||||
|
suppressClickRef={{ current: false }}/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagBody = (): HTMLElement => {
|
||||||
|
const link = document.querySelector<HTMLElement> ('a[title="ドラッグ元"]')
|
||||||
|
|
||||||
|
if (link == null)
|
||||||
|
throw new Error ('tag link not found')
|
||||||
|
|
||||||
|
const body = link.closest ('div')
|
||||||
|
|
||||||
|
if (body == null)
|
||||||
|
throw new Error ('tag body not found')
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
describe ('DraggableDroppableTagRow', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
dndKit.useDraggable.mockReturnValue ({
|
||||||
|
attributes: { 'aria-describedby': 'drag-source' },
|
||||||
|
listeners: { onPointerDown: vi.fn () },
|
||||||
|
setNodeRef: vi.fn (),
|
||||||
|
transform: null })
|
||||||
|
dndKit.useDroppable.mockReturnValue ({
|
||||||
|
isOver: false,
|
||||||
|
setNodeRef: vi.fn () })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('passes dndId through draggable data for active-row tracking', () => {
|
||||||
|
renderRow ()
|
||||||
|
|
||||||
|
expect (dndKit.useDraggable).toHaveBeenCalledWith (
|
||||||
|
expect.objectContaining ({
|
||||||
|
id: 'tag-node:cat-general-7',
|
||||||
|
data: expect.objectContaining ({
|
||||||
|
dndId: 'tag-node:cat-general-7',
|
||||||
|
nestLevel: 2,
|
||||||
|
tagId: 7 }) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('hides only the active drag source from the explicit active dnd id', () => {
|
||||||
|
renderRow ('tag-node:cat-general-7')
|
||||||
|
expect (tagBody ()).toHaveStyle ({ visibility: 'hidden' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps inactive tag rows visible while another tag is dragged', () => {
|
||||||
|
renderRow ('tag-node:other')
|
||||||
|
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,7 @@ import type { CSSProperties, FC, MutableRefObject } from 'react'
|
|||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
activeDndId?: string
|
||||||
tag: Tag
|
tag: Tag
|
||||||
nestLevel: number
|
nestLevel: number
|
||||||
pathKey: string
|
pathKey: string
|
||||||
@@ -21,7 +22,15 @@ type Props = {
|
|||||||
sp?: boolean }
|
sp?: boolean }
|
||||||
|
|
||||||
|
|
||||||
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
|
const DraggableDroppableTagRow: FC<Props> = ({
|
||||||
|
activeDndId,
|
||||||
|
tag,
|
||||||
|
nestLevel,
|
||||||
|
pathKey,
|
||||||
|
parentTagId,
|
||||||
|
suppressClickRef,
|
||||||
|
sp,
|
||||||
|
}) => {
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
@@ -53,18 +62,23 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
const { attributes,
|
const { attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef: setDragRef,
|
setNodeRef: setDragRef,
|
||||||
transform,
|
transform } = useDraggable ({ id: dndId,
|
||||||
isDragging: dragging } = useDraggable ({ id: dndId,
|
data: { kind: 'tag',
|
||||||
data: { kind: 'tag',
|
dndId,
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
parentTagId } })
|
parentTagId,
|
||||||
|
nestLevel } })
|
||||||
|
|
||||||
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
|
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
|
||||||
id: dndId,
|
id: dndId,
|
||||||
data: { kind: 'tag', tagId: tag.id } })
|
data: { kind: 'tag', tagId: tag.id } })
|
||||||
|
|
||||||
|
const activeDragging = activeDndId === dndId
|
||||||
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
||||||
visibility: dragging ? 'hidden' : 'visible' }
|
visibility: activeDragging ? 'hidden' : 'visible' }
|
||||||
|
const innerClassName = cn (
|
||||||
|
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
|
||||||
|
sp && 'touch-pan-y')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -77,12 +91,16 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
return
|
return
|
||||||
const dx = e.clientX - p.x
|
const dx = e.clientX - p.x
|
||||||
const dy = e.clientY - p.y
|
const dy = e.clientY - p.y
|
||||||
|
|
||||||
if (dx * dx + dy * dy >= 9)
|
if (dx * dx + dy * dy >= 9)
|
||||||
armEatNextClick ()
|
armEatNextClick ()
|
||||||
}}
|
}}
|
||||||
onPointerUpCapture={() => {
|
onPointerUpCapture={() => {
|
||||||
downPosRef.current = null
|
downPosRef.current = null
|
||||||
}}
|
}}
|
||||||
|
onPointerCancelCapture={() => {
|
||||||
|
downPosRef.current = null
|
||||||
|
}}
|
||||||
onClickCapture={e => {
|
onClickCapture={e => {
|
||||||
if (suppressClickRef.current)
|
if (suppressClickRef.current)
|
||||||
{
|
{
|
||||||
@@ -90,24 +108,35 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
e.stopPropagation ()
|
e.stopPropagation ()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={node => {
|
ref={setDropRef}
|
||||||
setDragRef (node)
|
|
||||||
setDropRef (node)
|
|
||||||
}}
|
|
||||||
style={style}
|
|
||||||
className={cn (
|
className={cn (
|
||||||
'min-w-0 max-w-full overflow-hidden rounded select-none',
|
'min-w-0 max-w-full overflow-hidden rounded select-none',
|
||||||
|
sp && 'touch-pan-y',
|
||||||
over && 'ring-2 ring-offset-2')}
|
over && 'ring-2 ring-offset-2')}
|
||||||
{...attributes}
|
>
|
||||||
{...listeners}>
|
{activeDragging
|
||||||
<motion.div
|
? (
|
||||||
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
<div
|
||||||
transition={{ layout: layoutTransition }}
|
ref={setDragRef}
|
||||||
layoutId={animationMode === 'off'
|
style={style}
|
||||||
? undefined
|
className={innerClassName}
|
||||||
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
{...attributes}
|
||||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
{...listeners}>
|
||||||
</motion.div>
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
|
</div>)
|
||||||
|
: (
|
||||||
|
<motion.div
|
||||||
|
ref={setDragRef}
|
||||||
|
style={style}
|
||||||
|
className={innerClassName}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
transition={{ layout: layoutTransition }}
|
||||||
|
layoutId={animationMode === 'off'
|
||||||
|
? undefined
|
||||||
|
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||||
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
|
</motion.div>)}
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { DndContext,
|
import { DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
MeasuringStrategy,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
|
pointerWithin,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors } from '@dnd-kit/core'
|
useSensors } from '@dnd-kit/core'
|
||||||
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
|
import { restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -25,15 +27,23 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
|||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { DragEndEvent } from '@dnd-kit/core'
|
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
|
||||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||||
|
|
||||||
import type { Category, Post, TagWithSections } from '@/types'
|
import type { Category, Post, TagWithSections } from '@/types'
|
||||||
|
|
||||||
type TagByCategory = { [key in Category]: TagWithSections[] }
|
type TagByCategory = { [key in Category]: TagWithSections[] }
|
||||||
|
|
||||||
|
const tagCollisionDetection: CollisionDetection = args => {
|
||||||
|
return pointerWithin (args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const alwaysMeasureDroppables = {
|
||||||
|
droppable: { strategy: MeasuringStrategy.Always } } as const
|
||||||
|
|
||||||
|
|
||||||
const renderTagTree = (
|
const renderTagTree = (
|
||||||
|
activeDndId: string | undefined,
|
||||||
tag: TagWithSections,
|
tag: TagWithSections,
|
||||||
nestLevel: number,
|
nestLevel: number,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -45,6 +55,7 @@ const renderTagTree = (
|
|||||||
const self = (
|
const self = (
|
||||||
<li key={key} className="mb-1">
|
<li key={key} className="mb-1">
|
||||||
<DraggableDroppableTagRow
|
<DraggableDroppableTagRow
|
||||||
|
activeDndId={activeDndId}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
nestLevel={nestLevel}
|
nestLevel={nestLevel}
|
||||||
pathKey={key}
|
pathKey={key}
|
||||||
@@ -58,7 +69,14 @@ const renderTagTree = (
|
|||||||
...((tag.children
|
...((tag.children
|
||||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||||
.flatMap (child =>
|
.flatMap (child =>
|
||||||
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp)))
|
renderTagTree (
|
||||||
|
activeDndId,
|
||||||
|
child,
|
||||||
|
nestLevel + 1,
|
||||||
|
key,
|
||||||
|
suppressClickRef,
|
||||||
|
tag.id,
|
||||||
|
sp)))
|
||||||
?? [])]
|
?? [])]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,12 +195,35 @@ const DropSlot = ({ cat }: { cat: Category }) => {
|
|||||||
</li>)
|
</li>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EmptyCategoryDropSection = (
|
||||||
|
{ cat, children }: { cat: Category
|
||||||
|
children: ReactNode }) => {
|
||||||
|
const { setNodeRef, isOver: over } = useDroppable ({
|
||||||
|
id: `slot:${ cat }`,
|
||||||
|
data: { kind: 'slot', cat } })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} className="my-3">
|
||||||
|
{children}
|
||||||
|
<ul>
|
||||||
|
<li className="h-1">
|
||||||
|
{over && <div className="h-0.5 w-full rounded bg-sky-400"/>}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
post: Post
|
post: Post
|
||||||
sp?: boolean }
|
sp?: boolean }
|
||||||
|
|
||||||
|
type ActiveTagDrag = {
|
||||||
|
dndId: string
|
||||||
|
tagId: number
|
||||||
|
nestLevel: number }
|
||||||
|
|
||||||
|
|
||||||
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||||
sp = Boolean (sp)
|
sp = Boolean (sp)
|
||||||
@@ -212,7 +253,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
return tagsTmp
|
return tagsTmp
|
||||||
}, [post])
|
}, [post])
|
||||||
|
|
||||||
const [activeTagId, setActiveTagId] = useState<number | null> (null)
|
const [activeTagDrag, setActiveTagDrag] = useState<ActiveTagDrag | null> (null)
|
||||||
const [dragging, setDragging] = useState (false)
|
const [dragging, setDragging] = useState (false)
|
||||||
const [saving, setSaving] = useState (false)
|
const [saving, setSaving] = useState (false)
|
||||||
const [tags, setTags] = useState (baseTags)
|
const [tags, setTags] = useState (baseTags)
|
||||||
@@ -220,6 +261,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
() => buildFlatTagByCategory (tags),
|
() => buildFlatTagByCategory (tags),
|
||||||
[tags],
|
[tags],
|
||||||
)
|
)
|
||||||
|
const activeDndId = activeTagDrag?.dndId
|
||||||
|
|
||||||
const suppressClickRef = useRef (false)
|
const suppressClickRef = useRef (false)
|
||||||
|
|
||||||
@@ -328,9 +370,19 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
<TagSearch/>
|
<TagSearch/>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
collisionDetection={tagCollisionDetection}
|
||||||
|
measuring={alwaysMeasureDroppables}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (e.active.data.current?.kind === 'tag')
|
if (e.active.data.current?.kind === 'tag')
|
||||||
setActiveTagId (e.active.data.current?.tagId ?? null)
|
{
|
||||||
|
const tagId = e.active.data.current?.tagId
|
||||||
|
const nestLevel = e.active.data.current?.nestLevel
|
||||||
|
const dndId = e.active.data.current?.dndId
|
||||||
|
setActiveTagDrag (
|
||||||
|
tagId == null || nestLevel == null || dndId == null
|
||||||
|
? null
|
||||||
|
: { dndId, tagId, nestLevel })
|
||||||
|
}
|
||||||
setDragging (true)
|
setDragging (true)
|
||||||
suppressClickRef.current = true
|
suppressClickRef.current = true
|
||||||
document.body.style.userSelect = 'none'
|
document.body.style.userSelect = 'none'
|
||||||
@@ -341,13 +393,13 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
suppressClickRef.current = false}, { capture: true, once: true })
|
suppressClickRef.current = false}, { capture: true, once: true })
|
||||||
}}
|
}}
|
||||||
onDragCancel={() => {
|
onDragCancel={() => {
|
||||||
setActiveTagId (null)
|
setActiveTagDrag (null)
|
||||||
setDragging (false)
|
setDragging (false)
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
suppressClickRef.current = false
|
suppressClickRef.current = false
|
||||||
}}
|
}}
|
||||||
onDragEnd={async e => {
|
onDragEnd={async e => {
|
||||||
setActiveTagId (null)
|
setActiveTagDrag (null)
|
||||||
setDragging (false)
|
setDragging (false)
|
||||||
await onDragEnd (e)
|
await onDragEnd (e)
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
@@ -362,22 +414,31 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
if (!(categoryTags.length > 0 || dragging))
|
if (!(categoryTags.length > 0 || dragging))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
const sectionTitle = (
|
||||||
|
<SubsectionTitle>
|
||||||
|
<motion.div
|
||||||
|
layoutId={animationMode === 'off'
|
||||||
|
? undefined
|
||||||
|
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
|
||||||
|
transition={{ layout: layoutTransition }}>
|
||||||
|
{CATEGORY_NAMES[cat]}
|
||||||
|
</motion.div>
|
||||||
|
</SubsectionTitle>)
|
||||||
|
|
||||||
|
if (!(categoryTags.length > 0))
|
||||||
|
return (
|
||||||
|
<EmptyCategoryDropSection cat={cat} key={cat}>
|
||||||
|
{sectionTitle}
|
||||||
|
</EmptyCategoryDropSection>)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-3" key={cat}>
|
<div className="my-3" key={cat}>
|
||||||
<SubsectionTitle>
|
{sectionTitle}
|
||||||
<motion.div
|
|
||||||
layoutId={animationMode === 'off'
|
|
||||||
? undefined
|
|
||||||
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
|
|
||||||
transition={{ layout: layoutTransition }}>
|
|
||||||
{CATEGORY_NAMES[cat]}
|
|
||||||
</motion.div>
|
|
||||||
</SubsectionTitle>
|
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{(tagRelationDisplay === 'grouped'
|
{(tagRelationDisplay === 'grouped'
|
||||||
? (tags[cat] ?? []).flatMap (tag =>
|
? (tags[cat] ?? []).flatMap (tag =>
|
||||||
renderTagTree (
|
renderTagTree (
|
||||||
|
activeDndId,
|
||||||
tag,
|
tag,
|
||||||
0,
|
0,
|
||||||
`cat-${ cat }`,
|
`cat-${ cat }`,
|
||||||
@@ -389,6 +450,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
: (flatTagsByCategory[cat] ?? []).map (tag => (
|
: (flatTagsByCategory[cat] ?? []).map (tag => (
|
||||||
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
|
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
|
||||||
<DraggableDroppableTagRow
|
<DraggableDroppableTagRow
|
||||||
|
activeDndId={activeDndId}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
nestLevel={0}
|
nestLevel={0}
|
||||||
pathKey={`flat-${ cat }-${ tag.id }`}
|
pathKey={`flat-${ cat }-${ tag.id }`}
|
||||||
@@ -441,11 +503,14 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
|
|
||||||
<DragOverlay adjustScale={false}>
|
<DragOverlay
|
||||||
|
adjustScale={false}
|
||||||
|
modifiers={[snapCenterToCursor]}
|
||||||
|
dropAnimation={animationMode === 'off' ? null : undefined}>
|
||||||
<div className="pointer-events-none">
|
<div className="pointer-events-none">
|
||||||
{activeTagId != null && (() => {
|
{activeTagDrag != null && (() => {
|
||||||
const tag = findTag (tags, activeTagId)
|
const tag = findTag (tags, activeTagDrag.tagId)
|
||||||
return tag && <TagLink tag={tag}/>
|
return tag && <TagLink tag={tag} nestLevel={activeTagDrag.nestLevel}/>
|
||||||
}) ()}
|
}) ()}
|
||||||
</div>
|
</div>
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする