From d8d6852d44cb872b464475a7c1e0e99c91c1e209 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 11:25:11 -0700 Subject: [PATCH 01/28] feat: add drag handles for content blocks --- .../web/partials/editor/block-reorder.test.ts | 80 +++++ apps/web/partials/editor/block-reorder.tsx | 333 ++++++++++++++++++ apps/web/partials/editor/editor.tsx | 25 +- 3 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 apps/web/partials/editor/block-reorder.test.ts create mode 100644 apps/web/partials/editor/block-reorder.tsx diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts new file mode 100644 index 0000000000..86fd7205f5 --- /dev/null +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -0,0 +1,80 @@ +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import { Editor } from '@tiptap/react'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { makeDropZones, moveTopLevelBlock } from './block-reorder'; + +const editors: Editor[] = []; + +afterEach(() => { + for (const editor of editors.splice(0)) editor.destroy(); +}); + +describe('moveTopLevelBlock', () => { + it('moves a block down to the selected document boundary', () => { + const editor = makeEditor(['A', 'B', 'C', 'D']); + + expect(moveTopLevelBlock(editor, 0, 3)).toBe(true); + + expect(blockText(editor)).toEqual(['B', 'C', 'A', 'D']); + }); + + it('moves a block up to the selected document boundary', () => { + const editor = makeEditor(['A', 'B', 'C', 'D']); + + expect(moveTopLevelBlock(editor, 3, 1)).toBe(true); + + expect(blockText(editor)).toEqual(['A', 'D', 'B', 'C']); + }); + + it('does not change the document when dropped beside its current position', () => { + const editor = makeEditor(['A', 'B', 'C']); + + expect(moveTopLevelBlock(editor, 1, 1)).toBe(false); + expect(moveTopLevelBlock(editor, 1, 2)).toBe(false); + expect(blockText(editor)).toEqual(['A', 'B', 'C']); + }); +}); + +describe('makeDropZones', () => { + it('creates a drop target before, between, and after every draggable block', () => { + expect( + makeDropZones([ + { childIndex: 0, top: 10, bottom: 30, center: 20 }, + { childIndex: 1, top: 40, bottom: 80, center: 60 }, + ]) + ).toEqual([ + { boundary: 0, top: 10, height: 10, indicatorTop: 10 }, + { boundary: 1, top: 20, height: 40, indicatorTop: 35 }, + { boundary: 2, top: 60, height: 20, indicatorTop: 80 }, + ]); + }); + + it('places the final drop boundary immediately after the last draggable block', () => { + const zones = makeDropZones([ + { childIndex: 0, top: 10, bottom: 30, center: 20 }, + { childIndex: 1, top: 50, bottom: 70, center: 60 }, + ]); + + expect(zones.map(zone => zone.boundary)).toEqual([0, 1, 2]); + }); +}); + +function makeEditor(labels: string[]) { + const editor = new Editor({ + extensions: [Document, Paragraph, Text], + content: { + type: 'doc', + content: labels.map(label => ({ type: 'paragraph', content: [{ type: 'text', text: label }] })), + }, + }); + editors.push(editor); + return editor; +} + +function blockText(editor: Editor) { + return Array.from({ length: editor.state.doc.childCount }, (_, index) => editor.state.doc.child(index).textContent); +} diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx new file mode 100644 index 0000000000..9290fe2d60 --- /dev/null +++ b/apps/web/partials/editor/block-reorder.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + closestCenter, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { Editor } from '@tiptap/react'; + +import * as React from 'react'; + +import { OrderDots } from '~/design-system/icons/order-dots'; + +type BlockLayout = { + childIndex: number; + top: number; + bottom: number; + center: number; +}; + +type DropZoneLayout = { + boundary: number; + top: number; + height: number; + indicatorTop: number; +}; + +type Props = { + children: React.ReactNode; + editor: Editor; + editorWrapperRef: React.RefObject; + enabled: boolean; + onReorder: () => void; +}; + +/** Adds edit-mode drag controls around TipTap's top-level content blocks. */ +export function BlockReorder({ children, editor, editorWrapperRef, enabled, onReorder }: Props) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 4 }, + }) + ); + const [blockLayout, setBlockLayout] = React.useState([]); + const [hoveredChildIndex, setHoveredChildIndex] = React.useState(null); + const hoveredChildIndexRef = React.useRef(null); + const [activeChildIndex, setActiveChildIndex] = React.useState(null); + const [activeBoundary, setActiveBoundary] = React.useState(null); + + const updateHoveredChildIndex = React.useCallback((childIndex: number | null) => { + hoveredChildIndexRef.current = childIndex; + setHoveredChildIndex(childIndex); + }, []); + + const measureBlocks = React.useCallback(() => { + const wrapper = editorWrapperRef.current; + const editorElement = wrapper?.querySelector('.ProseMirror'); + if (!wrapper || !editorElement) return; + + const wrapperRect = wrapper.getBoundingClientRect(); + const nextLayout = Array.from(editorElement.children).flatMap((element, childIndex) => { + if (!(element instanceof HTMLElement) || !isDraggableBlock(element)) return []; + + const rect = element.getBoundingClientRect(); + const top = rect.top - wrapperRect.top; + const bottom = rect.bottom - wrapperRect.top; + + return [{ childIndex, top, bottom, center: top + rect.height / 2 }]; + }); + + setBlockLayout(nextLayout); + }, [editorWrapperRef]); + + React.useLayoutEffect(() => { + if (!enabled) return; + + const wrapper = editorWrapperRef.current; + const editorElement = wrapper?.querySelector('.ProseMirror'); + if (!wrapper || !editorElement) return; + + measureBlocks(); + + const resizeObserver = new ResizeObserver(measureBlocks); + const mutationObserver = new MutationObserver(measureBlocks); + resizeObserver.observe(editorElement); + mutationObserver.observe(editorElement, { childList: true }); + + const handlePointerMove = (event: PointerEvent) => { + if (activeChildIndex !== null) return; + + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest('[data-block-drag-handle]')) return; + + const blockElement = target.closest('.ProseMirror > *'); + if (!blockElement || blockElement.parentElement !== editorElement || !isDraggableBlock(blockElement)) { + updateHoveredChildIndex(null); + return; + } + + const childIndex = Array.from(editorElement.children).indexOf(blockElement); + if (hoveredChildIndexRef.current === childIndex) return; + + measureBlocks(); + updateHoveredChildIndex(childIndex); + }; + + const handlePointerLeave = () => { + if (activeChildIndex === null) updateHoveredChildIndex(null); + }; + + wrapper.addEventListener('pointermove', handlePointerMove); + wrapper.addEventListener('pointerleave', handlePointerLeave); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + wrapper.removeEventListener('pointermove', handlePointerMove); + wrapper.removeEventListener('pointerleave', handlePointerLeave); + }; + }, [activeChildIndex, editorWrapperRef, enabled, measureBlocks, updateHoveredChildIndex]); + + React.useEffect(() => { + if (enabled) return; + + hoveredChildIndexRef.current = null; + setHoveredChildIndex(null); + setActiveChildIndex(null); + setActiveBoundary(null); + }, [enabled]); + + const editorRect = editorWrapperRef.current?.querySelector('.ProseMirror')?.getBoundingClientRect(); + const wrapperRect = editorWrapperRef.current?.getBoundingClientRect(); + const editorLeft = editorRect && wrapperRect ? editorRect.left - wrapperRect.left : 0; + const editorWidth = editorRect?.width ?? 0; + const visibleHandleIndex = activeChildIndex ?? hoveredChildIndex; + const handleLayout = blockLayout.find(block => block.childIndex === visibleHandleIndex); + const dropZones = makeDropZones(blockLayout); + const indicatorTop = dropZones.find(zone => zone.boundary === activeBoundary)?.indicatorTop; + + const resetDragState = () => { + setActiveChildIndex(null); + setActiveBoundary(null); + updateHoveredChildIndex(null); + }; + + const handleDragStart = (event: DragStartEvent) => { + if (!enabled) return; + + const childIndex = event.active.data.current?.childIndex; + if (typeof childIndex !== 'number') return; + + measureBlocks(); + setActiveChildIndex(childIndex); + }; + + const handleDragOver = (event: DragOverEvent) => { + const boundary = event.over?.data.current?.boundary; + setActiveBoundary(typeof boundary === 'number' ? boundary : null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const sourceIndex = event.active.data.current?.childIndex; + const dropBoundary = event.over?.data.current?.boundary; + + if ( + typeof sourceIndex === 'number' && + typeof dropBoundary === 'number' && + moveTopLevelBlock(editor, sourceIndex, dropBoundary) + ) { + onReorder(); + } + + resetDragState(); + }; + + return ( + + {children} + + {enabled && handleLayout && visibleHandleIndex !== null ? ( + + ) : null} + + {enabled && activeChildIndex !== null + ? dropZones.map(zone => ) + : null} + + {enabled && activeChildIndex !== null && indicatorTop !== undefined ? ( +
+ ) : null} + + + {enabled && activeChildIndex !== null ? ( +
+ +
+ ) : null} +
+ + ); +} + +function BlockDragHandle({ + childIndex, + top, + left, + isDragging, +}: { + childIndex: number; + top: number; + left: number; + isDragging: boolean; +}) { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: `content-block-${childIndex}`, + data: { childIndex }, + }); + + return ( + + ); +} + +function BlockDropZone({ zone, left, width }: { zone: DropZoneLayout; left: number; width: number }) { + const { setNodeRef } = useDroppable({ + id: `content-block-drop-${zone.boundary}`, + data: { boundary: zone.boundary }, + }); + + return ( +
+ ); +} + +function isDraggableBlock(element: HTMLElement) { + return ( + !element.classList.contains('paragraph-tail-placeholder') && + !element.matches('.paragraph-tail-placeholder, .is-empty') + ); +} + +export function makeDropZones(blocks: BlockLayout[]): DropZoneLayout[] { + if (blocks.length === 0) return []; + + return Array.from({ length: blocks.length + 1 }, (_, index) => { + const previous = blocks[index - 1]; + const next = blocks[index]; + const top = previous?.center ?? next.top; + const bottom = next?.center ?? previous.bottom; + const indicatorTop = previous && next ? (previous.bottom + next.top) / 2 : (next?.top ?? previous.bottom); + + return { + boundary: next?.childIndex ?? previous.childIndex + 1, + top, + height: Math.max(1, bottom - top), + indicatorTop, + }; + }); +} + +/** Moves one top-level document node to a boundary in the original document. */ +export function moveTopLevelBlock(editor: Editor, sourceIndex: number, dropBoundary: number): boolean { + const { doc } = editor.state; + if (sourceIndex < 0 || sourceIndex >= doc.childCount || dropBoundary < 0 || dropBoundary > doc.childCount) { + return false; + } + + // Dropping immediately before or after the source preserves its current order. + if (dropBoundary === sourceIndex || dropBoundary === sourceIndex + 1) return false; + + const sourceNode = doc.child(sourceIndex); + const sourcePosition = positionBeforeChild(doc, sourceIndex); + const boundaryPosition = positionBeforeChild(doc, dropBoundary); + const insertionPosition = + boundaryPosition > sourcePosition ? boundaryPosition - sourceNode.nodeSize : boundaryPosition; + const transaction = editor.state.tr + .delete(sourcePosition, sourcePosition + sourceNode.nodeSize) + .insert(insertionPosition, sourceNode) + .scrollIntoView(); + + editor.view.dispatch(transaction); + return true; +} + +function positionBeforeChild(doc: Editor['state']['doc'], childIndex: number) { + let position = 0; + for (let index = 0; index < childIndex; index += 1) { + position += doc.child(index).nodeSize; + } + return position; +} diff --git a/apps/web/partials/editor/editor.tsx b/apps/web/partials/editor/editor.tsx index ba8c5abafc..8ec7765e24 100644 --- a/apps/web/partials/editor/editor.tsx +++ b/apps/web/partials/editor/editor.tsx @@ -17,6 +17,7 @@ import { resolveGraphLinkHref } from '~/core/utils/graph-link'; import { Spacer } from '~/design-system/spacer'; +import { BlockReorder } from './block-reorder'; import { createCommandExtension } from './command-extension'; import { createEntityMentionExtension, entityMentionPluginKey } from './entity-mention-extension'; import { tiptapExtensions } from './extensions'; @@ -168,6 +169,15 @@ export function Editor({ shouldHandleOwnSpacing, spaceId, placeholder = null }: upsertEditorStateRef.current(json); }, [trackEditorDocument]); + // A deliberate drop is an explicit document change, so persist it immediately + // even if a suggestion popup happened to be active before the drag began. + const persistReorderedBlocks = React.useCallback(() => { + if (!editableRef.current || !editorRef.current) return; + const json = editorRef.current.getJSON(); + trackEditorDocument(json); + upsertEditorStateRef.current(json); + }, [trackEditorDocument]); + // When transitioning from edit → view mode, persist the editor content to the // store BEFORE useEditor destroys and recreates the editor. Without this, // content changes (like @mention links) would be lost because onBlur may not @@ -331,11 +341,22 @@ export function Editor({ shouldHandleOwnSpacing, spaceId, placeholder = null }:
- {editor ? : } + {editor ? ( + + + + ) : ( + + )} {shouldHandleOwnSpacing && editable && }
From bff2820a20909165f6ae11f86c99eb95f2222979 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 12:01:02 -0700 Subject: [PATCH 02/28] fix: preserve block drag handle hover --- .../web/partials/editor/block-reorder.test.ts | 27 ++++++++++++++++++- apps/web/partials/editor/block-reorder.tsx | 26 ++++++++++-------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index 86fd7205f5..f5f3e616b2 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -1,18 +1,43 @@ +import { DndContext } from '@dnd-kit/core'; +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; import Document from '@tiptap/extension-document'; import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; import { Editor } from '@tiptap/react'; +import React from 'react'; + import { afterEach, describe, expect, it } from 'vitest'; -import { makeDropZones, moveTopLevelBlock } from './block-reorder'; +import { BlockDragHandle, makeDropZones, moveTopLevelBlock } from './block-reorder'; const editors: Editor[] = []; afterEach(() => { + cleanup(); for (const editor of editors.splice(0)) editor.destroy(); }); +describe('BlockDragHandle', () => { + it('bridges the gap between the visible handle and the hovered block', () => { + render( + React.createElement( + DndContext, + null, + React.createElement(BlockDragHandle, { childIndex: 0, top: 12, left: -32, isDragging: false }) + ) + ); + + const button = screen.getByRole('button', { name: 'Drag to reorder block' }); + const hoverBridge = button.parentElement; + + expect(button).toHaveClass('size-6'); + expect(hoverBridge).toHaveAttribute('data-block-drag-handle'); + expect(hoverBridge).toHaveClass('w-8'); + }); +}); + describe('moveTopLevelBlock', () => { it('moves a block down to the selected document boundary', () => { const editor = makeEditor(['A', 'B', 'C', 'D']); diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 9290fe2d60..5b50d4f66a 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -225,7 +225,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe ); } -function BlockDragHandle({ +export function BlockDragHandle({ childIndex, top, left, @@ -242,19 +242,23 @@ function BlockDragHandle({ }); return ( - + +
); } From ffa177f42e86e84f176ec278e813dc2a13754a81 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 12:19:47 -0700 Subject: [PATCH 03/28] fix: preserve block relation identity when reordering --- apps/web/core/state/editor/use-editor.tsx | 106 +++++++++++--------- apps/web/core/sync/relation-update.test.ts | 36 +++++++ apps/web/core/sync/relation-update.ts | 16 +++ apps/web/core/sync/use-mutate.tsx | 10 +- apps/web/core/types.ts | 2 + apps/web/core/utils/publish/publish.test.ts | 28 ++++++ apps/web/core/utils/publish/publish.ts | 8 ++ 7 files changed, 157 insertions(+), 49 deletions(-) create mode 100644 apps/web/core/sync/relation-update.test.ts create mode 100644 apps/web/core/sync/relation-update.ts diff --git a/apps/web/core/state/editor/use-editor.tsx b/apps/web/core/state/editor/use-editor.tsx index e1e3471e19..3741c9962a 100644 --- a/apps/web/core/state/editor/use-editor.tsx +++ b/apps/web/core/state/editor/use-editor.tsx @@ -67,40 +67,12 @@ function makeNewBlockRelation({ }: MakeNewBlockArgs) { const newRelationId = ID.createEntityId(); - const position = nextBlockIds.indexOf(addedBlock.id); - - // @TODO: noUncheckedIndexAccess - const beforeBlockIndex = nextBlockIds[position - 1] as string | undefined; - const afterBlockIndex = nextBlockIds[position + 1] as string | undefined; - - // Create a unified array with consistent structure for both blockRelations and newBlocks - const allRelations = [ - ...blockRelations.map(r => ({ - toEntity: { id: r.block.id }, - // @TODO(migration): default position - position: r.position ?? 'a0', - })), - ...newBlocks.map(b => ({ - toEntity: { id: b.toEntity.id }, - // @TODO(migration): default position - position: b.position ?? 'a0', - })), - ].sort((a, b) => (a.position < b.position ? -1 : 1)); - - // Check both the existing blocks and any that are created as part of this update - // tick. This is necessary as right now we don't update the Geo state until the - // user blurs the editor. See the comment earlier in this function. - const beforeCollectionItemIndex = allRelations.find(c => c.toEntity.id === beforeBlockIndex)?.position; - - // When the afterCollectionItemIndex is undefined, we need to use the next block of beforeBlockIndex - const afterCollectionItemIndex = - allRelations.find(c => c.toEntity.id === afterBlockIndex)?.position ?? - allRelations[allRelations.findIndex(c => c.position === beforeCollectionItemIndex) + 1]?.position; - - const newBlockOrdering = Position.generateBetween( - beforeCollectionItemIndex ?? null, - afterCollectionItemIndex ?? null - ); + const newBlockOrdering = makeBlockPosition({ + blockId: addedBlock.id, + nextBlockIds, + blockRelations, + newBlocks, + }); const renderableType = ((): RenderableEntityType => { switch (tiptapBlock.type) { @@ -124,8 +96,8 @@ function makeNewBlockRelation({ } })(); - const newRelation: Relation = { - spaceId: spaceId, + return { + spaceId, id: newRelationId, position: newBlockOrdering, verified: false, @@ -144,9 +116,51 @@ function makeNewBlockRelation({ id: entityPageId, name: null, }, - }; + } satisfies Relation; +} + +function makeBlockPosition({ + blockId, + nextBlockIds, + blockRelations, + newBlocks, +}: { + blockId: string; + nextBlockIds: string[]; + blockRelations: RelationWithBlock[]; + newBlocks: Relation[]; +}) { + const position = nextBlockIds.indexOf(blockId); + + // @TODO: noUncheckedIndexAccess + const beforeBlockIndex = nextBlockIds[position - 1] as string | undefined; + const afterBlockIndex = nextBlockIds[position + 1] as string | undefined; - return newRelation; + // Create a unified array with consistent structure for both blockRelations and newBlocks + const allRelations = [ + ...blockRelations.map(r => ({ + toEntity: { id: r.block.id }, + // @TODO(migration): default position + position: r.position ?? 'a0', + })), + ...newBlocks.map(b => ({ + toEntity: { id: b.toEntity.id }, + // @TODO(migration): default position + position: b.position ?? 'a0', + })), + ].sort((a, b) => (a.position < b.position ? -1 : 1)); + + // Check both the existing blocks and any that are created as part of this update + // tick. This is necessary as right now we don't update the Geo state until the + // user blurs the editor. See the comment earlier in this function. + const beforeCollectionItemIndex = allRelations.find(c => c.toEntity.id === beforeBlockIndex)?.position; + + // When the afterCollectionItemIndex is undefined, we need to use the next block of beforeBlockIndex + const afterCollectionItemIndex = + allRelations.find(c => c.toEntity.id === afterBlockIndex)?.position ?? + allRelations[allRelations.findIndex(c => c.position === beforeCollectionItemIndex) + 1]?.position; + + return Position.generateBetween(beforeCollectionItemIndex ?? null, afterCollectionItemIndex ?? null); } interface UpsertBlocksRelationsArgs { @@ -207,22 +221,18 @@ const makeBlocksRelations = async ({ for (const movedBlock of movedBlocks) { const relationForMovedBlock = blockRelations.find(r => r.block.id === movedBlock.id); + if (!relationForMovedBlock) continue; - if (relationForMovedBlock) { - storage.relations.delete(relationForMovedBlock); - } - - const newRelation = makeNewBlockRelation({ - tiptapBlock: nextBlocks.find(b => b.id === movedBlock.id)!, - addedBlock: movedBlock, + const position = makeBlockPosition({ + blockId: movedBlock.id, nextBlockIds, blockRelations, - spaceId, newBlocks, - entityPageId, }); - storage.relations.set(newRelation); + storage.relations.update(relationForMovedBlock, draft => { + draft.position = position; + }); } }; diff --git a/apps/web/core/sync/relation-update.test.ts b/apps/web/core/sync/relation-update.test.ts new file mode 100644 index 0000000000..163c5a4a5a --- /dev/null +++ b/apps/web/core/sync/relation-update.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { Relation } from '../types'; +import { canPublishRelationUpdate } from './relation-update'; + +const existingRelation: Relation = { + id: 'existing-relation', + entityId: 'relation-entity', + type: { id: 'blocks', name: 'Blocks' }, + fromEntity: { id: 'page', name: 'Page' }, + toEntity: { id: 'block', name: 'Block', value: 'block' }, + renderableType: 'TEXT', + position: 'a0', + spaceId: 'space', +}; + +describe('canPublishRelationUpdate', () => { + it('updates an existing relation when only its position changes', () => { + expect(canPublishRelationUpdate(existingRelation, { ...existingRelation, position: 'a1' })).toBe(true); + }); + + it('keeps an unpublished local relation as a create', () => { + const localRelation = { ...existingRelation, isLocal: true, hasBeenPublished: false }; + + expect(canPublishRelationUpdate(localRelation, { ...localRelation, position: 'a1' })).toBe(false); + }); + + it('does not use updateRelation for endpoint changes the SDK cannot update', () => { + expect( + canPublishRelationUpdate(existingRelation, { + ...existingRelation, + toEntity: { id: 'different-block', name: 'Different block', value: 'different-block' }, + }) + ).toBe(false); + }); +}); diff --git a/apps/web/core/sync/relation-update.ts b/apps/web/core/sync/relation-update.ts new file mode 100644 index 0000000000..d071a3c3d9 --- /dev/null +++ b/apps/web/core/sync/relation-update.ts @@ -0,0 +1,16 @@ +import { Relation } from '../types'; + +/** Whether a changed relation can be published with the SDK's updateRelation operation. */ +export function canPublishRelationUpdate(base: Relation, changed: Relation) { + const existsRemotely = base.isLocal !== true || base.hasBeenPublished === true || base.isRelationUpdate === true; + + const onlyUpdatesSupportedFields = + base.id === changed.id && + base.entityId === changed.entityId && + base.type.id === changed.type.id && + base.fromEntity.id === changed.fromEntity.id && + base.toEntity.id === changed.toEntity.id && + base.spaceId === changed.spaceId; + + return existsRemotely && onlyUpdatesSupportedFields; +} diff --git a/apps/web/core/sync/use-mutate.tsx b/apps/web/core/sync/use-mutate.tsx index e7fd2dad06..327cd3fdf2 100644 --- a/apps/web/core/sync/use-mutate.tsx +++ b/apps/web/core/sync/use-mutate.tsx @@ -18,6 +18,7 @@ import { DataType, Relation, Value } from '../types'; import { toHexId } from '../utils/hex-id'; import { extractValueString } from '../utils/value'; import { saveVideoKeyframe } from '../utils/video/save-keyframe'; +import { canPublishRelationUpdate } from './relation-update'; import { GeoStore } from './store'; import { store, useSyncEngine } from './use-sync-engine'; @@ -482,7 +483,14 @@ function createMutator(store: GeoStore): Mutator { store.setRelation(newRelation); }, update: (base, recipe) => { - const newRelation = produce(base, recipe); + const changedRelation = produce(base, recipe); + + // Relations created in the current local edit still need createRelation. + // Once a relation exists remotely, preserve its identity and publish + // supported field changes through the SDK's updateRelation operation. + const newRelation = produce(changedRelation, draft => { + draft.isRelationUpdate = canPublishRelationUpdate(base, changedRelation); + }); store.setRelation(newRelation); }, delete: newRelation => { diff --git a/apps/web/core/types.ts b/apps/web/core/types.ts index 327de55a69..cb9745cee4 100644 --- a/apps/web/core/types.ts +++ b/apps/web/core/types.ts @@ -238,6 +238,8 @@ export type Value = LocalMetadata & { // ============================================================================== export type Relation = LocalMetadata & { + /** Publish this local change with the SDK's updateRelation operation. */ + isRelationUpdate?: boolean; id: string; entityId: string; type: { diff --git a/apps/web/core/utils/publish/publish.test.ts b/apps/web/core/utils/publish/publish.test.ts index 3c8a10cecf..6b8b52f711 100644 --- a/apps/web/core/utils/publish/publish.test.ts +++ b/apps/web/core/utils/publish/publish.test.ts @@ -90,6 +90,12 @@ type DeleteRelationOp = Op & { id: unknown; }; +type UpdateRelationOp = Op & { + type: 'updateRelation'; + id: unknown; + position?: string; +}; + describe('prepareLocalDataForPublishing', () => { describe('basic functionality', () => { it('should create updateEntity operation for valid values', () => { @@ -123,6 +129,28 @@ describe('prepareLocalDataForPublishing', () => { expect(createOp.position).toBe('1'); }); + it('should update an existing relation position without deleting or recreating it', () => { + const relationId = IdUtils.generate(); + const relations = [ + createMockRelation({ + id: relationId, + isRelationUpdate: true, + position: 'position-after-reorder', + }), + ]; + + const result = prepareLocalDataForPublishing([], relations, 'test-space'); + + expect(result).toHaveLength(1); + expect(result.filter(op => op.type === 'deleteRelation')).toHaveLength(0); + expect(result.filter(op => op.type === 'createRelation')).toHaveLength(0); + + const updateOp = result[0] as UpdateRelationOp; + expect(updateOp.type).toBe('updateRelation'); + expect(updateOp.position).toBe('position-after-reorder'); + expect(Array.from(updateOp.id as Uint8Array)).toEqual(Array.from(IdUtils.toBytes(relationId) as Uint8Array)); + }); + it('should create deleteRelation operation for deleted relations', async () => { const values: Value[] = []; const relations = [createMockRelation({ isDeleted: true, type: { id: SystemIds.BLOCKS, name: 'Blocks' } })]; diff --git a/apps/web/core/utils/publish/publish.ts b/apps/web/core/utils/publish/publish.ts index b80decd4e6..4896ab10ee 100644 --- a/apps/web/core/utils/publish/publish.ts +++ b/apps/web/core/utils/publish/publish.ts @@ -3,6 +3,7 @@ import { type DecimalMantissa, Graph, Op, + Ops, type PropertyValueParam, SystemIds, } from '@geoprotocol/geo-sdk/lite'; @@ -93,6 +94,13 @@ function prepareOps(values: Value[], relations: Relation[], spaceId: string): Op if (r.isDeleted) { const { ops: deleteOps } = Graph.deleteRelation({ id: r.id }); ops.push(...deleteOps); + } else if (r.isRelationUpdate) { + const { ops: updateOps } = Ops.relations.update({ + id: r.id, + position: r.position, + ...(r.toSpaceId && { toSpace: r.toSpaceId }), + }); + ops.push(...updateOps); } else { const { ops: createOps } = Graph.createRelation({ fromEntity: r.fromEntity.id, From 934b5d313b5d2eb8fb26fba146300a1fce1f04e1 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 12:31:54 -0700 Subject: [PATCH 04/28] fix: expand content block handle hover area --- .../web/partials/editor/block-reorder.test.ts | 24 ++++++++++- apps/web/partials/editor/block-reorder.tsx | 42 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index f5f3e616b2..d67bb50fb6 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -10,7 +10,7 @@ import React from 'react'; import { afterEach, describe, expect, it } from 'vitest'; -import { BlockDragHandle, makeDropZones, moveTopLevelBlock } from './block-reorder'; +import { BlockDragHandle, getGutterHoveredChildIndex, makeDropZones, moveTopLevelBlock } from './block-reorder'; const editors: Editor[] = []; @@ -64,6 +64,28 @@ describe('moveTopLevelBlock', () => { }); }); +describe('getGutterHoveredChildIndex', () => { + const blocks = [ + { childIndex: 0, top: 10, bottom: 30, center: 20 }, + { childIndex: 1, top: 50, bottom: 70, center: 60 }, + ]; + + it('shows the handle when hovering directly left of a block', () => { + expect(getGutterHoveredChildIndex(blocks, 60, 20, 100)).toBe(0); + expect(getGutterHoveredChildIndex(blocks, 60, 60, 100)).toBe(1); + }); + + it('keeps the gutter target continuous through the gap between blocks', () => { + expect(getGutterHoveredChildIndex(blocks, 60, 39, 100)).toBe(0); + expect(getGutterHoveredChildIndex(blocks, 60, 41, 100)).toBe(1); + }); + + it('ignores pointers outside the left gutter', () => { + expect(getGutterHoveredChildIndex(blocks, 51, 20, 100)).toBeNull(); + expect(getGutterHoveredChildIndex(blocks, 101, 20, 100)).toBeNull(); + }); +}); + describe('makeDropZones', () => { it('creates a drop target before, between, and after every draggable block', () => { expect( diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 5b50d4f66a..403521c025 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -33,6 +33,8 @@ type DropZoneLayout = { indicatorTop: number; }; +const GUTTER_HOVER_WIDTH = 48; + type Props = { children: React.ReactNode; editor: Editor; @@ -49,6 +51,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe }) ); const [blockLayout, setBlockLayout] = React.useState([]); + const blockLayoutRef = React.useRef([]); const [hoveredChildIndex, setHoveredChildIndex] = React.useState(null); const hoveredChildIndexRef = React.useRef(null); const [activeChildIndex, setActiveChildIndex] = React.useState(null); @@ -75,6 +78,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe return [{ childIndex, top, bottom, center: top + rect.height / 2 }]; }); + blockLayoutRef.current = nextLayout; setBlockLayout(nextLayout); }, [editorWrapperRef]); @@ -100,12 +104,25 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe if (target.closest('[data-block-drag-handle]')) return; const blockElement = target.closest('.ProseMirror > *'); - if (!blockElement || blockElement.parentElement !== editorElement || !isDraggableBlock(blockElement)) { + const hoveredContentIndex = + blockElement?.parentElement === editorElement && isDraggableBlock(blockElement) + ? Array.from(editorElement.children).indexOf(blockElement) + : null; + const wrapperRect = wrapper.getBoundingClientRect(); + const editorRect = editorElement.getBoundingClientRect(); + const hoveredGutterIndex = getGutterHoveredChildIndex( + blockLayoutRef.current, + event.clientX - wrapperRect.left, + event.clientY - wrapperRect.top, + editorRect.left - wrapperRect.left + ); + const childIndex = hoveredContentIndex ?? hoveredGutterIndex; + + if (childIndex === null) { updateHoveredChildIndex(null); return; } - const childIndex = Array.from(editorElement.children).indexOf(blockElement); if (hoveredChildIndexRef.current === childIndex) return; measureBlocks(); @@ -304,6 +321,27 @@ export function makeDropZones(blocks: BlockLayout[]): DropZoneLayout[] { }); } +/** Finds the block beside a pointer in the editor's left gutter. */ +export function getGutterHoveredChildIndex( + blocks: BlockLayout[], + pointerX: number, + pointerY: number, + editorLeft: number +) { + if (pointerX < editorLeft - GUTTER_HOVER_WIDTH || pointerX > editorLeft) return null; + + const hoveredBlock = blocks.find((block, index) => { + const previous = blocks[index - 1]; + const next = blocks[index + 1]; + const hoverTop = previous ? (previous.bottom + block.top) / 2 : block.top; + const hoverBottom = next ? (block.bottom + next.top) / 2 : block.bottom; + + return pointerY >= hoverTop && pointerY <= hoverBottom; + }); + + return hoveredBlock?.childIndex ?? null; +} + /** Moves one top-level document node to a boundary in the original document. */ export function moveTopLevelBlock(editor: Editor, sourceIndex: number, dropBoundary: number): boolean { const { doc } = editor.state; From f7b7a20c4367f64dcd8082636af28bda8339bf45 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 12:44:04 -0700 Subject: [PATCH 05/28] fix: address block reorder review feedback --- .../web/partials/editor/block-reorder.test.ts | 59 +++++++++++- apps/web/partials/editor/block-reorder.tsx | 95 +++++++++++++++++-- apps/web/partials/editor/id-extension.test.ts | 41 ++++++++ apps/web/partials/editor/id-extension.tsx | 67 ++++++------- 4 files changed, 215 insertions(+), 47 deletions(-) create mode 100644 apps/web/partials/editor/id-extension.test.ts diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index d67bb50fb6..78de0431f5 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -1,6 +1,6 @@ import { DndContext } from '@dnd-kit/core'; import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import Document from '@tiptap/extension-document'; import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; @@ -10,7 +10,13 @@ import React from 'react'; import { afterEach, describe, expect, it } from 'vitest'; -import { BlockDragHandle, getGutterHoveredChildIndex, makeDropZones, moveTopLevelBlock } from './block-reorder'; +import { + BlockDragHandle, + getGutterHoveredChildIndex, + getNextKeyboardDropBoundary, + makeDropZones, + moveTopLevelBlock, +} from './block-reorder'; const editors: Editor[] = []; @@ -25,17 +31,47 @@ describe('BlockDragHandle', () => { React.createElement( DndContext, null, - React.createElement(BlockDragHandle, { childIndex: 0, top: 12, left: -32, isDragging: false }) + React.createElement(BlockDragHandle, { + childIndex: 0, + top: 12, + left: -32, + isDragging: false, + visible: true, + }) ) ); - const button = screen.getByRole('button', { name: 'Drag to reorder block' }); + const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); const hoverBridge = button.parentElement; expect(button).toHaveClass('size-6'); expect(hoverBridge).toHaveAttribute('data-block-drag-handle'); expect(hoverBridge).toHaveClass('w-8'); }); + + it('reveals a hidden handle when it receives keyboard focus', () => { + render( + React.createElement( + DndContext, + null, + React.createElement(BlockDragHandle, { + childIndex: 0, + top: 12, + left: -32, + isDragging: false, + visible: false, + }) + ) + ); + + const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); + const handle = button.parentElement; + expect(handle).toHaveStyle({ opacity: '0', pointerEvents: 'none' }); + + fireEvent.focus(button); + + expect(handle).toHaveStyle({ opacity: '1', pointerEvents: 'auto' }); + }); }); describe('moveTopLevelBlock', () => { @@ -86,6 +122,21 @@ describe('getGutterHoveredChildIndex', () => { }); }); +describe('getNextKeyboardDropBoundary', () => { + const boundaries = [0, 1, 2, 3, 4]; + + it('moves one block at a time and can return to the original position', () => { + expect(getNextKeyboardDropBoundary(1, null, 1, boundaries)).toBe(3); + expect(getNextKeyboardDropBoundary(1, 3, -1, boundaries)).toBe(1); + expect(getNextKeyboardDropBoundary(1, 1, -1, boundaries)).toBe(0); + }); + + it('stops at the first and last positions', () => { + expect(getNextKeyboardDropBoundary(0, null, -1, boundaries)).toBeNull(); + expect(getNextKeyboardDropBoundary(3, null, 1, boundaries)).toBeNull(); + }); +}); + describe('makeDropZones', () => { it('creates a drop target before, between, and after every draggable block', () => { expect( diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 403521c025..393f0d9af1 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -6,6 +6,8 @@ import { DragOverEvent, DragOverlay, DragStartEvent, + KeyboardCode, + KeyboardSensor, PointerSensor, closestCenter, useDraggable, @@ -13,12 +15,15 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; +import type { KeyboardCoordinateGetter } from '@dnd-kit/core'; import type { Editor } from '@tiptap/react'; import * as React from 'react'; import { OrderDots } from '~/design-system/icons/order-dots'; +import { ensureUniqueNodeIds } from './id-extension'; + type BlockLayout = { childIndex: number; top: number; @@ -48,6 +53,9 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: blockKeyboardCoordinates, }) ); const [blockLayout, setBlockLayout] = React.useState([]); @@ -174,6 +182,9 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe const childIndex = event.active.data.current?.childIndex; if (typeof childIndex !== 'number') return; + // Continuation nodes loaded from markdown intentionally start without IDs. + // A drag can happen before blur, so assign/dedupe IDs before persisting it. + ensureUniqueNodeIds(editor); measureBlocks(); setActiveChildIndex(childIndex); }; @@ -210,14 +221,18 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe > {children} - {enabled && handleLayout && visibleHandleIndex !== null ? ( - - ) : null} + {enabled + ? blockLayout.map(layout => ( + + )) + : null} {enabled && activeChildIndex !== null ? dropZones.map(zone => ) @@ -247,12 +262,15 @@ export function BlockDragHandle({ top, left, isDragging, + visible, }: { childIndex: number; top: number; left: number; isDragging: boolean; + visible: boolean; }) { + const [isFocused, setIsFocused] = React.useState(false); const { attributes, listeners, setNodeRef } = useDraggable({ id: `content-block-${childIndex}`, data: { childIndex }, @@ -262,14 +280,21 @@ export function BlockDragHandle({
@@ -364,74 +331,6 @@ export function BlockDragHandle({ ); } -export const blockKeyboardCoordinates: KeyboardCoordinateGetter = (event, { context, currentCoordinates }) => { - if (event.code !== KeyboardCode.Up && event.code !== KeyboardCode.Down) return; - - const sourceIndex = context.active?.data.current?.childIndex; - if (typeof sourceIndex !== 'number') return; - - const dropZones = context.droppableContainers - .getEnabled() - .flatMap(container => { - const boundary = container.data.current?.boundary; - return typeof boundary === 'number' ? [{ boundary, container }] : []; - }) - .sort((a, b) => a.boundary - b.boundary); - const currentBoundary = context.over?.data.current?.boundary; - const targetBoundary = getNextKeyboardDropBoundary( - sourceIndex, - typeof currentBoundary === 'number' ? currentBoundary : null, - event.code === KeyboardCode.Down ? 1 : -1, - dropZones.map(zone => zone.boundary) - ); - const target = dropZones.find(zone => zone.boundary === targetBoundary); - const targetRect = target ? context.droppableRects.get(target.container.id) : null; - if (!targetRect) return; - - event.preventDefault(); - const collisionHeight = context.collisionRect?.height ?? 0; - - return { - x: currentCoordinates.x, - y: targetRect.top + (targetRect.height - collisionHeight) / 2, - }; -}; - -export function getNextKeyboardDropBoundary( - sourceIndex: number, - currentBoundary: number | null, - direction: -1 | 1, - boundaries: number[] -) { - const sourceRank = boundaries.indexOf(sourceIndex); - if (sourceRank === -1) return null; - - const currentBoundaryRank = currentBoundary === null ? sourceRank : boundaries.indexOf(currentBoundary); - if (currentBoundaryRank === -1) return null; - - const currentRank = currentBoundaryRank > sourceRank ? currentBoundaryRank - 1 : currentBoundaryRank; - const targetRank = currentRank + direction; - const targetBoundaryRank = targetRank > sourceRank ? targetRank + 1 : targetRank; - - return boundaries[targetBoundaryRank] ?? null; -} - -function BlockDropZone({ zone, left, width }: { zone: DropZoneLayout; left: number; width: number }) { - const { setNodeRef } = useDroppable({ - id: `content-block-drop-${zone.boundary}`, - data: { boundary: zone.boundary }, - }); - - return ( -
- ); -} - function isDraggableBlock(element: HTMLElement) { return ( !element.classList.contains('paragraph-tail-placeholder') && @@ -439,28 +338,46 @@ function isDraggableBlock(element: HTMLElement) { ); } -export function makeDropZones(blocks: BlockLayout[]): DropZoneLayout[] { - if (blocks.length === 0) return []; +/** Maps document children through ProseMirror positions so DOM widgets cannot shift block indexes. */ +export function getTopLevelBlockElements(editor: Editor, editorElement: HTMLElement) { + const blocks: Array<{ childIndex: number; element: HTMLElement }> = []; + let position = 0; - return Array.from({ length: blocks.length + 1 }, (_, index) => { - const previous = blocks[index - 1]; - const next = blocks[index]; - const top = previous?.center ?? next.top; - const bottom = next?.center ?? previous.bottom; - const indicatorTop = previous && next ? (previous.bottom + next.top) / 2 : (next?.top ?? previous.bottom); - - return { - boundary: next?.childIndex ?? previous.childIndex + 1, - top, - height: Math.max(1, bottom - top), - indicatorTop, - }; - }); + for (let childIndex = 0; childIndex < editor.state.doc.childCount; childIndex += 1) { + const element = editor.view.nodeDOM(position); + if (element instanceof HTMLElement && element.parentElement === editorElement) { + blocks.push({ childIndex, element }); + } + position += editor.state.doc.child(childIndex).nodeSize; + } + + return blocks; +} + +export function getSortableDropBoundary( + blocks: Array>, + sourceRank: number, + targetRank: number +) { + if ( + sourceRank < 0 || + sourceRank >= blocks.length || + targetRank < 0 || + targetRank >= blocks.length || + sourceRank === targetRank + ) { + return null; + } + + const targetChildIndex = blocks[targetRank]?.childIndex; + if (targetChildIndex === undefined) return null; + + return targetRank > sourceRank ? targetChildIndex + 1 : targetChildIndex; } /** Finds the block beside a pointer in the editor's left gutter. */ export function getGutterHoveredChildIndex( - blocks: BlockLayout[], + blocks: BlockPosition[], pointerX: number, pointerY: number, editorLeft: number diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts index abcb778089..75735b4087 100644 --- a/apps/web/vitest.setup.ts +++ b/apps/web/vitest.setup.ts @@ -36,9 +36,26 @@ */ import { TEST_UNMOCKED_NETWORK_CODE } from './core/io/errors/retry-utils'; +// @dnd-kit/dom creates a ResizeObserver subclass at module scope. jsdom does +// not provide the browser API, so sortable component imports need a minimal +// global implementation during tests. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; +} + const refuseNetwork: typeof fetch = async input => { const url = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url ?? ''; + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : ((input as Request).url ?? ''); const error = new Error( `Unmocked network request in a test: ${url}\n` + diff --git a/bun.lock b/bun.lock index 0c7c6ce48d..d1efd810b4 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,8 @@ "@ai-sdk/react": "^3.0.221", "@amplitude/unified": "^1.1.19", "@dnd-kit/core": "^6.3.1", + "@dnd-kit/helpers": "^0.5.0", + "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/opentelemetry": "^0.63.0", @@ -343,12 +345,26 @@ "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="], + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "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" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="], + + "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="], + + "@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="], + + "@dnd-kit/react": ["@dnd-kit/react@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw=="], + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="], + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], @@ -767,6 +783,8 @@ "@posthog/types": ["@posthog/types@1.394.0", "", {}, "sha512-ifQ7p8o8hoHErlJmpzCFzHQcuRam0vXk8LBVhBu4BlPYP6S0tog4FSAFItnI/nwN6cHZI0WFQilr7sIqQa7Flg=="], + "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], + "@privy-io/api-base": ["@privy-io/api-base@1.9.1", "", { "dependencies": { "zod": "^3.25.76" } }, "sha512-Dq7yyZyVQm71gm3d5o1VH9Ahak9gf/zXx6A6E4bx9VVZEYTevL8J3aJAvIIj1qFenc12UGUpWO3oWkn9Cpr+mw=="], "@privy-io/api-types": ["@privy-io/api-types@0.15.0", "", {}, "sha512-VT5/Wau37+ZRaVZ8CPa224z5qcJTeaQtamq6TfOcPJSdV6cNyX+Phlaeg13xFLjj3dIGkqibZvr0f45VT6PX3A=="], From ac337e78591238911fa02e44899c7f00a7832ea6 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 15:08:10 -0700 Subject: [PATCH 16/28] Revert "refactor: migrate block reorder to next-gen dnd kit" This reverts commit 1db871ca6c9fe2d9e777450eaba00e3f030bf4c0. --- apps/web/package.json | 2 - .../web/partials/editor/block-reorder.test.ts | 112 +++--- apps/web/partials/editor/block-reorder.tsx | 345 +++++++++++------- apps/web/vitest.setup.ts | 19 +- bun.lock | 18 - 5 files changed, 284 insertions(+), 212 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 84656bd491..970e374e51 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,8 +20,6 @@ "@ai-sdk/react": "^3.0.221", "@amplitude/unified": "^1.1.19", "@dnd-kit/core": "^6.3.1", - "@dnd-kit/helpers": "^0.5.0", - "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/opentelemetry": "^0.63.0", diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index 0d9a1e69c4..5c94713beb 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -1,3 +1,4 @@ +import { DndContext } from '@dnd-kit/core'; import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import Document from '@tiptap/extension-document'; @@ -13,8 +14,8 @@ import { BlockDragHandle, BlockGutterHoverArea, getGutterHoveredChildIndex, - getSortableDropBoundary, - getTopLevelBlockElements, + getNextKeyboardDropBoundary, + makeDropZones, moveTopLevelBlock, releasePointerDragFocus, } from './block-reorder'; @@ -29,13 +30,17 @@ afterEach(() => { describe('BlockDragHandle', () => { it('bridges the gap between the visible handle and the hovered block', () => { render( - React.createElement(BlockDragHandle, { - childIndex: 0, - top: 12, - left: -32, - isDragging: false, - visible: true, - }) + React.createElement( + DndContext, + null, + React.createElement(BlockDragHandle, { + childIndex: 0, + top: 12, + left: -32, + isDragging: false, + visible: true, + }) + ) ); const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); @@ -48,13 +53,17 @@ describe('BlockDragHandle', () => { it('reveals a hidden handle when it receives keyboard focus', () => { render( - React.createElement(BlockDragHandle, { - childIndex: 0, - top: 12, - left: -32, - isDragging: false, - visible: false, - }) + React.createElement( + DndContext, + null, + React.createElement(BlockDragHandle, { + childIndex: 0, + top: 12, + left: -32, + isDragging: false, + visible: false, + }) + ) ); const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); @@ -68,13 +77,17 @@ describe('BlockDragHandle', () => { it('releases pointer focus after a drag so the old block slot does not stay highlighted', () => { render( - React.createElement(BlockDragHandle, { - childIndex: 0, - top: 12, - left: -32, - isDragging: false, - visible: true, - }) + React.createElement( + DndContext, + null, + React.createElement(BlockDragHandle, { + childIndex: 0, + top: 12, + left: -32, + isDragging: false, + visible: true, + }) + ) ); const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); @@ -158,36 +171,49 @@ describe('getGutterHoveredChildIndex', () => { }); }); -describe('getSortableDropBoundary', () => { - const blocks = [{ childIndex: 0 }, { childIndex: 2 }, { childIndex: 3 }]; +describe('getNextKeyboardDropBoundary', () => { + const boundaries = [0, 1, 2, 3, 4]; - it('maps a downward rank move to the boundary after the target block', () => { - expect(getSortableDropBoundary(blocks, 0, 2)).toBe(4); + it('moves one block at a time and can return to the original position', () => { + expect(getNextKeyboardDropBoundary(1, null, 1, boundaries)).toBe(3); + expect(getNextKeyboardDropBoundary(1, 3, -1, boundaries)).toBe(1); + expect(getNextKeyboardDropBoundary(1, 1, -1, boundaries)).toBe(0); }); - it('maps an upward rank move to the boundary before the target block', () => { - expect(getSortableDropBoundary(blocks, 2, 0)).toBe(0); + it('stops at the first and last positions', () => { + expect(getNextKeyboardDropBoundary(0, null, -1, boundaries)).toBeNull(); + expect(getNextKeyboardDropBoundary(3, null, 1, boundaries)).toBeNull(); }); - it('does nothing when the sortable rank is unchanged or invalid', () => { - expect(getSortableDropBoundary(blocks, 1, 1)).toBeNull(); - expect(getSortableDropBoundary(blocks, -1, 1)).toBeNull(); + it('moves by draggable rank when excluded nodes make child indexes non-contiguous', () => { + const boundariesWithExcludedNode = [0, 2, 3]; + + expect(getNextKeyboardDropBoundary(0, null, 1, boundariesWithExcludedNode)).toBe(3); + expect(getNextKeyboardDropBoundary(2, null, -1, boundariesWithExcludedNode)).toBe(0); }); }); -describe('getTopLevelBlockElements', () => { - it('maps document indexes without counting direct gap-cursor widgets', () => { - const editor = makeEditor(['A', 'B']); - const editorElement = editor.view.dom; - const firstBlock = editor.view.nodeDOM(0); - const gapCursor = document.createElement('div'); - gapCursor.className = 'ProseMirror-gapcursor'; +describe('makeDropZones', () => { + it('creates a drop target before, between, and after every draggable block', () => { + expect( + makeDropZones([ + { childIndex: 0, top: 10, bottom: 30, center: 20 }, + { childIndex: 1, top: 40, bottom: 80, center: 60 }, + ]) + ).toEqual([ + { boundary: 0, top: 10, height: 10, indicatorTop: 10 }, + { boundary: 1, top: 20, height: 40, indicatorTop: 35 }, + { boundary: 2, top: 60, height: 20, indicatorTop: 80 }, + ]); + }); - expect(firstBlock).toBeInstanceOf(HTMLElement); - firstBlock?.parentNode?.insertBefore(gapCursor, firstBlock.nextSibling); + it('places the final drop boundary immediately after the last draggable block', () => { + const zones = makeDropZones([ + { childIndex: 0, top: 10, bottom: 30, center: 20 }, + { childIndex: 1, top: 50, bottom: 70, center: 60 }, + ]); - expect(getTopLevelBlockElements(editor, editorElement).map(block => block.childIndex)).toEqual([0, 1]); - expect(getTopLevelBlockElements(editor, editorElement).map(block => block.element)).not.toContain(gapCursor); + expect(zones.map(zone => zone.boundary)).toEqual([0, 1, 2]); }); }); diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 1b6e1a493b..6e0730428d 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -1,9 +1,22 @@ 'use client'; -import { move } from '@dnd-kit/helpers'; -import { DragDropProvider } from '@dnd-kit/react'; -import type { DragEndEvent, DragStartEvent } from '@dnd-kit/react'; -import { useSortable } from '@dnd-kit/react/sortable'; +import { + DndContext, + DragCancelEvent, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + KeyboardCode, + KeyboardSensor, + PointerSensor, + closestCenter, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { KeyboardCoordinateGetter } from '@dnd-kit/core'; import type { Editor } from '@tiptap/react'; import * as React from 'react'; @@ -13,19 +26,17 @@ import { OrderDots } from '~/design-system/icons/order-dots'; import { ensureUniqueNodeIds } from './id-extension'; type BlockLayout = { - id: string; - element: HTMLElement; childIndex: number; top: number; bottom: number; center: number; }; -type BlockPosition = { - childIndex: number; +type DropZoneLayout = { + boundary: number; top: number; - bottom: number; - center: number; + height: number; + indicatorTop: number; }; const GUTTER_HOVER_WIDTH = 48; @@ -40,11 +51,20 @@ type Props = { /** Adds edit-mode drag controls around TipTap's top-level content blocks. */ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onReorder }: Props) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 4 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: blockKeyboardCoordinates, + }) + ); const [blockLayout, setBlockLayout] = React.useState([]); const blockLayoutRef = React.useRef([]); const [hoveredChildIndex, setHoveredChildIndex] = React.useState(null); const hoveredChildIndexRef = React.useRef(null); - const [activeBlockId, setActiveBlockId] = React.useState(null); + const [activeChildIndex, setActiveChildIndex] = React.useState(null); + const [activeBoundary, setActiveBoundary] = React.useState(null); const updateHoveredChildIndex = React.useCallback((childIndex: number | null) => { hoveredChildIndexRef.current = childIndex; @@ -57,21 +77,19 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe if (!wrapper || !editorElement) return; const wrapperRect = wrapper.getBoundingClientRect(); - const nextLayout = getTopLevelBlockElements(editor, editorElement).flatMap(({ childIndex, element }) => { - if (!isDraggableBlock(element)) return []; + const nextLayout = Array.from(editorElement.children).flatMap((element, childIndex) => { + if (!(element instanceof HTMLElement) || !isDraggableBlock(element)) return []; const rect = element.getBoundingClientRect(); const top = rect.top - wrapperRect.top; const bottom = rect.bottom - wrapperRect.top; - const blockId = editor.state.doc.child(childIndex).attrs.id; - const id = typeof blockId === 'string' && blockId.length > 0 ? blockId : `child-${childIndex}`; - return [{ id, element, childIndex, top, bottom, center: top + rect.height / 2 }]; + return [{ childIndex, top, bottom, center: top + rect.height / 2 }]; }); blockLayoutRef.current = nextLayout; setBlockLayout(nextLayout); - }, [editor, editorWrapperRef]); + }, [editorWrapperRef]); React.useLayoutEffect(() => { if (!enabled) return; @@ -89,17 +107,19 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe const mutationObserver = new MutationObserver(measureBlocks); resizeObserver.observe(editorElement); mutationObserver.observe(editorElement, { childList: true }); - editor.on('update', measureBlocks); const handlePointerMove = (event: PointerEvent) => { - if (activeBlockId !== null) return; + if (activeChildIndex !== null) return; const target = event.target; if (!(target instanceof Element)) return; if (target.closest('[data-block-drag-handle]')) return; const blockElement = target.closest('.ProseMirror > *'); - const hoveredContentIndex = blockLayoutRef.current.find(block => block.element === blockElement)?.childIndex; + const hoveredContentIndex = + blockElement?.parentElement === editorElement && isDraggableBlock(blockElement) + ? Array.from(editorElement.children).indexOf(blockElement) + : null; const wrapperRect = wrapper.getBoundingClientRect(); const editorRect = editorElement.getBoundingClientRect(); const hoveredGutterIndex = getGutterHoveredChildIndex( @@ -108,7 +128,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe event.clientY - wrapperRect.top, editorRect.left - wrapperRect.left ); - const childIndex = hoveredContentIndex ?? hoveredGutterIndex ?? null; + const childIndex = hoveredContentIndex ?? hoveredGutterIndex; if (childIndex === null) { updateHoveredChildIndex(null); @@ -122,7 +142,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe }; const handlePointerLeave = () => { - if (activeBlockId === null) updateHoveredChildIndex(null); + if (activeChildIndex === null) updateHoveredChildIndex(null); }; wrapper.addEventListener('pointermove', handlePointerMove); @@ -131,94 +151,135 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); - editor.off('update', measureBlocks); wrapper.removeEventListener('pointermove', handlePointerMove); wrapper.removeEventListener('pointerleave', handlePointerLeave); }; - }, [activeBlockId, editor, editorWrapperRef, enabled, measureBlocks, updateHoveredChildIndex]); + }, [activeChildIndex, editor, editorWrapperRef, enabled, measureBlocks, updateHoveredChildIndex]); React.useEffect(() => { if (enabled) return; hoveredChildIndexRef.current = null; setHoveredChildIndex(null); - setActiveBlockId(null); + setActiveChildIndex(null); + setActiveBoundary(null); }, [enabled]); const editorRect = editorWrapperRef.current?.querySelector('.ProseMirror')?.getBoundingClientRect(); const wrapperRect = editorWrapperRef.current?.getBoundingClientRect(); const editorLeft = editorRect && wrapperRect ? editorRect.left - wrapperRect.left : 0; - const handleLayout = blockLayout.find(block => block.childIndex === hoveredChildIndex); + const editorWidth = editorRect?.width ?? 0; + const visibleHandleIndex = activeChildIndex ?? hoveredChildIndex; + const handleLayout = blockLayout.find(block => block.childIndex === visibleHandleIndex); + const dropZones = makeDropZones(blockLayout); + const indicatorTop = dropZones.find(zone => zone.boundary === activeBoundary)?.indicatorTop; const resetDragState = () => { - setActiveBlockId(null); + setActiveChildIndex(null); + setActiveBoundary(null); updateHoveredChildIndex(null); }; + const handleDragCancel = (event: DragCancelEvent) => { + releasePointerDragFocus(event.activatorEvent); + resetDragState(); + }; + const handleDragStart = (event: DragStartEvent) => { if (!enabled) return; - const sourceId = event.operation.source?.id; - if (sourceId === null || sourceId === undefined) return; + const childIndex = event.active.data.current?.childIndex; + if (typeof childIndex !== 'number') return; // Continuation nodes loaded from markdown intentionally start without IDs. // A drag can happen before blur, so assign/dedupe IDs before persisting it. ensureUniqueNodeIds(editor); measureBlocks(); - setActiveBlockId(String(sourceId)); + setActiveChildIndex(childIndex); + }; + + const handleDragOver = (event: DragOverEvent) => { + const boundary = event.over?.data.current?.boundary; + setActiveBoundary(typeof boundary === 'number' ? boundary : null); }; const handleDragEnd = (event: DragEndEvent) => { - const blocks = blockLayoutRef.current; - const sourceId = event.operation.source?.id; - - if (!event.canceled && sourceId !== null && sourceId !== undefined) { - const sourceRank = blocks.findIndex(block => block.id === String(sourceId)); - const reorderedBlocks = move(blocks, event); - const targetRank = reorderedBlocks.findIndex(block => block.id === String(sourceId)); - const dropBoundary = getSortableDropBoundary(blocks, sourceRank, targetRank); - const sourceIndex = blocks[sourceRank]?.childIndex; - - if ( - typeof sourceIndex === 'number' && - dropBoundary !== null && - moveTopLevelBlock(editor, sourceIndex, dropBoundary) - ) { - onReorder(); - } + const sourceIndex = event.active.data.current?.childIndex; + const dropBoundary = event.over?.data.current?.boundary; + + if ( + typeof sourceIndex === 'number' && + typeof dropBoundary === 'number' && + moveTopLevelBlock(editor, sourceIndex, dropBoundary) + ) { + onReorder(); } - releasePointerDragFocus(event.operation.activatorEvent); + releasePointerDragFocus(event.activatorEvent); resetDragState(); }; return ( - + {children} - {enabled && activeBlockId === null && blockLayout.length > 0 ? ( + {enabled && activeChildIndex === null && blockLayout.length > 0 ? ( editor.commands.focus()} /> ) : null} {enabled - ? blockLayout.map((layout, index) => ( - ( + )) : null} - + + {enabled && activeChildIndex !== null + ? dropZones.map(zone => ) + : null} + + {enabled && activeChildIndex !== null && indicatorTop !== undefined ? ( +
+ ) : null} + + + {enabled && activeChildIndex !== null ? ( +
+ +
+ ) : null} +
+ ); } +function getBlockDragHandleKey(editor: Editor, childIndex: number) { + if (childIndex < 0 || childIndex >= editor.state.doc.childCount) return `child-${childIndex}`; + + const blockId = editor.state.doc.child(childIndex).attrs.id; + return typeof blockId === 'string' && blockId.length > 0 ? blockId : `child-${childIndex}`; +} + /** Pointer activation should not leave a handle visibly focused after drop. */ -export function releasePointerDragFocus(activatorEvent: Event | null | undefined) { - if (!activatorEvent) return; +export function releasePointerDragFocus(activatorEvent: Event) { if (activatorEvent.type === 'keydown') return; const target = activatorEvent.target; @@ -232,7 +293,7 @@ export function BlockGutterHoverArea({ editorLeft, onClick, }: { - blocks: BlockPosition[]; + blocks: BlockLayout[]; editorLeft: number; onClick?: React.MouseEventHandler; }) { @@ -256,54 +317,24 @@ export function BlockGutterHoverArea({ ); } -function SortableBlockDragHandle({ - layout, - index, - left, - isDragging, - visible, -}: { - layout: BlockLayout; - index: number; - left: number; - isDragging: boolean; - visible: boolean; -}) { - const { handleRef } = useSortable({ - id: layout.id, - index, - element: layout.element, - transition: { duration: 150, easing: 'cubic-bezier(0.25, 1, 0.5, 1)' }, - }); - - return ( - - ); -} - export function BlockDragHandle({ childIndex, top, left, isDragging, visible, - dragHandleRef, }: { childIndex: number; top: number; left: number; isDragging: boolean; visible: boolean; - dragHandleRef?: (element: HTMLButtonElement | null) => void; }) { const [isFocused, setIsFocused] = React.useState(false); + const { attributes, listeners, setNodeRef } = useDraggable({ + id: `content-block-${childIndex}`, + data: { childIndex }, + }); return (
@@ -331,53 +364,103 @@ export function BlockDragHandle({ ); } -function isDraggableBlock(element: HTMLElement) { - return ( - !element.classList.contains('paragraph-tail-placeholder') && - !element.matches('.paragraph-tail-placeholder, .is-empty') +export const blockKeyboardCoordinates: KeyboardCoordinateGetter = (event, { context, currentCoordinates }) => { + if (event.code !== KeyboardCode.Up && event.code !== KeyboardCode.Down) return; + + const sourceIndex = context.active?.data.current?.childIndex; + if (typeof sourceIndex !== 'number') return; + + const dropZones = context.droppableContainers + .getEnabled() + .flatMap(container => { + const boundary = container.data.current?.boundary; + return typeof boundary === 'number' ? [{ boundary, container }] : []; + }) + .sort((a, b) => a.boundary - b.boundary); + const currentBoundary = context.over?.data.current?.boundary; + const targetBoundary = getNextKeyboardDropBoundary( + sourceIndex, + typeof currentBoundary === 'number' ? currentBoundary : null, + event.code === KeyboardCode.Down ? 1 : -1, + dropZones.map(zone => zone.boundary) ); -} + const target = dropZones.find(zone => zone.boundary === targetBoundary); + const targetRect = target ? context.droppableRects.get(target.container.id) : null; + if (!targetRect) return; -/** Maps document children through ProseMirror positions so DOM widgets cannot shift block indexes. */ -export function getTopLevelBlockElements(editor: Editor, editorElement: HTMLElement) { - const blocks: Array<{ childIndex: number; element: HTMLElement }> = []; - let position = 0; + event.preventDefault(); + const collisionHeight = context.collisionRect?.height ?? 0; - for (let childIndex = 0; childIndex < editor.state.doc.childCount; childIndex += 1) { - const element = editor.view.nodeDOM(position); - if (element instanceof HTMLElement && element.parentElement === editorElement) { - blocks.push({ childIndex, element }); - } - position += editor.state.doc.child(childIndex).nodeSize; - } + return { + x: currentCoordinates.x, + y: targetRect.top + (targetRect.height - collisionHeight) / 2, + }; +}; + +export function getNextKeyboardDropBoundary( + sourceIndex: number, + currentBoundary: number | null, + direction: -1 | 1, + boundaries: number[] +) { + const sourceRank = boundaries.indexOf(sourceIndex); + if (sourceRank === -1) return null; + + const currentBoundaryRank = currentBoundary === null ? sourceRank : boundaries.indexOf(currentBoundary); + if (currentBoundaryRank === -1) return null; - return blocks; + const currentRank = currentBoundaryRank > sourceRank ? currentBoundaryRank - 1 : currentBoundaryRank; + const targetRank = currentRank + direction; + const targetBoundaryRank = targetRank > sourceRank ? targetRank + 1 : targetRank; + + return boundaries[targetBoundaryRank] ?? null; } -export function getSortableDropBoundary( - blocks: Array>, - sourceRank: number, - targetRank: number -) { - if ( - sourceRank < 0 || - sourceRank >= blocks.length || - targetRank < 0 || - targetRank >= blocks.length || - sourceRank === targetRank - ) { - return null; - } +function BlockDropZone({ zone, left, width }: { zone: DropZoneLayout; left: number; width: number }) { + const { setNodeRef } = useDroppable({ + id: `content-block-drop-${zone.boundary}`, + data: { boundary: zone.boundary }, + }); - const targetChildIndex = blocks[targetRank]?.childIndex; - if (targetChildIndex === undefined) return null; + return ( +
+ ); +} - return targetRank > sourceRank ? targetChildIndex + 1 : targetChildIndex; +function isDraggableBlock(element: HTMLElement) { + return ( + !element.classList.contains('paragraph-tail-placeholder') && + !element.matches('.paragraph-tail-placeholder, .is-empty') + ); +} + +export function makeDropZones(blocks: BlockLayout[]): DropZoneLayout[] { + if (blocks.length === 0) return []; + + return Array.from({ length: blocks.length + 1 }, (_, index) => { + const previous = blocks[index - 1]; + const next = blocks[index]; + const top = previous?.center ?? next.top; + const bottom = next?.center ?? previous.bottom; + const indicatorTop = previous && next ? (previous.bottom + next.top) / 2 : (next?.top ?? previous.bottom); + + return { + boundary: next?.childIndex ?? previous.childIndex + 1, + top, + height: Math.max(1, bottom - top), + indicatorTop, + }; + }); } /** Finds the block beside a pointer in the editor's left gutter. */ export function getGutterHoveredChildIndex( - blocks: BlockPosition[], + blocks: BlockLayout[], pointerX: number, pointerY: number, editorLeft: number diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts index 75735b4087..abcb778089 100644 --- a/apps/web/vitest.setup.ts +++ b/apps/web/vitest.setup.ts @@ -36,26 +36,9 @@ */ import { TEST_UNMOCKED_NETWORK_CODE } from './core/io/errors/retry-utils'; -// @dnd-kit/dom creates a ResizeObserver subclass at module scope. jsdom does -// not provide the browser API, so sortable component imports need a minimal -// global implementation during tests. -class ResizeObserverStub { - observe() {} - unobserve() {} - disconnect() {} -} - -if (typeof globalThis.ResizeObserver === 'undefined') { - globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; -} - const refuseNetwork: typeof fetch = async input => { const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : ((input as Request).url ?? ''); + typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url ?? ''; const error = new Error( `Unmocked network request in a test: ${url}\n` + diff --git a/bun.lock b/bun.lock index d1efd810b4..0c7c6ce48d 100644 --- a/bun.lock +++ b/bun.lock @@ -26,8 +26,6 @@ "@ai-sdk/react": "^3.0.221", "@amplitude/unified": "^1.1.19", "@dnd-kit/core": "^6.3.1", - "@dnd-kit/helpers": "^0.5.0", - "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/opentelemetry": "^0.63.0", @@ -345,26 +343,12 @@ "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], - "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], - "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], - "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="], - "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "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" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], - "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="], - - "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="], - - "@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="], - - "@dnd-kit/react": ["@dnd-kit/react@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw=="], - "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], - "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="], - "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], @@ -783,8 +767,6 @@ "@posthog/types": ["@posthog/types@1.394.0", "", {}, "sha512-ifQ7p8o8hoHErlJmpzCFzHQcuRam0vXk8LBVhBu4BlPYP6S0tog4FSAFItnI/nwN6cHZI0WFQilr7sIqQa7Flg=="], - "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], - "@privy-io/api-base": ["@privy-io/api-base@1.9.1", "", { "dependencies": { "zod": "^3.25.76" } }, "sha512-Dq7yyZyVQm71gm3d5o1VH9Ahak9gf/zXx6A6E4bx9VVZEYTevL8J3aJAvIIj1qFenc12UGUpWO3oWkn9Cpr+mw=="], "@privy-io/api-types": ["@privy-io/api-types@0.15.0", "", {}, "sha512-VT5/Wau37+ZRaVZ8CPa224z5qcJTeaQtamq6TfOcPJSdV6cNyX+Phlaeg13xFLjj3dIGkqibZvr0f45VT6PX3A=="], From a0ffbed072ed42e4d6ecfda8809e45bc0acb1838 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 15:10:02 -0700 Subject: [PATCH 17/28] fix: keep pointer drag focus in editor --- .../web/partials/editor/block-reorder.test.ts | 10 +++----- apps/web/partials/editor/block-reorder.tsx | 25 ++++++------------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index 5c94713beb..3af4f41371 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -17,7 +17,6 @@ import { getNextKeyboardDropBoundary, makeDropZones, moveTopLevelBlock, - releasePointerDragFocus, } from './block-reorder'; const editors: Editor[] = []; @@ -75,7 +74,7 @@ describe('BlockDragHandle', () => { expect(handle).toHaveStyle({ opacity: '1', pointerEvents: 'auto' }); }); - it('releases pointer focus after a drag so the old block slot does not stay highlighted', () => { + it('prevents pointer activation from moving browser focus to the drag handle', () => { render( React.createElement( DndContext, @@ -91,13 +90,10 @@ describe('BlockDragHandle', () => { ); const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); - button.focus(); - const pointerDown = new MouseEvent('pointerdown', { bubbles: true }); + const pointerDown = new MouseEvent('pointerdown', { bubbles: true, cancelable: true }); button.dispatchEvent(pointerDown); - expect(button).toHaveFocus(); - - releasePointerDragFocus(pointerDown); + expect(pointerDown.defaultPrevented).toBe(true); expect(button).not.toHaveFocus(); }); }); diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 6e0730428d..09fb1d6310 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -2,7 +2,6 @@ import { DndContext, - DragCancelEvent, DragEndEvent, DragOverEvent, DragOverlay, @@ -180,11 +179,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe updateHoveredChildIndex(null); }; - const handleDragCancel = (event: DragCancelEvent) => { - releasePointerDragFocus(event.activatorEvent); - resetDragState(); - }; - const handleDragStart = (event: DragStartEvent) => { if (!enabled) return; @@ -215,7 +209,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe onReorder(); } - releasePointerDragFocus(event.activatorEvent); resetDragState(); }; @@ -226,7 +219,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe collisionDetection={closestCenter} onDragStart={handleDragStart} onDragOver={handleDragOver} - onDragCancel={handleDragCancel} + onDragCancel={resetDragState} onDragEnd={handleDragEnd} > {children} @@ -278,16 +271,6 @@ function getBlockDragHandleKey(editor: Editor, childIndex: number) { return typeof blockId === 'string' && blockId.length > 0 ? blockId : `child-${childIndex}`; } -/** Pointer activation should not leave a handle visibly focused after drop. */ -export function releasePointerDragFocus(activatorEvent: Event) { - if (activatorEvent.type === 'keydown') return; - - const target = activatorEvent.target; - if (!(target instanceof Element)) return; - - target.closest('[data-block-drag-handle] button')?.blur(); -} - export function BlockGutterHoverArea({ blocks, editorLeft, @@ -357,6 +340,12 @@ export function BlockDragHandle({ onBlur={() => setIsFocused(false)} {...attributes} {...listeners} + onPointerDown={event => { + // Pointer activation should not move browser focus away from the + // editor. Keyboard users can still focus and operate the handle. + event.preventDefault(); + listeners?.onPointerDown?.(event); + }} > From 901269c8090c960d450b9df6258f40ed162494fa Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 15:52:42 -0700 Subject: [PATCH 18/28] Revert "fix: keep pointer drag focus in editor" This reverts commit a0ffbed072ed42e4d6ecfda8809e45bc0acb1838. --- .../web/partials/editor/block-reorder.test.ts | 10 +++++--- apps/web/partials/editor/block-reorder.tsx | 25 +++++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index 3af4f41371..5c94713beb 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -17,6 +17,7 @@ import { getNextKeyboardDropBoundary, makeDropZones, moveTopLevelBlock, + releasePointerDragFocus, } from './block-reorder'; const editors: Editor[] = []; @@ -74,7 +75,7 @@ describe('BlockDragHandle', () => { expect(handle).toHaveStyle({ opacity: '1', pointerEvents: 'auto' }); }); - it('prevents pointer activation from moving browser focus to the drag handle', () => { + it('releases pointer focus after a drag so the old block slot does not stay highlighted', () => { render( React.createElement( DndContext, @@ -90,10 +91,13 @@ describe('BlockDragHandle', () => { ); const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); - const pointerDown = new MouseEvent('pointerdown', { bubbles: true, cancelable: true }); + button.focus(); + const pointerDown = new MouseEvent('pointerdown', { bubbles: true }); button.dispatchEvent(pointerDown); + expect(button).toHaveFocus(); + + releasePointerDragFocus(pointerDown); - expect(pointerDown.defaultPrevented).toBe(true); expect(button).not.toHaveFocus(); }); }); diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 09fb1d6310..6e0730428d 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -2,6 +2,7 @@ import { DndContext, + DragCancelEvent, DragEndEvent, DragOverEvent, DragOverlay, @@ -179,6 +180,11 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe updateHoveredChildIndex(null); }; + const handleDragCancel = (event: DragCancelEvent) => { + releasePointerDragFocus(event.activatorEvent); + resetDragState(); + }; + const handleDragStart = (event: DragStartEvent) => { if (!enabled) return; @@ -209,6 +215,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe onReorder(); } + releasePointerDragFocus(event.activatorEvent); resetDragState(); }; @@ -219,7 +226,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe collisionDetection={closestCenter} onDragStart={handleDragStart} onDragOver={handleDragOver} - onDragCancel={resetDragState} + onDragCancel={handleDragCancel} onDragEnd={handleDragEnd} > {children} @@ -271,6 +278,16 @@ function getBlockDragHandleKey(editor: Editor, childIndex: number) { return typeof blockId === 'string' && blockId.length > 0 ? blockId : `child-${childIndex}`; } +/** Pointer activation should not leave a handle visibly focused after drop. */ +export function releasePointerDragFocus(activatorEvent: Event) { + if (activatorEvent.type === 'keydown') return; + + const target = activatorEvent.target; + if (!(target instanceof Element)) return; + + target.closest('[data-block-drag-handle] button')?.blur(); +} + export function BlockGutterHoverArea({ blocks, editorLeft, @@ -340,12 +357,6 @@ export function BlockDragHandle({ onBlur={() => setIsFocused(false)} {...attributes} {...listeners} - onPointerDown={event => { - // Pointer activation should not move browser focus away from the - // editor. Keyboard users can still focus and operate the handle. - event.preventDefault(); - listeners?.onPointerDown?.(event); - }} > From bdef76917421d1f47ac977b2dbb8d450ce4f33cf Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 15:54:13 -0700 Subject: [PATCH 19/28] Revert "fix: clear stale block handle after reorder" This reverts commit eed0ab8f519a0f6dc580e9c20f6fbdf63f50d993. --- .../web/partials/editor/block-reorder.test.ts | 27 --------------- apps/web/partials/editor/block-reorder.tsx | 33 ++----------------- 2 files changed, 3 insertions(+), 57 deletions(-) diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index 5c94713beb..ef07cec771 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -17,7 +17,6 @@ import { getNextKeyboardDropBoundary, makeDropZones, moveTopLevelBlock, - releasePointerDragFocus, } from './block-reorder'; const editors: Editor[] = []; @@ -74,32 +73,6 @@ describe('BlockDragHandle', () => { expect(handle).toHaveStyle({ opacity: '1', pointerEvents: 'auto' }); }); - - it('releases pointer focus after a drag so the old block slot does not stay highlighted', () => { - render( - React.createElement( - DndContext, - null, - React.createElement(BlockDragHandle, { - childIndex: 0, - top: 12, - left: -32, - isDragging: false, - visible: true, - }) - ) - ); - - const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); - button.focus(); - const pointerDown = new MouseEvent('pointerdown', { bubbles: true }); - button.dispatchEvent(pointerDown); - expect(button).toHaveFocus(); - - releasePointerDragFocus(pointerDown); - - expect(button).not.toHaveFocus(); - }); }); describe('BlockGutterHoverArea', () => { diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 6e0730428d..7123059050 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -2,7 +2,6 @@ import { DndContext, - DragCancelEvent, DragEndEvent, DragOverEvent, DragOverlay, @@ -98,9 +97,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe const editorElement = wrapper?.querySelector('.ProseMirror'); if (!wrapper || !editorElement) return; - // Establish stable handle identities before rendering the controls. This - // lets focus follow a block when keyboard reordering changes its child index. - ensureUniqueNodeIds(editor); measureBlocks(); const resizeObserver = new ResizeObserver(measureBlocks); @@ -154,7 +150,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe wrapper.removeEventListener('pointermove', handlePointerMove); wrapper.removeEventListener('pointerleave', handlePointerLeave); }; - }, [activeChildIndex, editor, editorWrapperRef, enabled, measureBlocks, updateHoveredChildIndex]); + }, [activeChildIndex, editorWrapperRef, enabled, measureBlocks, updateHoveredChildIndex]); React.useEffect(() => { if (enabled) return; @@ -180,11 +176,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe updateHoveredChildIndex(null); }; - const handleDragCancel = (event: DragCancelEvent) => { - releasePointerDragFocus(event.activatorEvent); - resetDragState(); - }; - const handleDragStart = (event: DragStartEvent) => { if (!enabled) return; @@ -215,7 +206,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe onReorder(); } - releasePointerDragFocus(event.activatorEvent); resetDragState(); }; @@ -226,7 +216,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe collisionDetection={closestCenter} onDragStart={handleDragStart} onDragOver={handleDragOver} - onDragCancel={handleDragCancel} + onDragCancel={resetDragState} onDragEnd={handleDragEnd} > {children} @@ -238,7 +228,7 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe {enabled ? blockLayout.map(layout => ( = editor.state.doc.childCount) return `child-${childIndex}`; - - const blockId = editor.state.doc.child(childIndex).attrs.id; - return typeof blockId === 'string' && blockId.length > 0 ? blockId : `child-${childIndex}`; -} - -/** Pointer activation should not leave a handle visibly focused after drop. */ -export function releasePointerDragFocus(activatorEvent: Event) { - if (activatorEvent.type === 'keydown') return; - - const target = activatorEvent.target; - if (!(target instanceof Element)) return; - - target.closest('[data-block-drag-handle] button')?.blur(); -} - export function BlockGutterHoverArea({ blocks, editorLeft, From 0f9cfee15c42c10e7052f379d69eaf8f868de030 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 15:56:56 -0700 Subject: [PATCH 20/28] Revert "fix: add block gutter hover surface" This reverts commit 9430d3c0162018049369329c7c98de94a6f8d60f. --- .../web/partials/editor/block-reorder.test.ts | 22 ------------- apps/web/partials/editor/block-reorder.tsx | 33 ------------------- 2 files changed, 55 deletions(-) diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index ef07cec771..9b5c6242cf 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -12,7 +12,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { BlockDragHandle, - BlockGutterHoverArea, getGutterHoveredChildIndex, getNextKeyboardDropBoundary, makeDropZones, @@ -75,27 +74,6 @@ describe('BlockDragHandle', () => { }); }); -describe('BlockGutterHoverArea', () => { - it('creates a real pointer target extending left beyond the handle position', () => { - const { container } = render( - React.createElement(BlockGutterHoverArea, { - editorLeft: 100, - blocks: [ - { childIndex: 0, top: 10, bottom: 30, center: 20 }, - { childIndex: 1, top: 50, bottom: 70, center: 60 }, - ], - }) - ); - - expect(container.querySelector('[data-block-drag-gutter]')).toHaveStyle({ - top: '10px', - left: '52px', - width: '48px', - height: '60px', - }); - }); -}); - describe('moveTopLevelBlock', () => { it('moves a block down to the selected document boundary', () => { const editor = makeEditor(['A', 'B', 'C', 'D']); diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 7123059050..a899dcd63d 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -221,10 +221,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe > {children} - {enabled && activeChildIndex === null && blockLayout.length > 0 ? ( - editor.commands.focus()} /> - ) : null} - {enabled ? blockLayout.map(layout => ( ; -}) { - const firstBlock = blocks[0]; - const lastBlock = blocks[blocks.length - 1]; - if (!firstBlock || !lastBlock) return null; - - return ( -
- ); -} - export function BlockDragHandle({ childIndex, top, From bcc764a6421e9a5d8273920e3fccc5a838932cce Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 16:01:37 -0700 Subject: [PATCH 21/28] Revert "fix: handle sparse block order and relation unsets" This reverts commit 71917313c86837ae2c14b81ef00c70908bc20983. --- apps/web/core/sync/relation-update.test.ts | 22 +--------------- apps/web/core/sync/relation-update.ts | 13 ---------- apps/web/core/sync/use-mutate.tsx | 8 ++---- apps/web/core/types.ts | 2 -- apps/web/core/utils/publish/publish.test.ts | 21 ---------------- apps/web/core/utils/publish/publish.ts | 25 +++++-------------- .../web/partials/editor/block-reorder.test.ts | 7 ------ apps/web/partials/editor/block-reorder.tsx | 16 ++++++------ 8 files changed, 16 insertions(+), 98 deletions(-) diff --git a/apps/web/core/sync/relation-update.test.ts b/apps/web/core/sync/relation-update.test.ts index ae22fbd120..163c5a4a5a 100644 --- a/apps/web/core/sync/relation-update.test.ts +++ b/apps/web/core/sync/relation-update.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Relation } from '../types'; -import { canPublishRelationUpdate, getRelationUpdateUnsetFields } from './relation-update'; +import { canPublishRelationUpdate } from './relation-update'; const existingRelation: Relation = { id: 'existing-relation', @@ -34,23 +34,3 @@ describe('canPublishRelationUpdate', () => { ).toBe(false); }); }); - -describe('getRelationUpdateUnsetFields', () => { - it('explicitly unsets a removed to-space reference', () => { - const relationWithSpace = { ...existingRelation, toSpaceId: 'target-space' }; - - expect(getRelationUpdateUnsetFields(relationWithSpace, { ...relationWithSpace, toSpaceId: undefined })).toEqual([ - 'toSpace', - ]); - }); - - it('preserves a pending unset until a to-space reference is selected again', () => { - const pendingUnset = { - ...existingRelation, - relationUpdateUnsetFields: ['toSpace'] as Array<'toSpace'>, - }; - - expect(getRelationUpdateUnsetFields(pendingUnset, pendingUnset)).toEqual(['toSpace']); - expect(getRelationUpdateUnsetFields(pendingUnset, { ...pendingUnset, toSpaceId: 'target-space' })).toEqual([]); - }); -}); diff --git a/apps/web/core/sync/relation-update.ts b/apps/web/core/sync/relation-update.ts index f8e97284e2..d071a3c3d9 100644 --- a/apps/web/core/sync/relation-update.ts +++ b/apps/web/core/sync/relation-update.ts @@ -14,16 +14,3 @@ export function canPublishRelationUpdate(base: Relation, changed: Relation) { return existsRemotely && onlyUpdatesSupportedFields; } - -/** Tracks optional relation fields that must be explicitly cleared remotely. */ -export function getRelationUpdateUnsetFields(base: Relation, changed: Relation) { - const unsetFields = new Set(base.relationUpdateUnsetFields ?? []); - - if (changed.toSpaceId) { - unsetFields.delete('toSpace'); - } else if (base.toSpaceId) { - unsetFields.add('toSpace'); - } - - return Array.from(unsetFields); -} diff --git a/apps/web/core/sync/use-mutate.tsx b/apps/web/core/sync/use-mutate.tsx index 4576dabebb..327cd3fdf2 100644 --- a/apps/web/core/sync/use-mutate.tsx +++ b/apps/web/core/sync/use-mutate.tsx @@ -18,7 +18,7 @@ import { DataType, Relation, Value } from '../types'; import { toHexId } from '../utils/hex-id'; import { extractValueString } from '../utils/value'; import { saveVideoKeyframe } from '../utils/video/save-keyframe'; -import { canPublishRelationUpdate, getRelationUpdateUnsetFields } from './relation-update'; +import { canPublishRelationUpdate } from './relation-update'; import { GeoStore } from './store'; import { store, useSyncEngine } from './use-sync-engine'; @@ -489,11 +489,7 @@ function createMutator(store: GeoStore): Mutator { // Once a relation exists remotely, preserve its identity and publish // supported field changes through the SDK's updateRelation operation. const newRelation = produce(changedRelation, draft => { - const isRelationUpdate = canPublishRelationUpdate(base, changedRelation); - draft.isRelationUpdate = isRelationUpdate; - draft.relationUpdateUnsetFields = isRelationUpdate - ? getRelationUpdateUnsetFields(base, changedRelation) - : undefined; + draft.isRelationUpdate = canPublishRelationUpdate(base, changedRelation); }); store.setRelation(newRelation); }, diff --git a/apps/web/core/types.ts b/apps/web/core/types.ts index 980aae5fe6..cb9745cee4 100644 --- a/apps/web/core/types.ts +++ b/apps/web/core/types.ts @@ -240,8 +240,6 @@ export type Value = LocalMetadata & { export type Relation = LocalMetadata & { /** Publish this local change with the SDK's updateRelation operation. */ isRelationUpdate?: boolean; - /** Optional relation fields that the pending updateRelation operation clears. */ - relationUpdateUnsetFields?: Array<'toSpace'>; id: string; entityId: string; type: { diff --git a/apps/web/core/utils/publish/publish.test.ts b/apps/web/core/utils/publish/publish.test.ts index b1f467fcf7..6b8b52f711 100644 --- a/apps/web/core/utils/publish/publish.test.ts +++ b/apps/web/core/utils/publish/publish.test.ts @@ -94,7 +94,6 @@ type UpdateRelationOp = Op & { type: 'updateRelation'; id: unknown; position?: string; - unset: string[]; }; describe('prepareLocalDataForPublishing', () => { @@ -152,26 +151,6 @@ describe('prepareLocalDataForPublishing', () => { expect(Array.from(updateOp.id as Uint8Array)).toEqual(Array.from(IdUtils.toBytes(relationId) as Uint8Array)); }); - it('should explicitly unset a cleared relation to-space reference', () => { - const relationId = IdUtils.generate(); - const relations = [ - createMockRelation({ - id: relationId, - isRelationUpdate: true, - toSpaceId: undefined, - relationUpdateUnsetFields: ['toSpace'], - }), - ]; - - const result = prepareLocalDataForPublishing([], relations, 'test-space'); - - expect(result).toHaveLength(1); - const updateOp = result[0] as UpdateRelationOp; - expect(updateOp.type).toBe('updateRelation'); - expect(updateOp.unset).toEqual(['toSpace']); - expect(updateOp).not.toHaveProperty('toSpace'); - }); - it('should create deleteRelation operation for deleted relations', async () => { const values: Value[] = []; const relations = [createMockRelation({ isDeleted: true, type: { id: SystemIds.BLOCKS, name: 'Blocks' } })]; diff --git a/apps/web/core/utils/publish/publish.ts b/apps/web/core/utils/publish/publish.ts index 8ed84876d2..4896ab10ee 100644 --- a/apps/web/core/utils/publish/publish.ts +++ b/apps/web/core/utils/publish/publish.ts @@ -2,13 +2,11 @@ import { ContentIds, type DecimalMantissa, Graph, - IdUtils, Op, Ops, type PropertyValueParam, SystemIds, } from '@geoprotocol/geo-sdk/lite'; -import { updateRelation as updateGrc20Relation } from '@geoprotocol/grc-20'; import { Effect } from 'effect'; @@ -97,23 +95,12 @@ function prepareOps(values: Value[], relations: Relation[], spaceId: string): Op const { ops: deleteOps } = Graph.deleteRelation({ id: r.id }); ops.push(...deleteOps); } else if (r.isRelationUpdate) { - if (r.relationUpdateUnsetFields?.length) { - ops.push( - updateGrc20Relation({ - id: IdUtils.toGrcId(r.id), - position: r.position, - ...(r.toSpaceId && { toSpace: IdUtils.toGrcId(r.toSpaceId) }), - unset: r.relationUpdateUnsetFields, - }) - ); - } else { - const { ops: updateOps } = Ops.relations.update({ - id: r.id, - position: r.position, - ...(r.toSpaceId && { toSpace: r.toSpaceId }), - }); - ops.push(...updateOps); - } + const { ops: updateOps } = Ops.relations.update({ + id: r.id, + position: r.position, + ...(r.toSpaceId && { toSpace: r.toSpaceId }), + }); + ops.push(...updateOps); } else { const { ops: createOps } = Graph.createRelation({ fromEntity: r.fromEntity.id, diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index 9b5c6242cf..78de0431f5 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -135,13 +135,6 @@ describe('getNextKeyboardDropBoundary', () => { expect(getNextKeyboardDropBoundary(0, null, -1, boundaries)).toBeNull(); expect(getNextKeyboardDropBoundary(3, null, 1, boundaries)).toBeNull(); }); - - it('moves by draggable rank when excluded nodes make child indexes non-contiguous', () => { - const boundariesWithExcludedNode = [0, 2, 3]; - - expect(getNextKeyboardDropBoundary(0, null, 1, boundariesWithExcludedNode)).toBe(3); - expect(getNextKeyboardDropBoundary(2, null, -1, boundariesWithExcludedNode)).toBe(0); - }); }); describe('makeDropZones', () => { diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index a899dcd63d..393f0d9af1 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -343,17 +343,15 @@ export function getNextKeyboardDropBoundary( direction: -1 | 1, boundaries: number[] ) { - const sourceRank = boundaries.indexOf(sourceIndex); - if (sourceRank === -1) return null; + const currentIndex = currentBoundary === null ? sourceIndex : toFinalBlockIndex(sourceIndex, currentBoundary); + const targetIndex = currentIndex + direction; + const targetBoundary = targetIndex > sourceIndex ? targetIndex + 1 : targetIndex; - const currentBoundaryRank = currentBoundary === null ? sourceRank : boundaries.indexOf(currentBoundary); - if (currentBoundaryRank === -1) return null; - - const currentRank = currentBoundaryRank > sourceRank ? currentBoundaryRank - 1 : currentBoundaryRank; - const targetRank = currentRank + direction; - const targetBoundaryRank = targetRank > sourceRank ? targetRank + 1 : targetRank; + return boundaries.includes(targetBoundary) ? targetBoundary : null; +} - return boundaries[targetBoundaryRank] ?? null; +function toFinalBlockIndex(sourceIndex: number, dropBoundary: number) { + return dropBoundary > sourceIndex ? dropBoundary - 1 : dropBoundary; } function BlockDropZone({ zone, left, width }: { zone: DropZoneLayout; left: number; width: number }) { From 1693f6045d265ac33d943657c86a7f64a705cf37 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Wed, 26 Aug 2026 16:05:09 -0700 Subject: [PATCH 22/28] Revert "fix: address block reorder review feedback" This reverts commit f7b7a20c4367f64dcd8082636af28bda8339bf45. --- .../web/partials/editor/block-reorder.test.ts | 59 +----------- apps/web/partials/editor/block-reorder.tsx | 95 ++----------------- apps/web/partials/editor/id-extension.test.ts | 41 -------- apps/web/partials/editor/id-extension.tsx | 67 +++++++------ 4 files changed, 47 insertions(+), 215 deletions(-) delete mode 100644 apps/web/partials/editor/id-extension.test.ts diff --git a/apps/web/partials/editor/block-reorder.test.ts b/apps/web/partials/editor/block-reorder.test.ts index 78de0431f5..d67bb50fb6 100644 --- a/apps/web/partials/editor/block-reorder.test.ts +++ b/apps/web/partials/editor/block-reorder.test.ts @@ -1,6 +1,6 @@ import { DndContext } from '@dnd-kit/core'; import '@testing-library/jest-dom/vitest'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, render, screen } from '@testing-library/react'; import Document from '@tiptap/extension-document'; import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; @@ -10,13 +10,7 @@ import React from 'react'; import { afterEach, describe, expect, it } from 'vitest'; -import { - BlockDragHandle, - getGutterHoveredChildIndex, - getNextKeyboardDropBoundary, - makeDropZones, - moveTopLevelBlock, -} from './block-reorder'; +import { BlockDragHandle, getGutterHoveredChildIndex, makeDropZones, moveTopLevelBlock } from './block-reorder'; const editors: Editor[] = []; @@ -31,47 +25,17 @@ describe('BlockDragHandle', () => { React.createElement( DndContext, null, - React.createElement(BlockDragHandle, { - childIndex: 0, - top: 12, - left: -32, - isDragging: false, - visible: true, - }) + React.createElement(BlockDragHandle, { childIndex: 0, top: 12, left: -32, isDragging: false }) ) ); - const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); + const button = screen.getByRole('button', { name: 'Drag to reorder block' }); const hoverBridge = button.parentElement; expect(button).toHaveClass('size-6'); expect(hoverBridge).toHaveAttribute('data-block-drag-handle'); expect(hoverBridge).toHaveClass('w-8'); }); - - it('reveals a hidden handle when it receives keyboard focus', () => { - render( - React.createElement( - DndContext, - null, - React.createElement(BlockDragHandle, { - childIndex: 0, - top: 12, - left: -32, - isDragging: false, - visible: false, - }) - ) - ); - - const button = screen.getByRole('button', { name: 'Drag block 1 to reorder' }); - const handle = button.parentElement; - expect(handle).toHaveStyle({ opacity: '0', pointerEvents: 'none' }); - - fireEvent.focus(button); - - expect(handle).toHaveStyle({ opacity: '1', pointerEvents: 'auto' }); - }); }); describe('moveTopLevelBlock', () => { @@ -122,21 +86,6 @@ describe('getGutterHoveredChildIndex', () => { }); }); -describe('getNextKeyboardDropBoundary', () => { - const boundaries = [0, 1, 2, 3, 4]; - - it('moves one block at a time and can return to the original position', () => { - expect(getNextKeyboardDropBoundary(1, null, 1, boundaries)).toBe(3); - expect(getNextKeyboardDropBoundary(1, 3, -1, boundaries)).toBe(1); - expect(getNextKeyboardDropBoundary(1, 1, -1, boundaries)).toBe(0); - }); - - it('stops at the first and last positions', () => { - expect(getNextKeyboardDropBoundary(0, null, -1, boundaries)).toBeNull(); - expect(getNextKeyboardDropBoundary(3, null, 1, boundaries)).toBeNull(); - }); -}); - describe('makeDropZones', () => { it('creates a drop target before, between, and after every draggable block', () => { expect( diff --git a/apps/web/partials/editor/block-reorder.tsx b/apps/web/partials/editor/block-reorder.tsx index 393f0d9af1..403521c025 100644 --- a/apps/web/partials/editor/block-reorder.tsx +++ b/apps/web/partials/editor/block-reorder.tsx @@ -6,8 +6,6 @@ import { DragOverEvent, DragOverlay, DragStartEvent, - KeyboardCode, - KeyboardSensor, PointerSensor, closestCenter, useDraggable, @@ -15,15 +13,12 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import type { KeyboardCoordinateGetter } from '@dnd-kit/core'; import type { Editor } from '@tiptap/react'; import * as React from 'react'; import { OrderDots } from '~/design-system/icons/order-dots'; -import { ensureUniqueNodeIds } from './id-extension'; - type BlockLayout = { childIndex: number; top: number; @@ -53,9 +48,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: blockKeyboardCoordinates, }) ); const [blockLayout, setBlockLayout] = React.useState([]); @@ -182,9 +174,6 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe const childIndex = event.active.data.current?.childIndex; if (typeof childIndex !== 'number') return; - // Continuation nodes loaded from markdown intentionally start without IDs. - // A drag can happen before blur, so assign/dedupe IDs before persisting it. - ensureUniqueNodeIds(editor); measureBlocks(); setActiveChildIndex(childIndex); }; @@ -221,18 +210,14 @@ export function BlockReorder({ children, editor, editorWrapperRef, enabled, onRe > {children} - {enabled - ? blockLayout.map(layout => ( - - )) - : null} + {enabled && handleLayout && visibleHandleIndex !== null ? ( + + ) : null} {enabled && activeChildIndex !== null ? dropZones.map(zone => ) @@ -262,15 +247,12 @@ export function BlockDragHandle({ top, left, isDragging, - visible, }: { childIndex: number; top: number; left: number; isDragging: boolean; - visible: boolean; }) { - const [isFocused, setIsFocused] = React.useState(false); const { attributes, listeners, setNodeRef } = useDraggable({ id: `content-block-${childIndex}`, data: { childIndex }, @@ -280,21 +262,14 @@ export function BlockDragHandle({