タグ D&D 時の表示位置修正 (#268) #400
@@ -58,15 +58,14 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
data: { kind: 'tag',
|
data: { kind: 'tag',
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
parentTagId,
|
parentTagId,
|
||||||
nestLevel,
|
nestLevel } })
|
||||||
dndId } })
|
|
||||||
|
|
||||||
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 style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
||||||
visibility: dragging ? 'hidden' : 'visible' }
|
visibility: dragging && !(sp) ? 'hidden' : 'visible' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -79,12 +78,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)
|
||||||
{
|
{
|
||||||
@@ -100,7 +103,6 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={setDragRef}
|
ref={setDragRef}
|
||||||
data-tag-dnd-id={dndId}
|
|
||||||
style={style}
|
style={style}
|
||||||
className={cn (
|
className={cn (
|
||||||
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
|
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { DndContext,
|
import { DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
MeasuringStrategy,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
type Modifier,
|
|
||||||
pointerWithin,
|
pointerWithin,
|
||||||
rectIntersection,
|
rectIntersection,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
@@ -11,7 +11,7 @@ import { DndContext,
|
|||||||
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
|
import { restrictToWindowEdges } 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, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
@@ -28,13 +28,21 @@ 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 => {
|
||||||
|
const byPointer = pointerWithin (args)
|
||||||
|
return byPointer.length > 0 ? byPointer : rectIntersection (args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const alwaysMeasureDroppables = {
|
||||||
|
droppable: { strategy: MeasuringStrategy.Always } } as const
|
||||||
|
|
||||||
|
|
||||||
const renderTagTree = (
|
const renderTagTree = (
|
||||||
tag: TagWithSections,
|
tag: TagWithSections,
|
||||||
@@ -180,6 +188,24 @@ 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
|
||||||
@@ -188,12 +214,7 @@ type Props = {
|
|||||||
|
|
||||||
type ActiveTagDrag = {
|
type ActiveTagDrag = {
|
||||||
tagId: number
|
tagId: number
|
||||||
nestLevel: number
|
nestLevel: number }
|
||||||
dndId: string }
|
|
||||||
|
|
||||||
type DragOverlayOffset = {
|
|
||||||
x: number
|
|
||||||
y: number }
|
|
||||||
|
|
||||||
|
|
||||||
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||||
@@ -228,59 +249,16 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
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)
|
||||||
const [dragOverlayOffset, setDragOverlayOffset] =
|
|
||||||
useState<DragOverlayOffset> ({ x: 0, y: 0 })
|
|
||||||
const flatTagsByCategory = useMemo<TagByCategory> (
|
const flatTagsByCategory = useMemo<TagByCategory> (
|
||||||
() => buildFlatTagByCategory (tags),
|
() => buildFlatTagByCategory (tags),
|
||||||
[tags],
|
[tags],
|
||||||
)
|
)
|
||||||
|
|
||||||
const suppressClickRef = useRef (false)
|
const suppressClickRef = useRef (false)
|
||||||
const dragStartRectRef = useRef<DOMRect | null> (null)
|
|
||||||
const offsetFrameRef = useRef<number | null> (null)
|
|
||||||
|
|
||||||
const sensors = useSensors (
|
const sensors = useSensors (
|
||||||
useSensor (MouseSensor, { activationConstraint: { distance: 6 } }),
|
useSensor (MouseSensor, { activationConstraint: { distance: 6 } }),
|
||||||
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
|
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
|
||||||
const dragOverlayModifiers = useMemo<Modifier[]> (
|
|
||||||
() => (
|
|
||||||
sp
|
|
||||||
? [({ transform }) => ({
|
|
||||||
...transform,
|
|
||||||
x: transform.x + dragOverlayOffset.x,
|
|
||||||
y: transform.y + dragOverlayOffset.y })]
|
|
||||||
: []),
|
|
||||||
[dragOverlayOffset.x, dragOverlayOffset.y, sp],
|
|
||||||
)
|
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
|
||||||
if (!(sp && dragging && activeTagDrag && dragStartRectRef.current))
|
|
||||||
{
|
|
||||||
setDragOverlayOffset ({ x: 0, y: 0 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
offsetFrameRef.current = requestAnimationFrame (() => {
|
|
||||||
const node = document.querySelector<HTMLElement> (
|
|
||||||
`[data-tag-dnd-id="${ activeTagDrag.dndId }"]`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!(node && dragStartRectRef.current))
|
|
||||||
return
|
|
||||||
|
|
||||||
const currentRect = node.getBoundingClientRect ()
|
|
||||||
const initialRect = dragStartRectRef.current
|
|
||||||
|
|
||||||
setDragOverlayOffset ({
|
|
||||||
x: currentRect.left - initialRect.left,
|
|
||||||
y: currentRect.top - initialRect.top })
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (offsetFrameRef.current != null)
|
|
||||||
cancelAnimationFrame (offsetFrameRef.current)
|
|
||||||
}
|
|
||||||
}, [activeTagDrag, dragging, sp])
|
|
||||||
|
|
||||||
const reloadTags = async (): Promise<void> => {
|
const reloadTags = async (): Promise<void> => {
|
||||||
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`)))
|
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`)))
|
||||||
@@ -383,28 +361,17 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
<TagSearch/>
|
<TagSearch/>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={args => {
|
collisionDetection={sp ? tagCollisionDetection : undefined}
|
||||||
const byPointer = pointerWithin (args)
|
measuring={sp ? alwaysMeasureDroppables : undefined}
|
||||||
return byPointer.length > 0 ? byPointer : rectIntersection (args)
|
|
||||||
}}
|
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (e.active.data.current?.kind === 'tag')
|
if (e.active.data.current?.kind === 'tag')
|
||||||
{
|
{
|
||||||
const tagId = e.active.data.current?.tagId
|
const tagId = e.active.data.current?.tagId
|
||||||
const nestLevel = e.active.data.current?.nestLevel
|
const nestLevel = e.active.data.current?.nestLevel
|
||||||
const dndId = e.active.data.current?.dndId
|
|
||||||
dragStartRectRef.current =
|
|
||||||
e.active.rect.current.initial
|
|
||||||
? new DOMRect (
|
|
||||||
e.active.rect.current.initial.left,
|
|
||||||
e.active.rect.current.initial.top,
|
|
||||||
e.active.rect.current.initial.width,
|
|
||||||
e.active.rect.current.initial.height)
|
|
||||||
: null
|
|
||||||
setActiveTagDrag (
|
setActiveTagDrag (
|
||||||
tagId == null || nestLevel == null || !(dndId)
|
tagId == null || nestLevel == null
|
||||||
? null
|
? null
|
||||||
: { tagId, nestLevel, dndId })
|
: { tagId, nestLevel })
|
||||||
}
|
}
|
||||||
setDragging (true)
|
setDragging (true)
|
||||||
suppressClickRef.current = true
|
suppressClickRef.current = true
|
||||||
@@ -418,16 +385,12 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
onDragCancel={() => {
|
onDragCancel={() => {
|
||||||
setActiveTagDrag (null)
|
setActiveTagDrag (null)
|
||||||
setDragging (false)
|
setDragging (false)
|
||||||
setDragOverlayOffset ({ x: 0, y: 0 })
|
|
||||||
dragStartRectRef.current = null
|
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
suppressClickRef.current = false
|
suppressClickRef.current = false
|
||||||
}}
|
}}
|
||||||
onDragEnd={async e => {
|
onDragEnd={async e => {
|
||||||
setActiveTagDrag (null)
|
setActiveTagDrag (null)
|
||||||
setDragging (false)
|
setDragging (false)
|
||||||
setDragOverlayOffset ({ x: 0, y: 0 })
|
|
||||||
dragStartRectRef.current = null
|
|
||||||
await onDragEnd (e)
|
await onDragEnd (e)
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
}}
|
}}
|
||||||
@@ -441,18 +404,26 @@ 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 =>
|
||||||
@@ -520,16 +491,15 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
|
|
||||||
<DragOverlay adjustScale={false} modifiers={dragOverlayModifiers}>
|
{!(sp) && (
|
||||||
<div className="pointer-events-none">
|
<DragOverlay adjustScale={false}>
|
||||||
{activeTagDrag != null && (() => {
|
<div className="pointer-events-none">
|
||||||
const tag = findTag (tags, activeTagDrag.tagId)
|
{activeTagDrag != null && (() => {
|
||||||
return (
|
const tag = findTag (tags, activeTagDrag.tagId)
|
||||||
tag
|
return tag && <TagLink tag={tag} nestLevel={activeTagDrag.nestLevel}/>
|
||||||
&& <TagLink tag={tag} nestLevel={activeTagDrag.nestLevel}/>)
|
}) ()}
|
||||||
}) ()}
|
</div>
|
||||||
</div>
|
</DragOverlay>)}
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</SidebarComponent>)
|
</SidebarComponent>)
|
||||||
}
|
}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする