Files
palace/src/components/Space.tsx

240 lines
6.8 KiB
TypeScript

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 = () => {
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)
}
}
return undefined
}, [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>
)
}