Skip to content

Commit 9bfd6a9

Browse files
authored
ENG-1639: Re-implement sidebar drag-and-drop with @dnd-kit (#973)
* ENG-1639: Re-implement sidebar drag-and-drop with @dnd-kit Replaces the removed @hello-pangea/dnd (ENG-1051, PR #544) with @dnd-kit/core + @dnd-kit/sortable across three surfaces: the left sidebar, Global settings, and Personal settings. dnd-kit was chosen over re-introducing pangea because pangea's portal handling crashed inside Blueprint's Dialog (ENG-985) and couldn't be fixed. - Shared SortableList primitive: PointerSensor (8px activation so clicks still work) + KeyboardSensor (space/enter to pick up, arrows to move) for a11y. - moveRoamBlockToIndex util encapsulates the finalIndex off-by-one that Roam moveBlock requires. - Settings panels: up/down arrow buttons replaced by drag; keyboard users retain reorder via KeyboardSensor. * ENG-1639: Drop KeyboardSensor — out of scope for re-implementation The prior pangea implementation had no keyboard reorder; restoring that baseline, not expanding on it. * ENG-1639: Use named args for reorderChildren (AGENTS.md:36) Addresses Devin review: functions with >2 params should use object destructuring per AGENTS.md. Applies to both reorderChildren callbacks and the onChildrenReorder prop type in LeftSidebarView. * ENG-1639: Fix drag stretching, add global children DnD, restore cursor affordance - Use CSS.Translate instead of CSS.Transform to prevent scale-induced stretching - Add drag-and-drop reordering to global section children in sidebar view - Restore cursor-pointer on clickable section titles in personal settings - Scope eslint-disable to the specific line in moveRoamBlock.ts
1 parent 15e3d47 commit 9bfd6a9

7 files changed

Lines changed: 500 additions & 248 deletions

File tree

apps/roam/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
"@blueprintjs/core": "3.50.4",
3838
"@blueprintjs/icons": "3.30.2",
3939
"@blueprintjs/select": "3.19.1",
40+
"@dnd-kit/core": "^6.3.1",
41+
"@dnd-kit/sortable": "^10.0.0",
42+
"@dnd-kit/utilities": "^3.2.2",
4043
"@octokit/auth-app": "^7.1.4",
4144
"@octokit/core": "^6.1.3",
4245
"@repo/database": "workspace:*",

apps/roam/src/components/LeftSidebarView.tsx

Lines changed: 197 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import React, {
77
useState,
88
} from "react";
99
import ReactDOM from "react-dom";
10+
import { arrayMove } from "@dnd-kit/sortable";
11+
import { SortableList, type SortableHandle } from "./SortableList";
12+
import { moveRoamBlockToIndex } from "~/utils/moveRoamBlock";
1013
import {
1114
Button,
1215
Collapse,
@@ -132,61 +135,86 @@ const toggleFoldedState = ({
132135
}
133136
};
134137

138+
type ChildNode = { uid: string; text: string; alias?: { value: string } };
139+
140+
const ChildRow = ({
141+
child,
142+
truncateAt,
143+
onloadArgs,
144+
}: {
145+
child: ChildNode;
146+
truncateAt?: number;
147+
onloadArgs: OnloadArgs;
148+
}) => {
149+
const ref = parseReference(child.text);
150+
const alias = child.alias?.value;
151+
const display =
152+
ref.type === "command"
153+
? ref.display
154+
: ref.type === "page"
155+
? getPageTitleByPageUid(ref.display)
156+
: getTextByBlockUid(ref.uid);
157+
const label = alias || truncate(display, truncateAt);
158+
const onClick = (e: React.MouseEvent) => {
159+
return void openTarget(e, child.text, onloadArgs);
160+
};
161+
return (
162+
<div className="pl-8 pr-2.5">
163+
{ref.type === "command" ? (
164+
<span className="bp3-dark">
165+
<Button onClick={onClick} minimal className="m-px">
166+
{cleanCommandName(label)}
167+
</Button>
168+
</span>
169+
) : (
170+
<div
171+
className="section-child-item page cursor-pointer rounded-sm leading-normal text-gray-600"
172+
onClick={onClick}
173+
>
174+
{label}
175+
</div>
176+
)}
177+
</div>
178+
);
179+
};
180+
135181
const SectionChildren = ({
136182
childrenNodes,
137183
truncateAt,
138184
onloadArgs,
139185
}: {
140-
childrenNodes: { uid: string; text: string; alias?: { value: string } }[];
186+
childrenNodes: ChildNode[];
141187
truncateAt?: number;
142188
onloadArgs: OnloadArgs;
143189
}) => {
144190
if (!childrenNodes?.length) return null;
145191
return (
146192
<>
147-
{childrenNodes.map((child) => {
148-
const ref = parseReference(child.text);
149-
const alias = child.alias?.value;
150-
const display =
151-
ref.type === "command"
152-
? ref.display
153-
: ref.type === "page"
154-
? getPageTitleByPageUid(ref.display)
155-
: getTextByBlockUid(ref.uid);
156-
const label = alias || truncate(display, truncateAt);
157-
const onClick = (e: React.MouseEvent) => {
158-
return void openTarget(e, child.text, onloadArgs);
159-
};
160-
return (
161-
<div key={child.uid} className="pl-8 pr-2.5">
162-
{ref.type === "command" ? (
163-
<span className="bp3-dark">
164-
<Button onClick={onClick} minimal className="m-px">
165-
{cleanCommandName(label)}
166-
</Button>
167-
</span>
168-
) : (
169-
<div
170-
className={
171-
"section-child-item page cursor-pointer rounded-sm leading-normal text-gray-600"
172-
}
173-
onClick={onClick}
174-
>
175-
{label}
176-
</div>
177-
)}
178-
</div>
179-
);
180-
})}
193+
{childrenNodes.map((child) => (
194+
<ChildRow
195+
key={child.uid}
196+
child={child}
197+
truncateAt={truncateAt}
198+
onloadArgs={onloadArgs}
199+
/>
200+
))}
181201
</>
182202
);
183203
};
184204

185205
const PersonalSectionItem = ({
186206
section,
207+
dragHandle,
208+
onChildrenReorder,
187209
onloadArgs,
188210
}: {
189211
section: LeftSidebarPersonalSectionConfig;
212+
dragHandle: SortableHandle;
213+
onChildrenReorder: (args: {
214+
sectionUid: string;
215+
oldIndex: number;
216+
newIndex: number;
217+
}) => void;
190218
onloadArgs: OnloadArgs;
191219
}) => {
192220
const titleRef = parseReference(section.text);
@@ -213,7 +241,11 @@ const PersonalSectionItem = ({
213241

214242
return (
215243
<>
216-
<div className="sidebar-title-button flex w-full cursor-pointer items-center border-none bg-transparent pl-6 pr-2.5 font-semibold outline-none">
244+
<div
245+
{...dragHandle.attributes}
246+
{...dragHandle.listeners}
247+
className="sidebar-title-button flex w-full cursor-pointer items-center border-none bg-transparent pl-6 pr-2.5 font-semibold outline-none"
248+
>
217249
<div className="flex w-full items-center justify-between">
218250
<div
219251
className="flex items-center"
@@ -236,10 +268,21 @@ const PersonalSectionItem = ({
236268
</div>
237269
</div>
238270
<Collapse isOpen={isOpen}>
239-
<SectionChildren
240-
childrenNodes={section.children || []}
241-
truncateAt={truncateAt}
242-
onloadArgs={onloadArgs}
271+
<SortableList
272+
items={section.children || []}
273+
getId={(c) => c.uid}
274+
onReorder={(oldIndex, newIndex) =>
275+
onChildrenReorder({ sectionUid: section.uid, oldIndex, newIndex })
276+
}
277+
renderItem={(child, handle) => (
278+
<div {...handle.attributes} {...handle.listeners}>
279+
<ChildRow
280+
child={child}
281+
truncateAt={truncateAt}
282+
onloadArgs={onloadArgs}
283+
/>
284+
</div>
285+
)}
243286
/>
244287
</Collapse>
245288
</>
@@ -248,31 +291,92 @@ const PersonalSectionItem = ({
248291

249292
const PersonalSections = ({
250293
config,
294+
setConfig,
251295
onloadArgs,
252296
}: {
253297
config: LeftSidebarConfig;
298+
setConfig: Dispatch<SetStateAction<LeftSidebarConfig>>;
254299
onloadArgs: OnloadArgs;
255300
}) => {
256301
const sections = config.personal.sections || [];
257302

258303
if (!sections.length) return null;
259304

305+
const reorderSections = (oldIndex: number, newIndex: number) => {
306+
const moved = sections[oldIndex];
307+
if (!moved) return;
308+
const reordered = arrayMove(sections, oldIndex, newIndex);
309+
setConfig({
310+
...config,
311+
personal: { ...config.personal, sections: reordered },
312+
});
313+
void moveRoamBlockToIndex({
314+
blockUid: moved.uid,
315+
parentUid: config.personal.uid,
316+
sourceIndex: oldIndex,
317+
destIndex: newIndex,
318+
}).then(() => {
319+
refreshAndNotify();
320+
});
321+
};
322+
323+
const reorderChildren = ({
324+
sectionUid,
325+
oldIndex,
326+
newIndex,
327+
}: {
328+
sectionUid: string;
329+
oldIndex: number;
330+
newIndex: number;
331+
}) => {
332+
const section = sections.find((s) => s.uid === sectionUid);
333+
const children = section?.children;
334+
if (!section || !children || !section.childrenUid) return;
335+
const child = children[oldIndex];
336+
if (!child) return;
337+
const reorderedChildren = arrayMove(children, oldIndex, newIndex);
338+
const newSections = sections.map((s) =>
339+
s.uid === sectionUid ? { ...s, children: reorderedChildren } : s,
340+
);
341+
setConfig({
342+
...config,
343+
personal: { ...config.personal, sections: newSections },
344+
});
345+
void moveRoamBlockToIndex({
346+
blockUid: child.uid,
347+
parentUid: section.childrenUid,
348+
sourceIndex: oldIndex,
349+
destIndex: newIndex,
350+
}).then(() => {
351+
refreshAndNotify();
352+
});
353+
};
354+
260355
return (
261-
<div className="personal-left-sidebar-sections">
262-
{sections.map((section) => (
263-
<div key={section.uid}>
264-
<PersonalSectionItem section={section} onloadArgs={onloadArgs} />
265-
</div>
266-
))}
267-
</div>
356+
<SortableList
357+
items={sections}
358+
getId={(s) => s.uid}
359+
onReorder={reorderSections}
360+
className="personal-left-sidebar-sections"
361+
renderItem={(section, handle) => (
362+
<PersonalSectionItem
363+
section={section}
364+
dragHandle={handle}
365+
onChildrenReorder={reorderChildren}
366+
onloadArgs={onloadArgs}
367+
/>
368+
)}
369+
/>
268370
);
269371
};
270372

271373
const GlobalSection = ({
272374
config,
375+
onGlobalChildrenReorder,
273376
onloadArgs,
274377
}: {
275378
config: LeftSidebarConfig["global"];
379+
onGlobalChildrenReorder: (oldIndex: number, newIndex: number) => void;
276380
onloadArgs: OnloadArgs;
277381
}) => {
278382
const [isOpen, setIsOpen] = useState<boolean>(
@@ -281,6 +385,19 @@ const GlobalSection = ({
281385
if (!config.children?.length) return null;
282386
const isCollapsable = config.settings?.collapsable.value;
283387

388+
const children = (
389+
<SortableList
390+
items={config.children}
391+
getId={(c) => c.uid}
392+
onReorder={onGlobalChildrenReorder}
393+
renderItem={(child, handle) => (
394+
<div {...handle.attributes} {...handle.listeners}>
395+
<ChildRow child={child} onloadArgs={onloadArgs} />
396+
</div>
397+
)}
398+
/>
399+
);
400+
284401
return (
285402
<>
286403
<div
@@ -305,17 +422,9 @@ const GlobalSection = ({
305422
</div>
306423
</div>
307424
{isCollapsable ? (
308-
<Collapse isOpen={isOpen}>
309-
<SectionChildren
310-
childrenNodes={config.children}
311-
onloadArgs={onloadArgs}
312-
/>
313-
</Collapse>
425+
<Collapse isOpen={isOpen}>{children}</Collapse>
314426
) : (
315-
<SectionChildren
316-
childrenNodes={config.children}
317-
onloadArgs={onloadArgs}
318-
/>
427+
children
319428
)}
320429
</>
321430
);
@@ -453,13 +562,41 @@ const FavoritesPopover = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
453562
};
454563

455564
const LeftSidebarView = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
456-
const { config } = useConfig();
565+
const { config, setConfig } = useConfig();
566+
567+
const reorderGlobalChildren = (oldIndex: number, newIndex: number) => {
568+
const children = config.global.children;
569+
if (!children) return;
570+
const moved = children[oldIndex];
571+
if (!moved) return;
572+
const reordered = arrayMove(children, oldIndex, newIndex);
573+
setConfig({
574+
...config,
575+
global: { ...config.global, children: reordered },
576+
});
577+
void moveRoamBlockToIndex({
578+
blockUid: moved.uid,
579+
parentUid: config.global.childrenUid,
580+
sourceIndex: oldIndex,
581+
destIndex: newIndex,
582+
}).then(() => {
583+
refreshAndNotify();
584+
});
585+
};
457586

458587
return (
459588
<>
460589
<FavoritesPopover onloadArgs={onloadArgs} />
461-
<GlobalSection config={config.global} onloadArgs={onloadArgs} />
462-
<PersonalSections config={config} onloadArgs={onloadArgs} />
590+
<GlobalSection
591+
config={config.global}
592+
onGlobalChildrenReorder={reorderGlobalChildren}
593+
onloadArgs={onloadArgs}
594+
/>
595+
<PersonalSections
596+
config={config}
597+
setConfig={setConfig}
598+
onloadArgs={onloadArgs}
599+
/>
463600
</>
464601
);
465602
};

0 commit comments

Comments
 (0)