Skip to content

Commit 1bd30fb

Browse files
authored
Support moving bookmark by drag and drop (#142)
* Support moving bookmark by drag and drop * Implement drop * Ignore non-bookmark event * [WIP] Use drop target * WIP * refactor: extract viewmodel * Reorder bookmarks * Fix styles on drag * Fix dragEnd when drag across folder * Use dragEnter for better performance * Use data-drag to prevent hover * Improve moving style * Set url data * refactor: toggle by data-drag-active * refactor: extract <BookmarkDragDrop> * Hover style * refactor: move drag related styles to <BookmarkDragDrop> * refactor: remove unused css class * Improve behavior
1 parent e922fb3 commit 1bd30fb

File tree

5 files changed

+199
-12
lines changed

5 files changed

+199
-12
lines changed

src/Bookmarks/component.css

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@
3636
position: relative; /* for .BookmarkEditButton */
3737
}
3838

39+
/* when a bookmark is moved to another folder, "From" should be hidden */
40+
.Bookmark__DragDrop__From:not(.Bookmark__DragDrop__To) .Bookmark {
41+
visibility: hidden;
42+
}
43+
44+
/* when a bookmark is moved to another folder but not hovered, "To" should be placeholder */
45+
.Bookmark__DragDrop__To:not(.Bookmark__DragDrop__Hover) {
46+
outline: var(--palette-1) dotted 2px;
47+
}
48+
49+
.Bookmark__DragDrop__To:not(.Bookmark__DragDrop__Hover) .Bookmark {
50+
visibility: hidden;
51+
}
52+
53+
.Bookmark__DragDrop__Hover {
54+
outline: var(--palette-1) solid 2px;
55+
}
56+
3957
.BookmarkButton {
4058
display: grid;
4159
grid-template-columns: var(--size-icon) auto;
@@ -48,7 +66,7 @@
4866
background-color: var(--palette-3);
4967
}
5068

51-
.BookmarkButton:hover {
69+
.BookmarkButton:not([data-drag-active]):hover {
5270
background-color: var(--palette-2);
5371
box-shadow: 0 0 calc(var(--size-folder-item-gap) * 3) var(--palette-01);
5472
}
@@ -91,6 +109,10 @@
91109
text-align: center;
92110
}
93111

112+
.BookmarkEditButton[data-drag-active] {
113+
display: none;
114+
}
115+
94116
.BookmarkEditButton {
95117
visibility: hidden;
96118
opacity: 0;

src/Bookmarks/component.tsx

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import './component.css'
2-
import { Bookmark, BookmarkFolder, FolderCollapse, filterBookmarks } from './model'
3-
import React, { FC, PropsWithChildren, useState } from 'react'
4-
import { useBookmarkFolders, useFolderCollapse } from './repository'
2+
import { Bookmark, BookmarkFolder, FolderCollapse, Position, filterBookmarks } from './model'
3+
import { BookmarkWithDragProps, Drag, reorderBookmarks } from './viewmodel'
4+
import React, { Dispatch, FC, PropsWithChildren, useState } from 'react'
5+
import { moveBookmark, useBookmarkFolders, useFolderCollapse } from './repository'
56
import BookmarkEditorComponent from '../BookmarkEditor/component'
67
import { EditingBookmark } from '../BookmarkEditor/model'
78
import Link from '../Link/component'
@@ -37,12 +38,13 @@ type BookmarkFoldersComponentProps = {
3738
const BookmarkFoldersComponent: FC<BookmarkFoldersComponentProps> = ({ bookmarkFolders, shortcutMap, search }) => {
3839
const [toggles] = useToggles()
3940
const [folderCollapse, setFolderCollapse] = useFolderCollapse()
41+
const [drag, setDrag] = useState<Drag>()
4042
return (
4143
<div className="BookmarkFolders">
4244
{bookmarkFolders.map((f, i) => (
4345
<BookmarkFolderIndent key={i} depth={toggles.indent ? f.depth : 0}>
4446
<BookmarkFolderCollapse folder={f} folderCollapse={folderCollapse} setFolderCollapse={setFolderCollapse}>
45-
<BookmarkFolderItems folder={f} shortcutMap={shortcutMap} search={search} />
47+
<BookmarkFolderItems folder={f} shortcutMap={shortcutMap} search={search} drag={drag} setDrag={setDrag} />
4648
</BookmarkFolderCollapse>
4749
</BookmarkFolderIndent>
4850
))}
@@ -111,31 +113,109 @@ type BookmarkFolderItemsProps = {
111113
folder: BookmarkFolder
112114
shortcutMap: ShortcutMap
113115
search: string
116+
drag: Drag | undefined
117+
setDrag: Dispatch<Drag | undefined>
114118
}
115119

116-
const BookmarkFolderItems: FC<BookmarkFolderItemsProps> = ({ folder, shortcutMap, search }) => {
117-
const bookmarks = filterBookmarks(folder.bookmarks, search)
120+
const BookmarkFolderItems: FC<BookmarkFolderItemsProps> = ({ folder, shortcutMap, search, drag, setDrag }) => {
121+
const bookmarks = reorderBookmarks(drag, folder.id, filterBookmarks(folder.bookmarks, search))
118122
return (
119123
<>
120-
{bookmarks.map((b) => (
121-
<BookmarkComponent key={b.id} bookmark={b} shortcutMap={shortcutMap} />
124+
{bookmarks.map((b, index) => (
125+
<BookmarkDragDrop
126+
key={b.id}
127+
bookmark={b}
128+
position={{ folderID: folder.id, index }}
129+
drag={drag}
130+
setDrag={setDrag}
131+
>
132+
<BookmarkComponent bookmark={b} shortcutMap={shortcutMap} dragActive={drag ? true : undefined} />
133+
</BookmarkDragDrop>
122134
))}
123135
</>
124136
)
125137
}
126138

139+
const classNameOfMap = (classNameMap: { [className: string]: boolean | undefined }) =>
140+
Object.entries(classNameMap)
141+
.filter(([, enabled]) => enabled === true)
142+
.map(([className]) => className)
143+
.join(' ')
144+
145+
type BookmarkDragDropProps = {
146+
bookmark: BookmarkWithDragProps
147+
position: Position
148+
drag: Drag | undefined
149+
setDrag: Dispatch<Drag | undefined>
150+
} & PropsWithChildren
151+
152+
const BookmarkDragDrop: FC<BookmarkDragDropProps> = ({ bookmark, position, drag, setDrag, children }) => {
153+
return (
154+
<div
155+
className={classNameOfMap({
156+
Bookmark__DragDrop__From: bookmark.dragFrom,
157+
Bookmark__DragDrop__To: bookmark.dragTo,
158+
Bookmark__DragDrop__Hover: bookmark.hover,
159+
})}
160+
onDragStart={(e) => {
161+
setDrag(Drag.start(bookmark, position))
162+
e.dataTransfer.effectAllowed = 'move'
163+
e.dataTransfer.setData('text/plain', bookmark.url)
164+
}}
165+
onDragOver={(e) => {
166+
if (drag) {
167+
e.preventDefault()
168+
}
169+
}}
170+
onDragEnter={(e) => {
171+
if (drag) {
172+
e.preventDefault()
173+
setDrag(drag.enterTo(position))
174+
}
175+
}}
176+
onDragLeave={(e) => {
177+
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/relatedTarget
178+
const exitedFrom = e.target
179+
const enteredTo = e.relatedTarget
180+
if (
181+
drag &&
182+
exitedFrom instanceof HTMLElement &&
183+
exitedFrom.classList.contains('BookmarkButton') &&
184+
enteredTo instanceof HTMLElement &&
185+
enteredTo.classList.contains('BookmarkFolder')
186+
) {
187+
setDrag(drag.leave())
188+
}
189+
}}
190+
onDragEnd={() => {
191+
setDrag(undefined)
192+
}}
193+
onDrop={(e) => {
194+
e.preventDefault()
195+
if (drag) {
196+
moveBookmark(drag.bookmark, drag.calculateDestination()).catch(console.error)
197+
setDrag(undefined)
198+
}
199+
}}
200+
>
201+
{children}
202+
</div>
203+
)
204+
}
205+
127206
type BookmarkComponentProps = {
128207
bookmark: Bookmark
129208
shortcutMap: ShortcutMap
209+
dragActive: true | undefined
130210
}
131211

132-
const BookmarkComponent: FC<BookmarkComponentProps> = ({ bookmark, shortcutMap }) => {
212+
const BookmarkComponent: FC<BookmarkComponentProps> = ({ bookmark, shortcutMap, dragActive }) => {
133213
const [editingBookmark, setEditingBookmark] = useState<EditingBookmark>()
134214
const shortcutKey = shortcutMap.getByBookmarkID(bookmark.id)
135215
return (
136216
<div className="Bookmark">
137217
<Link href={bookmark.url}>
138-
<div className="BookmarkButton">
218+
<div className="BookmarkButton" data-drag-active={dragActive} draggable>
139219
<div className="BookmarkButton__Title">{bookmark.title}</div>
140220
<img className="BookmarkButton__Icon" alt="" src={faviconImage(bookmark.url)} />
141221
{shortcutKey ? <div className="BookmarkButton__Badge">{shortcutKey}</div> : null}
@@ -144,6 +224,7 @@ const BookmarkComponent: FC<BookmarkComponentProps> = ({ bookmark, shortcutMap }
144224
<a
145225
href="#Edit"
146226
className="BookmarkEditButton"
227+
data-drag-active={dragActive}
147228
onClick={(e) => {
148229
setEditingBookmark(new EditingBookmark(bookmark, shortcutKey))
149230
e.preventDefault()

src/Bookmarks/model.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export const filterBookmarks = (bookmarks: readonly Bookmark[], search: string):
2626
)
2727
}
2828

29+
export type Position = {
30+
readonly folderID: BookmarkFolderID
31+
readonly index: number
32+
}
33+
2934
export class FolderCollapse {
3035
private readonly collapsedIDs: ReadonlySet<BookmarkFolderID>
3136

src/Bookmarks/repository.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Bookmark, BookmarkFolder, BookmarkFolderID, FolderCollapse } from './model'
1+
import { Bookmark, BookmarkFolder, BookmarkFolderID, FolderCollapse, Position } from './model'
22
import { useEffect, useState } from 'react'
33
import { useChromeStorage } from '../infrastructure/chromeStorage'
44

@@ -73,6 +73,10 @@ export const removeBookmark = async (bookmark: Bookmark) => {
7373
await chrome.bookmarks.remove(bookmark.id)
7474
}
7575

76+
export const moveBookmark = async (bookmark: Bookmark, position: Position) => {
77+
await chrome.bookmarks.move(bookmark.id, { parentId: position.folderID, index: position.index })
78+
}
79+
7680
export const useFolderCollapse = (): readonly [FolderCollapse, (newSet: FolderCollapse) => void] => {
7781
const [ids, setIDs] = useChromeStorage<readonly BookmarkFolderID[]>({
7882
areaName: 'sync',

src/Bookmarks/viewmodel.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Bookmark, BookmarkFolderID, Position } from './model'
2+
3+
export class Drag {
4+
readonly bookmark: Bookmark
5+
readonly from: Position
6+
readonly to: Position
7+
readonly hover: boolean
8+
9+
private constructor(bookmark: Bookmark, from: Position, to: Position, hover: boolean) {
10+
this.bookmark = bookmark
11+
this.from = from
12+
this.to = to
13+
this.hover = hover
14+
}
15+
16+
static start(bookmark: Bookmark, from: Position) {
17+
return new Drag(bookmark, from, from, true)
18+
}
19+
20+
enterTo(to: Position) {
21+
return new Drag(this.bookmark, this.from, to, true)
22+
}
23+
24+
leave() {
25+
return new Drag(this.bookmark, this.from, this.to, false)
26+
}
27+
28+
calculateDestination(): Position {
29+
// https://stackoverflow.com/questions/13264060/chrome-bookmarks-api-using-move-to-reorder-bookmarks-in-the-same-folder
30+
if (this.from.folderID === this.to.folderID && this.from.index < this.to.index) {
31+
return { ...this.to, index: this.to.index + 1 }
32+
}
33+
return this.to
34+
}
35+
}
36+
37+
export type BookmarkWithDragProps = Bookmark & {
38+
readonly dragFrom?: true
39+
readonly dragTo?: true
40+
readonly hover?: true
41+
}
42+
43+
export const reorderBookmarks = (
44+
drag: Drag | undefined,
45+
folderID: BookmarkFolderID,
46+
bookmarks: readonly Bookmark[]
47+
): readonly BookmarkWithDragProps[] => {
48+
if (!drag) {
49+
return bookmarks
50+
}
51+
52+
if (folderID === drag.from.folderID && drag.from.folderID === drag.to.folderID) {
53+
// move the bookmark in the folder
54+
const r: BookmarkWithDragProps[] = [...bookmarks]
55+
r.splice(drag.from.index, 1)
56+
r.splice(drag.to.index, 0, { ...drag.bookmark, dragFrom: true, dragTo: true, hover: drag.hover || undefined })
57+
return r
58+
}
59+
60+
// when move across the folder, keep the element to receive the dragEnd event
61+
if (folderID === drag.from.folderID) {
62+
const r: BookmarkWithDragProps[] = [...bookmarks]
63+
r.splice(drag.from.index, 1)
64+
r.push({ ...drag.bookmark, dragFrom: true })
65+
return r
66+
}
67+
68+
if (folderID === drag.to.folderID) {
69+
const r: BookmarkWithDragProps[] = [...bookmarks]
70+
r.splice(drag.to.index, 0, { ...drag.bookmark, dragTo: true, hover: drag.hover || undefined })
71+
return r
72+
}
73+
74+
return bookmarks
75+
}

0 commit comments

Comments
 (0)