Skip to content

Commit bb77935

Browse files
authored
Merge pull request #10 from pheuberger/claude/research-undo-implementation-e8Aky
2 parents 4faabd9 + 40ecc80 commit bb77935

5 files changed

Lines changed: 268 additions & 41 deletions

File tree

src/components/bookmarks/BookmarkList.jsx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
2-
import { useYjs } from '../../hooks/useYjs'
2+
import { useYjs, undo, redo } from '../../hooks/useYjs'
33
import { useSearch, useDebounce } from '../../hooks/useSearch'
44
import { useHotkeys } from '../../hooks/useHotkeys'
5+
import { useToast } from '../../hooks/useToast'
56
import { BookmarkItem } from './BookmarkItem'
67
import { BookmarkInlineCard } from './BookmarkInlineCard'
78
import { InboxView } from './InboxView'
@@ -11,6 +12,7 @@ import { SettingsView } from '../ui/SettingsView'
1112
import { HelpModal } from '../ui/HelpModal'
1213
import { Modal } from '../ui/Modal'
1314
import { Button } from '../ui/Button'
15+
import { ToastContainer } from '../ui/Toast'
1416
import { PackageOpen } from '../ui/Icons'
1517
import {
1618
getAllBookmarks,
@@ -20,6 +22,7 @@ import {
2022
export function BookmarkList() {
2123
const { bookmarks: bookmarksMap, synced } = useYjs()
2224
const [bookmarks, setBookmarks] = useState([])
25+
const { toasts, addToast, removeToast } = useToast()
2326
const [currentView, setCurrentView] = useState('bookmarks')
2427

2528
useEffect(() => {
@@ -189,14 +192,35 @@ export function BookmarkList() {
189192

190193
const confirmDelete = useCallback(() => {
191194
if (deleteConfirmBookmark) {
195+
const bookmarkTitle = deleteConfirmBookmark.title
192196
try {
193197
deleteBookmark(deleteConfirmBookmark._id)
198+
addToast({
199+
message: `Deleted "${bookmarkTitle}"`,
200+
action: () => {
201+
undo()
202+
},
203+
actionLabel: 'Undo',
204+
duration: 5000,
205+
})
194206
} catch (error) {
195207
console.error('Failed to delete bookmark:', error)
196208
}
197209
setDeleteConfirmBookmark(null)
198210
}
199-
}, [deleteConfirmBookmark])
211+
}, [deleteConfirmBookmark, addToast])
212+
213+
const handleUndo = useCallback(() => {
214+
if (undo()) {
215+
addToast({ message: 'Undone', duration: 2000 })
216+
}
217+
}, [addToast])
218+
219+
const handleRedo = useCallback(() => {
220+
if (redo()) {
221+
addToast({ message: 'Redone', duration: 2000 })
222+
}
223+
}, [addToast])
200224

201225
useEffect(() => {
202226
setSelectedIndex(-1)
@@ -228,6 +252,8 @@ export function BookmarkList() {
228252
'd': promptDeleteSelected,
229253
'mod+k': focusSearch,
230254
'q': exitInbox,
255+
'mod+z': handleUndo,
256+
'mod+shift+z': handleRedo,
231257
})
232258

233259
const handleAddNew = openNewBookmarkForm
@@ -241,14 +267,24 @@ export function BookmarkList() {
241267
setEditingBookmarkId(null)
242268
}, [])
243269

244-
const handleDelete = (bookmarkId) => {
270+
const handleDelete = useCallback((bookmarkId) => {
271+
const bookmark = bookmarks.find(b => b._id === bookmarkId)
272+
const bookmarkTitle = bookmark?.title || 'Bookmark'
245273
try {
246274
deleteBookmark(bookmarkId)
275+
addToast({
276+
message: `Deleted "${bookmarkTitle}"`,
277+
action: () => {
278+
undo()
279+
},
280+
actionLabel: 'Undo',
281+
duration: 5000,
282+
})
247283
} catch (error) {
248284
console.error('Failed to delete bookmark:', error)
249-
alert('Failed to delete bookmark: ' + error.message)
285+
addToast({ message: 'Failed to delete bookmark', duration: 3000 })
250286
}
251-
}
287+
}, [bookmarks, addToast])
252288

253289
const handleTagClick = (tag) => {
254290
setFilterView('tag')
@@ -393,7 +429,7 @@ export function BookmarkList() {
393429
title="Delete bookmark?"
394430
>
395431
<p className="text-sm text-muted-foreground mb-4">
396-
This will permanently delete "{deleteConfirmBookmark?.title}".
432+
Delete "{deleteConfirmBookmark?.title}"? You can undo this action.
397433
</p>
398434
<div className="flex justify-end gap-2">
399435
<Button variant="ghost" onClick={() => setDeleteConfirmBookmark(null)}>
@@ -404,6 +440,8 @@ export function BookmarkList() {
404440
</Button>
405441
</div>
406442
</Modal>
443+
444+
<ToastContainer toasts={toasts} onRemove={removeToast} />
407445
</div>
408446
)
409447
}

src/components/ui/Toast.jsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useEffect, useState, useCallback } from 'react'
2+
import { X } from 'lucide-react'
3+
import { cn } from '@/utils/cn'
4+
5+
export function Toast({ message, action, actionLabel = 'Undo', duration = 5000, onClose }) {
6+
const [isVisible, setIsVisible] = useState(true)
7+
const [isLeaving, setIsLeaving] = useState(false)
8+
9+
const handleClose = useCallback(() => {
10+
setIsLeaving(true)
11+
setTimeout(() => {
12+
setIsVisible(false)
13+
onClose?.()
14+
}, 150)
15+
}, [onClose])
16+
17+
const handleAction = useCallback(() => {
18+
action?.()
19+
handleClose()
20+
}, [action, handleClose])
21+
22+
useEffect(() => {
23+
if (duration > 0) {
24+
const timer = setTimeout(handleClose, duration)
25+
return () => clearTimeout(timer)
26+
}
27+
}, [duration, handleClose])
28+
29+
if (!isVisible) return null
30+
31+
return (
32+
<div
33+
className={cn(
34+
'fixed bottom-4 left-1/2 -translate-x-1/2 z-50',
35+
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg',
36+
'bg-card border border-border text-foreground',
37+
'transition-all duration-150',
38+
isLeaving ? 'opacity-0 translate-y-2' : 'opacity-100 translate-y-0'
39+
)}
40+
>
41+
<span className="text-sm">{message}</span>
42+
{action && (
43+
<button
44+
onClick={handleAction}
45+
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
46+
>
47+
{actionLabel}
48+
</button>
49+
)}
50+
<button
51+
onClick={handleClose}
52+
className="ml-1 p-1 rounded hover:bg-muted transition-colors"
53+
aria-label="Dismiss"
54+
>
55+
<X className="w-4 h-4 text-muted-foreground" />
56+
</button>
57+
</div>
58+
)
59+
}
60+
61+
// Toast container that manages multiple toasts
62+
export function ToastContainer({ toasts, onRemove }) {
63+
return (
64+
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2">
65+
{toasts.map((toast) => (
66+
<Toast
67+
key={toast.id}
68+
message={toast.message}
69+
action={toast.action}
70+
actionLabel={toast.actionLabel}
71+
duration={toast.duration}
72+
onClose={() => onRemove(toast.id)}
73+
/>
74+
))}
75+
</div>
76+
)
77+
}

src/hooks/useToast.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useState, useCallback } from 'react'
2+
3+
let toastId = 0
4+
5+
export function useToast() {
6+
const [toasts, setToasts] = useState([])
7+
8+
const addToast = useCallback(({ message, action, actionLabel, duration = 5000 }) => {
9+
const id = ++toastId
10+
setToasts((prev) => [...prev, { id, message, action, actionLabel, duration }])
11+
return id
12+
}, [])
13+
14+
const removeToast = useCallback((id) => {
15+
setToasts((prev) => prev.filter((t) => t.id !== id))
16+
}, [])
17+
18+
const clearToasts = useCallback(() => {
19+
setToasts([])
20+
}, [])
21+
22+
return {
23+
toasts,
24+
addToast,
25+
removeToast,
26+
clearToasts,
27+
}
28+
}

src/hooks/useYjs.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,55 @@ let webrtcProvider = null
1111
let indexeddbProvider = null
1212
let awareness = null
1313
let webrtcProviderListeners = []
14+
let undoManager = null
15+
let undoManagerListeners = []
16+
17+
// Origin used for local bookmark operations (tracked by UndoManager)
18+
export const LOCAL_ORIGIN = 'local'
1419

1520
function notifyWebrtcListeners() {
1621
webrtcProviderListeners.forEach(cb => cb(webrtcProvider))
1722
}
1823

24+
function notifyUndoManagerListeners(event) {
25+
undoManagerListeners.forEach(cb => cb(event))
26+
}
27+
28+
export function subscribeToUndoManager(callback) {
29+
undoManagerListeners.push(callback)
30+
return () => {
31+
undoManagerListeners = undoManagerListeners.filter(cb => cb !== callback)
32+
}
33+
}
34+
35+
export function getUndoManager() {
36+
return undoManager
37+
}
38+
39+
export function undo() {
40+
if (undoManager && undoManager.canUndo()) {
41+
undoManager.undo()
42+
return true
43+
}
44+
return false
45+
}
46+
47+
export function redo() {
48+
if (undoManager && undoManager.canRedo()) {
49+
undoManager.redo()
50+
return true
51+
}
52+
return false
53+
}
54+
55+
export function canUndo() {
56+
return undoManager?.canUndo() ?? false
57+
}
58+
59+
export function canRedo() {
60+
return undoManager?.canRedo() ?? false
61+
}
62+
1963
export function subscribeToWebrtcProvider(callback) {
2064
webrtcProviderListeners.push(callback)
2165
callback(webrtcProvider)
@@ -46,6 +90,25 @@ function initializeYjs(roomName = 'hypermark') {
4690
console.log('[Yjs] Initializing empty data structures')
4791
}
4892

93+
// Initialize UndoManager for bookmarks
94+
const bookmarksMap = ydoc.getMap('bookmarks')
95+
undoManager = new Y.UndoManager(bookmarksMap, {
96+
trackedOrigins: new Set([LOCAL_ORIGIN]),
97+
captureTimeout: 500, // Group rapid changes within 500ms
98+
})
99+
100+
undoManager.on('stack-item-added', (event) => {
101+
console.log('[Yjs] Undo stack item added:', event.type)
102+
notifyUndoManagerListeners({ type: 'stack-item-added', ...event })
103+
})
104+
105+
undoManager.on('stack-item-popped', (event) => {
106+
console.log('[Yjs] Undo stack item popped:', event.type)
107+
notifyUndoManagerListeners({ type: 'stack-item-popped', ...event })
108+
})
109+
110+
console.log('[Yjs] UndoManager initialized')
111+
49112
return ydoc
50113
}
51114

0 commit comments

Comments
 (0)