Make MyNote draggable
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s

This commit is contained in:
Daniel 2026-05-12 18:38:57 +02:00
parent 9f97218f39
commit 55713902ed
2 changed files with 135 additions and 5 deletions

View file

@ -1,11 +1,21 @@
import { useEffect, useRef, useState } from 'react'
import api from '../api/client'
const TAB_POSITION_KEY = 'pedshub_mynote_tab_position'
function formatSavedAt(value) {
if (!value) return 'Not saved yet'
return `Saved ${new Date(value).toLocaleString()}`
}
function clampPosition(x, y, width, height) {
const margin = 8
return {
x: Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - width - margin)),
y: Math.min(Math.max(margin, y), Math.max(margin, window.innerHeight - height - margin)),
}
}
export default function MyNote({ variant = 'tab' }) {
const [open, setOpen] = useState(false)
const [content, setContent] = useState('')
@ -13,8 +23,13 @@ export default function MyNote({ variant = 'tab' }) {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [position, setPosition] = useState(null)
const [dragging, setDragging] = useState(false)
const tabRef = useRef(null)
const saveRef = useRef(null)
const loadedRef = useRef(false)
const dragRef = useRef(null)
const draggedRef = useRef(false)
const loadNote = async () => {
if (loadedRef.current) return
@ -36,6 +51,44 @@ export default function MyNote({ variant = 'tab' }) {
if (variant === 'card') loadNote()
}, [variant])
useEffect(() => {
if (variant !== 'tab') return
const rect = tabRef.current?.getBoundingClientRect()
let next = null
try {
const saved = JSON.parse(localStorage.getItem(TAB_POSITION_KEY) || 'null')
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
next = clampPosition(saved.x, saved.y, rect?.width || 120, rect?.height || 48)
}
} catch {
next = null
}
if (!next && rect) next = { x: rect.left, y: rect.top }
if (next) setPosition(next)
const handleResize = () => {
setPosition(current => {
if (!current) return current
const resizedRect = tabRef.current?.getBoundingClientRect()
const clamped = clampPosition(current.x, current.y, resizedRect?.width || 120, resizedRect?.height || 48)
localStorage.setItem(TAB_POSITION_KEY, JSON.stringify(clamped))
return clamped
})
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [variant])
useEffect(() => {
if (variant !== 'tab') return
setPosition(current => {
if (!current) return current
const rect = tabRef.current?.getBoundingClientRect()
if (!rect) return current
return clampPosition(current.x, current.y, rect.width, rect.height)
})
}, [open, variant])
useEffect(() => {
if (!loadedRef.current) return
clearTimeout(saveRef.current)
@ -59,16 +112,73 @@ export default function MyNote({ variant = 'tab' }) {
loadNote()
}
const startDrag = event => {
if (variant !== 'tab' || event.button !== 0) return
if (event.target.closest('button, textarea') && event.currentTarget !== event.target) return
const rect = tabRef.current?.getBoundingClientRect()
if (!rect) return
dragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
startX: event.clientX,
startY: event.clientY,
moved: false,
position: { x: rect.left, y: rect.top },
}
event.currentTarget.setPointerCapture?.(event.pointerId)
}
const moveDrag = event => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
const rect = tabRef.current?.getBoundingClientRect()
if (!rect) return
const next = clampPosition(event.clientX - drag.offsetX, event.clientY - drag.offsetY, rect.width, rect.height)
drag.position = next
if (Math.abs(event.clientX - drag.startX) > 4 || Math.abs(event.clientY - drag.startY) > 4) {
drag.moved = true
setDragging(true)
}
setPosition(next)
event.preventDefault()
}
const stopDrag = event => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
if (drag.moved) {
localStorage.setItem(TAB_POSITION_KEY, JSON.stringify(drag.position))
}
draggedRef.current = drag.moved
dragRef.current = null
setDragging(false)
setTimeout(() => {
draggedRef.current = false
}, 0)
}
const openFromTabButton = event => {
if (draggedRef.current) {
event.preventDefault()
return
}
openNote()
}
const preview = content.trim()
? content.trim().slice(0, 150) + (content.trim().length > 150 ? '...' : '')
: 'Your single study note will appear here once you start writing during quizzes.'
const editor = (
<div className="mynote-editor">
<div className="mynote-editor-header">
<div
className={`mynote-editor-header ${variant === 'tab' ? 'mynote-drag-handle' : ''}`}
onPointerDown={variant === 'tab' ? startDrag : undefined}
>
<div>
<div className="mynote-title">MyNote</div>
<div className="mynote-status">{saving ? 'Saving...' : formatSavedAt(updatedAt)}</div>
<div className="mynote-status">{variant === 'tab' ? 'Drag this header to move. ' : ''}{saving ? 'Saving...' : formatSavedAt(updatedAt)}</div>
</div>
{variant === 'tab' && <button className="mynote-close" type="button" onClick={() => setOpen(false)}>Collapse</button>}
</div>
@ -105,9 +215,16 @@ export default function MyNote({ variant = 'tab' }) {
}
return (
<div className={`mynote-tab ${open ? 'open' : ''}`}>
<div
ref={tabRef}
className={`mynote-tab ${open ? 'open' : ''} ${dragging ? 'dragging' : ''}`}
style={position ? { left: position.x, top: position.y, right: 'auto', bottom: 'auto' } : undefined}
onPointerMove={moveDrag}
onPointerUp={stopDrag}
onPointerCancel={stopDrag}
>
{!open ? (
<button className="mynote-tab-button" type="button" onClick={openNote}>MyNote</button>
<button className="mynote-tab-button" type="button" onPointerDown={startDrag} onClick={openFromTabButton}>MyNote</button>
) : editor}
</div>
)

View file

@ -304,6 +304,9 @@ body {
bottom: 18px;
z-index: 45;
}
.mynote-tab.dragging {
user-select: none;
}
.mynote-tab-button {
border: 1px solid rgba(37, 99, 235, 0.22);
border-radius: 999px;
@ -311,9 +314,10 @@ body {
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white;
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.28);
cursor: pointer;
cursor: grab;
font-weight: 800;
letter-spacing: -0.02em;
touch-action: none;
}
.mynote-editor {
width: min(420px, calc(100vw - 28px));
@ -333,6 +337,15 @@ body {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
}
.mynote-drag-handle {
cursor: grab;
touch-action: none;
user-select: none;
}
.mynote-tab.dragging .mynote-tab-button,
.mynote-tab.dragging .mynote-drag-handle {
cursor: grabbing;
}
.mynote-title {
font-weight: 850;
font-size: 1rem;