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 { useEffect, useRef, useState } from 'react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
|
const TAB_POSITION_KEY = 'pedshub_mynote_tab_position'
|
||||||
|
|
||||||
function formatSavedAt(value) {
|
function formatSavedAt(value) {
|
||||||
if (!value) return 'Not saved yet'
|
if (!value) return 'Not saved yet'
|
||||||
return `Saved ${new Date(value).toLocaleString()}`
|
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' }) {
|
export default function MyNote({ variant = 'tab' }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
|
|
@ -13,8 +23,13 @@ export default function MyNote({ variant = 'tab' }) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [position, setPosition] = useState(null)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const tabRef = useRef(null)
|
||||||
const saveRef = useRef(null)
|
const saveRef = useRef(null)
|
||||||
const loadedRef = useRef(false)
|
const loadedRef = useRef(false)
|
||||||
|
const dragRef = useRef(null)
|
||||||
|
const draggedRef = useRef(false)
|
||||||
|
|
||||||
const loadNote = async () => {
|
const loadNote = async () => {
|
||||||
if (loadedRef.current) return
|
if (loadedRef.current) return
|
||||||
|
|
@ -36,6 +51,44 @@ export default function MyNote({ variant = 'tab' }) {
|
||||||
if (variant === 'card') loadNote()
|
if (variant === 'card') loadNote()
|
||||||
}, [variant])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!loadedRef.current) return
|
if (!loadedRef.current) return
|
||||||
clearTimeout(saveRef.current)
|
clearTimeout(saveRef.current)
|
||||||
|
|
@ -59,16 +112,73 @@ export default function MyNote({ variant = 'tab' }) {
|
||||||
loadNote()
|
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()
|
const preview = content.trim()
|
||||||
? content.trim().slice(0, 150) + (content.trim().length > 150 ? '...' : '')
|
? content.trim().slice(0, 150) + (content.trim().length > 150 ? '...' : '')
|
||||||
: 'Your single study note will appear here once you start writing during quizzes.'
|
: 'Your single study note will appear here once you start writing during quizzes.'
|
||||||
|
|
||||||
const editor = (
|
const editor = (
|
||||||
<div className="mynote-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>
|
||||||
<div className="mynote-title">MyNote</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>
|
</div>
|
||||||
{variant === 'tab' && <button className="mynote-close" type="button" onClick={() => setOpen(false)}>Collapse</button>}
|
{variant === 'tab' && <button className="mynote-close" type="button" onClick={() => setOpen(false)}>Collapse</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,9 +215,16 @@ export default function MyNote({ variant = 'tab' }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 ? (
|
{!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}
|
) : editor}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,9 @@ body {
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
z-index: 45;
|
z-index: 45;
|
||||||
}
|
}
|
||||||
|
.mynote-tab.dragging {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
.mynote-tab-button {
|
.mynote-tab-button {
|
||||||
border: 1px solid rgba(37, 99, 235, 0.22);
|
border: 1px solid rgba(37, 99, 235, 0.22);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
@ -311,9 +314,10 @@ body {
|
||||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.28);
|
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.28);
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
.mynote-editor {
|
.mynote-editor {
|
||||||
width: min(420px, calc(100vw - 28px));
|
width: min(420px, calc(100vw - 28px));
|
||||||
|
|
@ -333,6 +337,15 @@ body {
|
||||||
padding: 14px 16px 10px;
|
padding: 14px 16px 10px;
|
||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.mynote-title {
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue