first commit
This commit is contained in:
227
src/components/Space.tsx
Normal file
227
src/components/Space.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { currentSpace, currentSpaceId, createNote, editingNoteId, clearSelection, deleteSelectedNotes, selectNotesInRect, refreshExistingSpaces } from '../store'
|
||||
import { Note } from './Note'
|
||||
import { NavigationConsole } from './NavigationConsole'
|
||||
import { WelcomeModal, shouldShowWelcome } from './WelcomeModal'
|
||||
|
||||
export function Space() {
|
||||
const $currentSpace = useStore(currentSpace)
|
||||
const $currentSpaceId = useStore(currentSpaceId)
|
||||
const $editingNoteId = useStore(editingNoteId)
|
||||
|
||||
const [isSelecting, setIsSelecting] = useState(false)
|
||||
const [selectionStart, setSelectionStart] = useState({ x: 0, y: 0 })
|
||||
const [selectionEnd, setSelectionEnd] = useState({ x: 0, y: 0 })
|
||||
const [justFinishedSelection, setJustFinishedSelection] = useState(false)
|
||||
const [showNavigationConsole, setShowNavigationConsole] = useState(false)
|
||||
const [showWelcomeModal, setShowWelcomeModal] = useState(false)
|
||||
|
||||
// Show welcome modal on first load and refresh existing spaces
|
||||
useEffect(() => {
|
||||
if (shouldShowWelcome()) {
|
||||
setShowWelcomeModal(true)
|
||||
}
|
||||
// Initialize existing spaces list
|
||||
refreshExistingSpaces()
|
||||
}, [])
|
||||
|
||||
// Click in empty space - clear selection (but not if we just finished a rectangle selection)
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (justFinishedSelection) {
|
||||
setJustFinishedSelection(false)
|
||||
return
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
// Double-click in empty space - create note
|
||||
const handleDoubleClick = async (e: React.MouseEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
const noteId = await createNote(x, y)
|
||||
if (noteId) {
|
||||
editingNoteId.set(noteId)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag in empty space - rectangle selection
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.detail === 2) return // Ignore double-click
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
setIsSelecting(true)
|
||||
setSelectionStart({ x, y })
|
||||
setSelectionEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isSelecting) return
|
||||
|
||||
const spaceElement = document.querySelector('[data-space-canvas]') as HTMLElement
|
||||
if (!spaceElement) return
|
||||
|
||||
const rect = spaceElement.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
setSelectionEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!isSelecting) return
|
||||
|
||||
setIsSelecting(false)
|
||||
|
||||
// Get final mouse position directly from event
|
||||
const spaceElement = document.querySelector('[data-space-canvas]') as HTMLElement
|
||||
if (!spaceElement) return
|
||||
|
||||
const rect = spaceElement.getBoundingClientRect()
|
||||
const finalX = e.clientX - rect.left
|
||||
const finalY = e.clientY - rect.top
|
||||
|
||||
// Calculate selection rectangle using actual coordinates
|
||||
const minX = Math.min(selectionStart.x, finalX)
|
||||
const maxX = Math.max(selectionStart.x, finalX)
|
||||
const minY = Math.min(selectionStart.y, finalY)
|
||||
const maxY = Math.max(selectionStart.y, finalY)
|
||||
|
||||
const width = Math.abs(maxX - minX)
|
||||
const height = Math.abs(maxY - minY)
|
||||
|
||||
// Only select if rectangle is big enough
|
||||
if (width > 5 || height > 5) {
|
||||
selectNotesInRect(minX, minY, maxX, maxY)
|
||||
setJustFinishedSelection(true)
|
||||
} else {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelecting) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isSelecting, selectionStart])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle shortcuts if editing a note, navigation console or welcome modal is open
|
||||
if ($editingNoteId || showNavigationConsole || showWelcomeModal) return
|
||||
|
||||
if (e.key === ':' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setShowNavigationConsole(true)
|
||||
} else if (e.key === '?' || (e.key === ':' && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
setShowWelcomeModal(true)
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
deleteSelectedNotes()
|
||||
} else if (e.key === 'Escape') {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [$editingNoteId, showNavigationConsole, showWelcomeModal])
|
||||
|
||||
if (!$currentSpace) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: '#000',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectionRect = {
|
||||
left: Math.min(selectionStart.x, selectionEnd.x),
|
||||
top: Math.min(selectionStart.y, selectionEnd.y),
|
||||
width: Math.abs(selectionEnd.x - selectionStart.x),
|
||||
height: Math.abs(selectionEnd.y - selectionStart.y)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-space-canvas
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: '#000',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: isSelecting ? 'crosshair' : 'default'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
color: '#666',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
Space: {$currentSpaceId}
|
||||
</div>
|
||||
|
||||
{$currentSpace.notes.map((note) => (
|
||||
<Note key={note.id} note={note} />
|
||||
))}
|
||||
|
||||
{isSelecting && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: selectionRect.left,
|
||||
top: selectionRect.top,
|
||||
width: selectionRect.width,
|
||||
height: selectionRect.height,
|
||||
border: '1px dashed #555',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NavigationConsole
|
||||
isOpen={showNavigationConsole}
|
||||
onClose={() => setShowNavigationConsole(false)}
|
||||
/>
|
||||
|
||||
<WelcomeModal
|
||||
isOpen={showWelcomeModal}
|
||||
onClose={() => setShowWelcomeModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user