タグ D&D 時の表示位置修正 (#268) #400

マージ済み
みてるぞ が 8 個のコミットを feature/268 から main へマージ 2026-07-08 00:01:08 +09:00
2個のファイルの変更67行の追加95行の削除
コミット 33f9c6602f の変更だけを表示してゐます - すべてのコミットを表示
+6 -4
ファイルの表示
@@ -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',
+61 -91
ファイルの表示
@@ -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>)
} }