diff --git a/frontend/src/components/DraggableDroppableTagRow.test.tsx b/frontend/src/components/DraggableDroppableTagRow.test.tsx new file mode 100644 index 0000000..8469831 --- /dev/null +++ b/frontend/src/components/DraggableDroppableTagRow.test.tsx @@ -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 ( + , + ) +} + +const tagBody = (): HTMLElement => { + const link = document.querySelector ('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' }) + }) +}) diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index f465bea..49f9aed 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -13,6 +13,7 @@ import type { CSSProperties, FC, MutableRefObject } from 'react' import type { Tag } from '@/types' type Props = { + activeDndId?: string tag: Tag nestLevel: number pathKey: string @@ -21,7 +22,15 @@ type Props = { sp?: boolean } -const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { +const DraggableDroppableTagRow: FC = ({ + activeDndId, + tag, + nestLevel, + pathKey, + parentTagId, + suppressClickRef, + sp, +}) => { const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const layoutTransition = clientAnimationTransition ( @@ -53,18 +62,23 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa const { attributes, listeners, setNodeRef: setDragRef, - transform, - isDragging: dragging } = useDraggable ({ id: dndId, - data: { kind: 'tag', - tagId: tag.id, - parentTagId } }) + transform } = useDraggable ({ id: dndId, + data: { kind: 'tag', + dndId, + tagId: tag.id, + parentTagId, + nestLevel } }) const { setNodeRef: setDropRef, isOver: over } = useDroppable ({ id: dndId, data: { kind: 'tag', tagId: tag.id } }) + const activeDragging = activeDndId === dndId 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 (
= ({ tag, nestLevel, pathKey, parentTa return const dx = e.clientX - p.x const dy = e.clientY - p.y + if (dx * dx + dy * dy >= 9) armEatNextClick () }} onPointerUpCapture={() => { downPosRef.current = null }} + onPointerCancelCapture={() => { + downPosRef.current = null + }} onClickCapture={e => { if (suppressClickRef.current) { @@ -90,24 +108,35 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa e.stopPropagation () } }} - ref={node => { - setDragRef (node) - setDropRef (node) - }} - style={style} + ref={setDropRef} className={cn ( 'min-w-0 max-w-full overflow-hidden rounded select-none', + sp && 'touch-pan-y', over && 'ring-2 ring-offset-2')} - {...attributes} - {...listeners}> - - - + > + {activeDragging + ? ( +
+ +
) + : ( + + + )}
) } diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 79b31db..63fbfd2 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -1,11 +1,13 @@ import { DndContext, DragOverlay, + MeasuringStrategy, MouseSensor, TouchSensor, + pointerWithin, useDroppable, useSensor, 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 { motion } from 'framer-motion' import { useEffect, useMemo, useRef, useState } from 'react' @@ -25,15 +27,23 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' 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 { Category, Post, TagWithSections } from '@/types' type TagByCategory = { [key in Category]: TagWithSections[] } +const tagCollisionDetection: CollisionDetection = args => { + return pointerWithin (args) +} + +const alwaysMeasureDroppables = { + droppable: { strategy: MeasuringStrategy.Always } } as const + const renderTagTree = ( + activeDndId: string | undefined, tag: TagWithSections, nestLevel: number, path: string, @@ -45,6 +55,7 @@ const renderTagTree = ( const self = (
  • a.name < b.name ? -1 : 1) .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 }) => {
  • ) } +const EmptyCategoryDropSection = ( + { cat, children }: { cat: Category + children: ReactNode }) => { + const { setNodeRef, isOver: over } = useDroppable ({ + id: `slot:${ cat }`, + data: { kind: 'slot', cat } }) + + return ( +
    + {children} +
      +
    • + {over &&
      } +
    • +
    +
    ) +} + type Props = { className?: string post: Post sp?: boolean } +type ActiveTagDrag = { + dndId: string + tagId: number + nestLevel: number } + const TagDetailSidebar: FC = ({ className, post, sp }) => { sp = Boolean (sp) @@ -212,7 +253,7 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { return tagsTmp }, [post]) - const [activeTagId, setActiveTagId] = useState (null) + const [activeTagDrag, setActiveTagDrag] = useState (null) const [dragging, setDragging] = useState (false) const [saving, setSaving] = useState (false) const [tags, setTags] = useState (baseTags) @@ -220,6 +261,7 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { () => buildFlatTagByCategory (tags), [tags], ) + const activeDndId = activeTagDrag?.dndId const suppressClickRef = useRef (false) @@ -328,9 +370,19 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { { 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) suppressClickRef.current = true document.body.style.userSelect = 'none' @@ -341,13 +393,13 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { suppressClickRef.current = false}, { capture: true, once: true }) }} onDragCancel={() => { - setActiveTagId (null) + setActiveTagDrag (null) setDragging (false) document.body.style.userSelect = '' suppressClickRef.current = false }} onDragEnd={async e => { - setActiveTagId (null) + setActiveTagDrag (null) setDragging (false) await onDragEnd (e) document.body.style.userSelect = '' @@ -362,22 +414,31 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { if (!(categoryTags.length > 0 || dragging)) return null + const sectionTitle = ( + + + {CATEGORY_NAMES[cat]} + + ) + + if (!(categoryTags.length > 0)) + return ( + + {sectionTitle} + ) + return (
    - - - {CATEGORY_NAMES[cat]} - - - + {sectionTitle}
      {(tagRelationDisplay === 'grouped' ? (tags[cat] ?? []).flatMap (tag => renderTagTree ( + activeDndId, tag, 0, `cat-${ cat }`, @@ -389,6 +450,7 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { : (flatTagsByCategory[cat] ?? []).map (tag => (
    • = ({ className, post, sp }) => {
    )} - +
    - {activeTagId != null && (() => { - const tag = findTag (tags, activeTagId) - return tag && + {activeTagDrag != null && (() => { + const tag = findTag (tags, activeTagDrag.tagId) + return tag && }) ()}