Make MyNote draggable
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
This commit is contained in:
parent
9f97218f39
commit
55713902ed
2 changed files with 135 additions and 5 deletions
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue