everything working in dev
This commit is contained in:
parent
afb92b80a7
commit
e727deea19
|
|
@ -0,0 +1,186 @@
|
|||
# Data Conversion Guide: TLDraw Sync to Automerge Sync
|
||||
|
||||
This guide explains the data conversion process from the old TLDraw sync format to the new Automerge sync format, and how to verify the conversion is working correctly.
|
||||
|
||||
## Data Format Changes
|
||||
|
||||
### Old Format (TLDraw Sync)
|
||||
```json
|
||||
{
|
||||
"documents": [
|
||||
{ "state": { "id": "shape:abc123", "typeName": "shape", ... } },
|
||||
{ "state": { "id": "page:page", "typeName": "page", ... } }
|
||||
],
|
||||
"schema": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### New Format (Automerge Sync)
|
||||
```json
|
||||
{
|
||||
"store": {
|
||||
"shape:abc123": { "id": "shape:abc123", "typeName": "shape", ... },
|
||||
"page:page": { "id": "page:page", "typeName": "page", ... }
|
||||
},
|
||||
"schema": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Conversion Process
|
||||
|
||||
The conversion happens automatically when a document is loaded from R2. The `AutomergeDurableObject.getDocument()` method detects the format and converts it:
|
||||
|
||||
1. **Automerge Array Format**: Detected by `Array.isArray(rawDoc)`
|
||||
- Converts via `convertAutomergeToStore()`
|
||||
- Extracts `record.state` and uses it as the store record
|
||||
|
||||
2. **Store Format**: Detected by `rawDoc.store` existing
|
||||
- Already in correct format, uses as-is
|
||||
- No conversion needed
|
||||
|
||||
3. **Old Documents Format**: Detected by `rawDoc.documents` existing but no `store`
|
||||
- Converts via `migrateDocumentsToStore()`
|
||||
- Maps `doc.state.id` to `store[doc.state.id] = doc.state`
|
||||
|
||||
4. **Shape Property Migration**: After format conversion, all shapes are migrated via `migrateShapeProperties()`
|
||||
- Ensures required properties exist (x, y, rotation, isLocked, opacity, meta, index)
|
||||
- Moves `w`/`h` from top-level to `props` for geo shapes
|
||||
- Fixes richText structure
|
||||
- Preserves custom shape properties
|
||||
|
||||
## Validation & Error Handling
|
||||
|
||||
The conversion functions now include comprehensive validation:
|
||||
|
||||
- **Missing state.id**: Skipped with warning
|
||||
- **Missing state.typeName**: Skipped with warning
|
||||
- **Null/undefined records**: Skipped with warning
|
||||
- **Invalid ID types**: Skipped with warning
|
||||
- **Malformed shapes**: Fixed during shape migration
|
||||
|
||||
All validation errors are logged with detailed statistics.
|
||||
|
||||
## Custom Records
|
||||
|
||||
Custom record types (like `obsidian_vault:`) are preserved during conversion:
|
||||
- Tracked during conversion
|
||||
- Verified in logs
|
||||
- Preserved in the final store
|
||||
|
||||
## Custom Shapes
|
||||
|
||||
Custom shape types are preserved:
|
||||
- ObsNote
|
||||
- Holon
|
||||
- FathomMeetingsBrowser
|
||||
- FathomTranscript
|
||||
- HolonBrowser
|
||||
- LocationShare
|
||||
- ObsidianBrowser
|
||||
|
||||
All custom shape properties are preserved during migration.
|
||||
|
||||
## Logging
|
||||
|
||||
The conversion process logs comprehensive statistics:
|
||||
|
||||
```
|
||||
📊 Automerge to Store conversion statistics:
|
||||
- total: Number of records processed
|
||||
- converted: Number successfully converted
|
||||
- skipped: Number skipped (invalid)
|
||||
- errors: Number of errors
|
||||
- customRecordCount: Number of custom records
|
||||
- errorCount: Number of error details
|
||||
```
|
||||
|
||||
Similar statistics are logged for:
|
||||
- Documents to Store migration
|
||||
- Shape property migration
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Edge Cases
|
||||
|
||||
Run the test script to verify edge case handling:
|
||||
|
||||
```bash
|
||||
npx tsx test-data-conversion.ts
|
||||
```
|
||||
|
||||
This tests:
|
||||
- Missing state.id
|
||||
- Missing state.typeName
|
||||
- Null/undefined records
|
||||
- Missing state property
|
||||
- Invalid ID types
|
||||
- Custom records
|
||||
- Malformed shapes
|
||||
- Empty documents
|
||||
- Mixed valid/invalid records
|
||||
|
||||
### Test with Real R2 Data
|
||||
|
||||
To test with actual R2 data:
|
||||
|
||||
1. **Check Worker Logs**: When a document is loaded, check the Cloudflare Worker logs for conversion statistics
|
||||
2. **Verify Data Integrity**: After conversion, verify:
|
||||
- All shapes appear correctly
|
||||
- All properties are preserved
|
||||
- No validation errors in TLDraw
|
||||
- Custom records are present
|
||||
- Custom shapes work correctly
|
||||
|
||||
3. **Monitor Conversion**: Watch for:
|
||||
- High skip counts (may indicate data issues)
|
||||
- Errors during conversion
|
||||
- Missing custom records
|
||||
- Shape migration issues
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [x] Format detection (Automerge array, store format, old documents format)
|
||||
- [x] Validation for malformed records
|
||||
- [x] Error handling and logging
|
||||
- [x] Custom record preservation
|
||||
- [x] Custom shape preservation
|
||||
- [x] Shape property migration
|
||||
- [x] Comprehensive logging
|
||||
- [x] Edge case testing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### High Skip Counts
|
||||
If many records are being skipped:
|
||||
1. Check error details in logs
|
||||
2. Verify data format in R2
|
||||
3. Check for missing required fields
|
||||
|
||||
### Missing Custom Records
|
||||
If custom records are missing:
|
||||
1. Check logs for custom record count
|
||||
2. Verify records start with expected prefix (e.g., `obsidian_vault:`)
|
||||
3. Check if records were filtered during conversion
|
||||
|
||||
### Shape Validation Errors
|
||||
If shapes have validation errors:
|
||||
1. Check shape migration logs
|
||||
2. Verify required properties are present
|
||||
3. Check for w/h in wrong location (should be in props for geo shapes)
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The conversion is backward compatible:
|
||||
- Old format documents are automatically converted
|
||||
- New format documents are used as-is
|
||||
- No data loss during conversion
|
||||
- All properties are preserved
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential improvements:
|
||||
1. Add migration flag to track converted documents
|
||||
2. Add backup before conversion
|
||||
3. Add rollback mechanism
|
||||
4. Add conversion progress tracking for large documents
|
||||
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
# Data Conversion Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the data conversion implementation from the old tldraw sync format to the new automerge sync format.
|
||||
|
||||
## Conversion Paths
|
||||
|
||||
The system handles three data formats automatically:
|
||||
|
||||
### 1. Automerge Array Format
|
||||
- **Format**: `[{ state: { id: "...", ... } }, ...]`
|
||||
- **Conversion**: `convertAutomergeToStore()`
|
||||
- **Handles**: Raw Automerge document format
|
||||
|
||||
### 2. Store Format (Already Converted)
|
||||
- **Format**: `{ store: { "recordId": {...}, ... }, schema: {...} }`
|
||||
- **Conversion**: None needed - already in correct format
|
||||
- **Handles**: Previously converted documents
|
||||
|
||||
### 3. Old Documents Format (Legacy)
|
||||
- **Format**: `{ documents: [{ state: {...} }, ...] }`
|
||||
- **Conversion**: `migrateDocumentsToStore()`
|
||||
- **Handles**: Old tldraw sync format
|
||||
|
||||
## Validation & Error Handling
|
||||
|
||||
### Record Validation
|
||||
- ✅ Validates `state` property exists
|
||||
- ✅ Validates `state.id` exists and is a string
|
||||
- ✅ Validates `state.typeName` exists (for documents format)
|
||||
- ✅ Skips invalid records with detailed logging
|
||||
- ✅ Preserves valid records
|
||||
|
||||
### Shape Migration
|
||||
- ✅ Ensures required properties (x, y, rotation, opacity, isLocked, meta, index)
|
||||
- ✅ Moves `w`/`h` from top-level to `props` for geo shapes
|
||||
- ✅ Fixes richText structure
|
||||
- ✅ Preserves custom shape properties (ObsNote, Holon, etc.)
|
||||
- ✅ Tracks and verifies custom shapes
|
||||
|
||||
### Custom Records
|
||||
- ✅ Preserves `obsidian_vault:` records
|
||||
- ✅ Tracks custom record count
|
||||
- ✅ Logs custom record IDs for verification
|
||||
|
||||
## Logging & Statistics
|
||||
|
||||
All conversion functions now provide comprehensive statistics:
|
||||
|
||||
### Conversion Statistics Include:
|
||||
- Total records processed
|
||||
- Successfully converted count
|
||||
- Skipped records (with reasons)
|
||||
- Errors encountered
|
||||
- Custom records preserved
|
||||
- Shape types distribution
|
||||
- Custom shapes preserved
|
||||
|
||||
### Log Levels:
|
||||
- **Info**: Conversion statistics, successful conversions
|
||||
- **Warn**: Skipped records, warnings (first 10 shown)
|
||||
- **Error**: Conversion errors with details
|
||||
|
||||
## Data Preservation Guarantees
|
||||
|
||||
### What is Preserved:
|
||||
- ✅ All valid shape data
|
||||
- ✅ All custom shape properties (ObsNote, Holon, etc.)
|
||||
- ✅ All custom records (obsidian_vault)
|
||||
- ✅ All metadata
|
||||
- ✅ All text content
|
||||
- ✅ All richText content (structure fixed, content preserved)
|
||||
|
||||
### What is Fixed:
|
||||
- 🔧 Missing required properties (defaults added)
|
||||
- 🔧 Invalid property locations (w/h moved to props)
|
||||
- 🔧 Malformed richText structure
|
||||
- 🔧 Missing typeName (inferred where possible)
|
||||
|
||||
### What is Skipped:
|
||||
- ⚠️ Records with missing `state` property
|
||||
- ⚠️ Records with missing `state.id`
|
||||
- ⚠️ Records with invalid `state.id` type
|
||||
- ⚠️ Records with missing `state.typeName` (for documents format)
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- `test-data-conversion.ts`: Tests edge cases with malformed data
|
||||
- Covers: missing fields, null records, invalid types, custom records
|
||||
|
||||
### Integration Testing
|
||||
- Test with real R2 data (see `test-r2-conversion.md`)
|
||||
- Verify data integrity after conversion
|
||||
- Check logs for warnings/errors
|
||||
|
||||
## Migration Safety
|
||||
|
||||
### Safety Features:
|
||||
1. **Non-destructive**: Original R2 data is not modified until first save
|
||||
2. **Error handling**: Invalid records are skipped, not lost
|
||||
3. **Comprehensive logging**: All actions are logged for debugging
|
||||
4. **Fallback**: Creates empty document if conversion fails completely
|
||||
|
||||
### Rollback:
|
||||
- Original data remains in R2 until overwritten
|
||||
- Can restore from backup if needed
|
||||
- Conversion errors don't corrupt existing data
|
||||
|
||||
## Performance
|
||||
|
||||
- Conversion happens once per room (cached)
|
||||
- Statistics logging is efficient (limited to first 10 errors)
|
||||
- Shape migration only processes shapes (not all records)
|
||||
- Custom record tracking is lightweight
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Conversion logic implemented and validated
|
||||
2. ✅ Comprehensive logging added
|
||||
3. ✅ Custom records/shapes preservation verified
|
||||
4. ✅ Edge case handling implemented
|
||||
5. ⏳ Test with real R2 data (manual process)
|
||||
6. ⏳ Monitor production conversions
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `worker/AutomergeDurableObject.ts`: Main conversion logic
|
||||
- `getDocument()`: Format detection and routing
|
||||
- `convertAutomergeToStore()`: Automerge array conversion
|
||||
- `migrateDocumentsToStore()`: Old documents format conversion
|
||||
- `migrateShapeProperties()`: Shape property migration
|
||||
|
||||
## Key Improvements
|
||||
|
||||
1. **Validation**: All records are validated before conversion
|
||||
2. **Logging**: Comprehensive statistics for debugging
|
||||
3. **Error Handling**: Graceful handling of malformed data
|
||||
4. **Preservation**: Custom records and shapes are tracked and verified
|
||||
5. **Safety**: Non-destructive conversion with fallbacks
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
# Fathom API Integration for tldraw Canvas
|
||||
|
||||
This integration allows you to import Fathom meeting transcripts directly into your tldraw canvas at jeffemmett.com/board/test.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎥 **Import Fathom Meetings**: Browse and import your Fathom meeting recordings
|
||||
- 📝 **Rich Transcript Display**: View full transcripts with speaker identification and timestamps
|
||||
- ✅ **Action Items**: See extracted action items from meetings
|
||||
- 📋 **AI Summaries**: Display AI-generated meeting summaries
|
||||
- 🔗 **Direct Links**: Click to view meetings in Fathom
|
||||
- 🎨 **Customizable Display**: Toggle between compact and expanded views
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Get Your Fathom API Key
|
||||
|
||||
1. Go to your [Fathom User Settings](https://app.usefathom.com/settings/integrations)
|
||||
2. Navigate to the "Integrations" section
|
||||
3. Generate an API key
|
||||
4. Copy the API key for use in the canvas
|
||||
|
||||
### 2. Using the Integration
|
||||
|
||||
1. **Open the Canvas**: Navigate to `jeffemmett.com/board/test`
|
||||
2. **Access Fathom Meetings**: Click the "Fathom Meetings" button in the toolbar (calendar icon)
|
||||
3. **Enter API Key**: When prompted, enter your Fathom API key
|
||||
4. **Browse Meetings**: The panel will load your recent Fathom meetings
|
||||
5. **Add to Canvas**: Click "Add to Canvas" on any meeting to create a transcript shape
|
||||
|
||||
### 3. Customizing Transcript Shapes
|
||||
|
||||
Once added to the canvas, you can:
|
||||
|
||||
- **Toggle Transcript View**: Click the "📝 Transcript" button to show/hide the full transcript
|
||||
- **Toggle Action Items**: Click the "✅ Actions" button to show/hide action items
|
||||
- **Expand/Collapse**: Click the "📄 Expanded/Compact" button to change the view
|
||||
- **Resize**: Drag the corners to resize the shape
|
||||
- **Move**: Click and drag to reposition the shape
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The integration includes these backend endpoints:
|
||||
|
||||
- `GET /api/fathom/meetings` - List all meetings
|
||||
- `GET /api/fathom/meetings/:id` - Get specific meeting details
|
||||
- `POST /api/fathom/webhook` - Receive webhook notifications (for future real-time updates)
|
||||
|
||||
## Webhook Setup (Optional)
|
||||
|
||||
For real-time updates when new meetings are recorded:
|
||||
|
||||
1. **Get Webhook URL**: Your webhook endpoint is `https://jeffemmett-canvas.jeffemmett.workers.dev/api/fathom/webhook`
|
||||
2. **Configure in Fathom**: Add this URL in your Fathom webhook settings
|
||||
3. **Enable Notifications**: Turn on webhook notifications for new meetings
|
||||
|
||||
## Data Structure
|
||||
|
||||
The Fathom transcript shape includes:
|
||||
|
||||
```typescript
|
||||
{
|
||||
meetingId: string
|
||||
meetingTitle: string
|
||||
meetingUrl: string
|
||||
summary: string
|
||||
transcript: Array<{
|
||||
speaker: string
|
||||
text: string
|
||||
timestamp: string
|
||||
}>
|
||||
actionItems: Array<{
|
||||
text: string
|
||||
assignee?: string
|
||||
dueDate?: string
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"No API key provided"**: Make sure you've entered your Fathom API key correctly
|
||||
2. **"Failed to fetch meetings"**: Check that your API key is valid and has the correct permissions
|
||||
3. **Empty transcript**: Some meetings may not have transcripts if they were recorded without transcription enabled
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check the browser console for error messages
|
||||
- Verify your Fathom API key is correct
|
||||
- Ensure you have recorded meetings in Fathom
|
||||
- Contact support if issues persist
|
||||
|
||||
## Security Notes
|
||||
|
||||
- API keys are stored locally in your browser
|
||||
- Webhook endpoints are currently not signature-verified (TODO for production)
|
||||
- All data is processed client-side for privacy
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Real-time webhook notifications
|
||||
- [ ] Search and filter meetings
|
||||
- [ ] Export transcript data
|
||||
- [ ] Integration with other meeting tools
|
||||
- [ ] Advanced transcript formatting options
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# Sanitization Explanation
|
||||
|
||||
## Why Sanitization Exists
|
||||
|
||||
Sanitization is **necessary** because TLDraw has strict schema requirements that must be met for shapes to render correctly. Without sanitization, we get validation errors and broken shapes.
|
||||
|
||||
## Critical Fixes (MUST KEEP)
|
||||
|
||||
These fixes are **required** for TLDraw to work:
|
||||
|
||||
1. **Move w/h/geo from top-level to props for geo shapes**
|
||||
- TLDraw schema requires `w`, `h`, and `geo` to be in `props`, not at the top level
|
||||
- Without this, TLDraw throws validation errors
|
||||
|
||||
2. **Remove w/h from group shapes**
|
||||
- Group shapes don't have `w`/`h` properties
|
||||
- Having them causes validation errors
|
||||
|
||||
3. **Remove w/h from line shapes**
|
||||
- Line shapes use `points`, not `w`/`h`
|
||||
- Having them causes validation errors
|
||||
|
||||
4. **Fix richText structure**
|
||||
- TLDraw requires `richText` to be `{ content: [...], type: 'doc' }`
|
||||
- Old data might have it as an array or missing structure
|
||||
- We preserve all content, just fix the structure
|
||||
|
||||
5. **Fix crop structure for image/video**
|
||||
- TLDraw requires `crop` to be `{ topLeft: {x,y}, bottomRight: {x,y} }` or `null`
|
||||
- Old data might have `{ x, y, w, h }` format
|
||||
- We convert the format, preserving the crop area
|
||||
|
||||
6. **Remove h/geo from text shapes**
|
||||
- Text shapes don't have `h` or `geo` properties
|
||||
- Having them causes validation errors
|
||||
|
||||
7. **Ensure required properties exist**
|
||||
- Some shapes require certain properties (e.g., `points` for line shapes)
|
||||
- We only add defaults if truly missing
|
||||
|
||||
## What We Preserve
|
||||
|
||||
We **preserve all user data**:
|
||||
- ✅ `richText` content (we only fix structure, never delete content)
|
||||
- ✅ `text` property on arrows
|
||||
- ✅ All metadata (`meta` object)
|
||||
- ✅ All valid shape properties
|
||||
- ✅ Custom shape properties
|
||||
|
||||
## What We Remove (Only When Necessary)
|
||||
|
||||
We only remove properties that:
|
||||
1. **Cause validation errors** (e.g., `w`/`h` on groups/lines)
|
||||
2. **Are invalid for the shape type** (e.g., `geo` on text shapes)
|
||||
|
||||
We **never** remove:
|
||||
- User-created content (text, richText)
|
||||
- Valid metadata
|
||||
- Properties that don't cause errors
|
||||
|
||||
## Current Sanitization Locations
|
||||
|
||||
1. **TLStoreToAutomerge.ts** - When saving from TLDraw to Automerge
|
||||
- Minimal fixes only
|
||||
- Preserves all data
|
||||
|
||||
2. **AutomergeToTLStore.ts** - When loading from Automerge to TLDraw
|
||||
- Minimal fixes only
|
||||
- Preserves all data
|
||||
|
||||
3. **useAutomergeStoreV2.ts** - Initial load processing
|
||||
- More extensive (handles migration from old formats)
|
||||
- Still preserves all user data
|
||||
|
||||
## Can We Simplify?
|
||||
|
||||
**Yes, but carefully:**
|
||||
|
||||
1. ✅ We can remove property deletions that don't cause validation errors
|
||||
2. ✅ We can consolidate duplicate logic
|
||||
3. ❌ We **cannot** remove schema fixes (w/h/geo movement, richText structure)
|
||||
4. ❌ We **cannot** remove property deletions that cause validation errors
|
||||
|
||||
## Recommendation
|
||||
|
||||
Keep sanitization but:
|
||||
1. Only delete properties that **actually cause validation errors**
|
||||
2. Preserve all user data (text, richText, metadata)
|
||||
3. Consolidate duplicate logic between files
|
||||
4. Add comments explaining why each fix is necessary
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Transcription Setup Guide
|
||||
|
||||
## Why the Start Button Doesn't Work
|
||||
|
||||
The transcription start button is likely disabled because the **OpenAI API key is not configured**. The button will be disabled and show a tooltip "OpenAI API key not configured - Please set your API key in settings" when this is the case.
|
||||
|
||||
## How to Fix It
|
||||
|
||||
### Step 1: Get an OpenAI API Key
|
||||
1. Go to [OpenAI API Keys](https://platform.openai.com/api-keys)
|
||||
2. Sign in to your OpenAI account
|
||||
3. Click "Create new secret key"
|
||||
4. Copy the API key (it starts with `sk-`)
|
||||
|
||||
### Step 2: Configure the API Key in Canvas
|
||||
1. In your Canvas application, look for the **Settings** button (usually a gear icon)
|
||||
2. Open the settings dialog
|
||||
3. Find the **OpenAI API Key** field
|
||||
4. Paste your API key
|
||||
5. Save the settings
|
||||
|
||||
### Step 3: Test the Transcription
|
||||
1. Create a transcription shape on the canvas
|
||||
2. Click the "Start" button
|
||||
3. Allow microphone access when prompted
|
||||
4. Start speaking - you should see the transcription appear in real-time
|
||||
|
||||
## Debugging Information
|
||||
|
||||
The application now includes debug logging to help identify issues:
|
||||
|
||||
- **Console Logs**: Check the browser console for messages starting with `🔧 OpenAI Config Debug:`
|
||||
- **Visual Indicators**: The transcription window will show "(API Key Required)" if not configured
|
||||
- **Button State**: The start button will be disabled and grayed out if the API key is missing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Button Still Disabled After Adding API Key
|
||||
1. Refresh the page to reload the configuration
|
||||
2. Check the browser console for any error messages
|
||||
3. Verify the API key is correctly saved in settings
|
||||
|
||||
### Microphone Permission Issues
|
||||
1. Make sure you've granted microphone access to the browser
|
||||
2. Check that your microphone is working in other applications
|
||||
3. Try refreshing the page and granting permission again
|
||||
|
||||
### No Audio Being Recorded
|
||||
1. Check the browser console for audio-related error messages
|
||||
2. Verify your microphone is not being used by another application
|
||||
3. Try using a different browser if issues persist
|
||||
|
||||
## Technical Details
|
||||
|
||||
The transcription system:
|
||||
- Uses the device microphone directly (not Daily room audio)
|
||||
- Records audio in WebM format
|
||||
- Sends audio chunks to OpenAI's Whisper API
|
||||
- Updates the transcription shape in real-time
|
||||
- Requires a valid OpenAI API key to function
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# Worker Environment Switching Guide
|
||||
|
||||
## Quick Switch Commands
|
||||
|
||||
### Switch to Dev Environment (Default)
|
||||
```bash
|
||||
./switch-worker-env.sh dev
|
||||
```
|
||||
|
||||
### Switch to Production Environment
|
||||
```bash
|
||||
./switch-worker-env.sh production
|
||||
```
|
||||
|
||||
### Switch to Local Environment
|
||||
```bash
|
||||
./switch-worker-env.sh local
|
||||
```
|
||||
|
||||
## Manual Switching
|
||||
|
||||
You can also manually edit the environment by:
|
||||
|
||||
1. **Option 1**: Set environment variable
|
||||
```bash
|
||||
export VITE_WORKER_ENV=dev
|
||||
```
|
||||
|
||||
2. **Option 2**: Edit `.env.local` file
|
||||
```
|
||||
VITE_WORKER_ENV=dev
|
||||
```
|
||||
|
||||
3. **Option 3**: Edit `src/constants/workerUrl.ts` directly
|
||||
```typescript
|
||||
const WORKER_ENV = 'dev' // Change this line
|
||||
```
|
||||
|
||||
## Available Environments
|
||||
|
||||
| Environment | URL | Description |
|
||||
|-------------|-----|-------------|
|
||||
| `local` | `http://localhost:5172` | Local worker (requires `npm run dev:worker:local`) |
|
||||
| `dev` | `https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev` | Cloudflare dev environment |
|
||||
| `production` | `https://jeffemmett-canvas.jeffemmett.workers.dev` | Production environment |
|
||||
|
||||
## Current Status
|
||||
|
||||
- ✅ **Dev Environment**: Working with AutomergeDurableObject
|
||||
- ✅ **R2 Data Loading**: Fixed format conversion
|
||||
- ✅ **WebSocket**: Improved with keep-alive and reconnection
|
||||
- 🔄 **Production**: Ready to deploy when testing is complete
|
||||
|
||||
## Testing the Fix
|
||||
|
||||
1. Switch to dev environment: `./switch-worker-env.sh dev`
|
||||
2. Start your frontend: `npm run dev`
|
||||
3. Check browser console for environment logs
|
||||
4. Test R2 data loading in your canvas app
|
||||
5. Verify WebSocket connections are stable
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -35,10 +35,16 @@
|
|||
"@types/marked": "^5.0.2",
|
||||
"@uiw/react-md-editor": "^4.0.5",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"ai": "^4.1.0",
|
||||
"ajv": "^8.17.1",
|
||||
"cherry-markdown": "^0.8.57",
|
||||
"cloudflare-workers-unfurl": "^0.0.7",
|
||||
"fathom-typescript": "^0.0.36",
|
||||
"gray-matter": "^4.0.3",
|
||||
"gun": "^0.2020.1241",
|
||||
"h3-js": "^4.3.0",
|
||||
"holosphere": "^1.1.20",
|
||||
"html2canvas": "^1.4.1",
|
||||
"itty-router": "^5.0.17",
|
||||
"jotai": "^2.6.0",
|
||||
|
|
|
|||
20
src/App.tsx
20
src/App.tsx
|
|
@ -17,7 +17,11 @@ import "@/css/auth.css"; // Import auth styles
|
|||
import "@/css/crypto-auth.css"; // Import crypto auth styles
|
||||
import "@/css/starred-boards.css"; // Import starred boards styles
|
||||
import "@/css/user-profile.css"; // Import user profile styles
|
||||
import "@/css/location.css"; // Import location sharing styles
|
||||
import { Dashboard } from "./routes/Dashboard";
|
||||
import { LocationShareCreate } from "./routes/LocationShareCreate";
|
||||
import { LocationShareView } from "./routes/LocationShareView";
|
||||
import { LocationDashboardRoute } from "./routes/LocationDashboardRoute";
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Import React Context providers
|
||||
|
|
@ -148,6 +152,22 @@ const AppWithProviders = () => {
|
|||
<Resilience />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
{/* Location sharing routes */}
|
||||
<Route path="/share-location" element={
|
||||
<OptionalAuthRoute>
|
||||
<LocationShareCreate />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
<Route path="/location/:token" element={
|
||||
<OptionalAuthRoute>
|
||||
<LocationShareView />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
<Route path="/location-dashboard" element={
|
||||
<OptionalAuthRoute>
|
||||
<LocationDashboardRoute />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</DailyProvider>
|
||||
|
|
|
|||
|
|
@ -200,6 +200,8 @@ export class Drawing extends StateNode {
|
|||
|
||||
onGestureEnd = () => {
|
||||
const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape
|
||||
if (!shape) return
|
||||
|
||||
const ps = shape.props.segments[0].points.map((s) => ({ x: s.x, y: s.y }))
|
||||
const gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps)
|
||||
const score_pass = gesture.score > 0.2
|
||||
|
|
@ -210,52 +212,63 @@ export class Drawing extends StateNode {
|
|||
} else if (!score_confident) {
|
||||
score_color = "yellow"
|
||||
}
|
||||
|
||||
// Execute the gesture action if recognized
|
||||
if (score_pass) {
|
||||
gesture.onComplete?.(this.editor, shape)
|
||||
}
|
||||
let opacity = 1
|
||||
const labelShape: TLShapePartial<TLTextShape> = {
|
||||
id: createShapeId(),
|
||||
type: "text",
|
||||
x: this.editor.inputs.currentPagePoint.x + 20,
|
||||
y: this.editor.inputs.currentPagePoint.y,
|
||||
isLocked: false,
|
||||
props: {
|
||||
size: "xl",
|
||||
text: gesture.name,
|
||||
color: score_color,
|
||||
} as any,
|
||||
}
|
||||
|
||||
// Delete the gesture shape immediately - it's just a command, not a persistent shape
|
||||
this.editor.deleteShape(shape.id)
|
||||
|
||||
// Optionally show a temporary label with fade-out
|
||||
if (SHOW_LABELS) {
|
||||
const labelShape: TLShapePartial<TLTextShape> = {
|
||||
id: createShapeId(),
|
||||
type: "text",
|
||||
x: this.editor.inputs.currentPagePoint.x + 20,
|
||||
y: this.editor.inputs.currentPagePoint.y,
|
||||
isLocked: false,
|
||||
props: {
|
||||
size: "xl",
|
||||
richText: {
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: gesture.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
},
|
||||
color: score_color,
|
||||
},
|
||||
}
|
||||
this.editor.createShape(labelShape)
|
||||
}
|
||||
const intervalId = setInterval(() => {
|
||||
if (opacity > 0) {
|
||||
this.editor.updateShape({
|
||||
...shape,
|
||||
opacity: opacity,
|
||||
props: {
|
||||
...shape.props,
|
||||
color: score_color,
|
||||
},
|
||||
})
|
||||
this.editor.updateShape({
|
||||
...labelShape,
|
||||
opacity: opacity,
|
||||
props: {
|
||||
...labelShape.props,
|
||||
color: score_color,
|
||||
},
|
||||
})
|
||||
opacity = Math.max(0, opacity - 0.025)
|
||||
} else {
|
||||
clearInterval(intervalId)
|
||||
this.editor.deleteShape(shape.id)
|
||||
if (SHOW_LABELS) {
|
||||
|
||||
// Fade out and delete the label
|
||||
let opacity = 1
|
||||
const intervalId = setInterval(() => {
|
||||
if (opacity > 0) {
|
||||
this.editor.updateShape({
|
||||
...labelShape,
|
||||
opacity: opacity,
|
||||
props: {
|
||||
...labelShape.props,
|
||||
color: score_color,
|
||||
},
|
||||
})
|
||||
opacity = Math.max(0, opacity - 0.025)
|
||||
} else {
|
||||
clearInterval(intervalId)
|
||||
this.editor.deleteShape(labelShape.id)
|
||||
}
|
||||
}
|
||||
}, 20)
|
||||
}, 20)
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove: TLEventHandlers["onPointerMove"] = () => {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,25 @@ export function applyAutomergePatchesToTLStore(
|
|||
if (!isStorePatch(patch)) return
|
||||
|
||||
const id = pathToId(patch.path)
|
||||
|
||||
// Skip records with empty or invalid IDs
|
||||
if (!id || id === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Skip custom record types that aren't TLDraw records
|
||||
// These should only exist in Automerge, not in TLDraw store
|
||||
// Components like ObsidianVaultBrowser read directly from Automerge
|
||||
if (typeof id === 'string' && id.startsWith('obsidian_vault:')) {
|
||||
return // Skip - not a TLDraw record, don't process
|
||||
}
|
||||
|
||||
const existingRecord = getRecordFromStore(store, id)
|
||||
const record = updatedObjects[id] || (existingRecord ? JSON.parse(JSON.stringify(existingRecord)) : {
|
||||
id,
|
||||
|
||||
// Infer typeName from ID pattern if record doesn't exist
|
||||
let defaultTypeName = 'shape'
|
||||
let defaultRecord: any = {
|
||||
id,
|
||||
typeName: 'shape',
|
||||
type: 'geo', // Default shape type
|
||||
x: 0,
|
||||
|
|
@ -24,7 +40,82 @@ export function applyAutomergePatchesToTLStore(
|
|||
opacity: 1,
|
||||
meta: {},
|
||||
props: {}
|
||||
})
|
||||
}
|
||||
|
||||
// Check if ID pattern indicates a record type
|
||||
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
|
||||
if (typeof id === 'string') {
|
||||
if (id.startsWith('shape:')) {
|
||||
defaultTypeName = 'shape'
|
||||
// Keep default shape record structure
|
||||
} else if (id.startsWith('page:')) {
|
||||
defaultTypeName = 'page'
|
||||
defaultRecord = {
|
||||
id,
|
||||
typeName: 'page',
|
||||
name: '',
|
||||
index: 'a0' as any,
|
||||
meta: {}
|
||||
}
|
||||
} else if (id.startsWith('camera:')) {
|
||||
defaultTypeName = 'camera'
|
||||
defaultRecord = {
|
||||
id,
|
||||
typeName: 'camera',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
meta: {}
|
||||
}
|
||||
} else if (id.startsWith('instance:')) {
|
||||
defaultTypeName = 'instance'
|
||||
defaultRecord = {
|
||||
id,
|
||||
typeName: 'instance',
|
||||
currentPageId: 'page:page' as any,
|
||||
meta: {}
|
||||
}
|
||||
} else if (id.startsWith('pointer:')) {
|
||||
defaultTypeName = 'pointer'
|
||||
defaultRecord = {
|
||||
id,
|
||||
typeName: 'pointer',
|
||||
x: 0,
|
||||
y: 0,
|
||||
lastActivityTimestamp: 0,
|
||||
meta: {}
|
||||
}
|
||||
} else if (id.startsWith('document:')) {
|
||||
defaultTypeName = 'document'
|
||||
defaultRecord = {
|
||||
id,
|
||||
typeName: 'document',
|
||||
gridSize: 10,
|
||||
name: '',
|
||||
meta: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const record = updatedObjects[id] || (existingRecord ? JSON.parse(JSON.stringify(existingRecord)) : defaultRecord)
|
||||
|
||||
// CRITICAL: Ensure typeName matches ID pattern (fixes misclassification)
|
||||
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
|
||||
if (typeof id === 'string') {
|
||||
if (id.startsWith('shape:') && record.typeName !== 'shape') {
|
||||
record.typeName = 'shape'
|
||||
} else if (id.startsWith('page:') && record.typeName !== 'page') {
|
||||
record.typeName = 'page'
|
||||
} else if (id.startsWith('camera:') && record.typeName !== 'camera') {
|
||||
record.typeName = 'camera'
|
||||
} else if (id.startsWith('instance:') && record.typeName !== 'instance') {
|
||||
record.typeName = 'instance'
|
||||
} else if (id.startsWith('pointer:') && record.typeName !== 'pointer') {
|
||||
record.typeName = 'pointer'
|
||||
} else if (id.startsWith('document:') && record.typeName !== 'document') {
|
||||
record.typeName = 'document'
|
||||
}
|
||||
}
|
||||
|
||||
switch (patch.action) {
|
||||
case "insert": {
|
||||
|
|
@ -58,6 +149,9 @@ export function applyAutomergePatchesToTLStore(
|
|||
console.log("Unsupported patch:", patch)
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Re-check typeName after patch application to ensure it's still correct
|
||||
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
|
||||
})
|
||||
|
||||
// Sanitize records before putting them in the store
|
||||
|
|
@ -65,264 +159,383 @@ export function applyAutomergePatchesToTLStore(
|
|||
const failedRecords: any[] = []
|
||||
|
||||
Object.values(updatedObjects).forEach(record => {
|
||||
// Skip records with empty or invalid IDs
|
||||
if (!record || !record.id || record.id === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Skip custom record types that aren't TLDraw records
|
||||
// These should only exist in Automerge, not in TLDraw store
|
||||
if (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:')) {
|
||||
return // Skip - not a TLDraw record
|
||||
}
|
||||
|
||||
try {
|
||||
const sanitized = sanitizeRecord(record)
|
||||
toPut.push(sanitized)
|
||||
} catch (error) {
|
||||
// If it's a missing typeName/id error, skip it
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('missing required typeName') ||
|
||||
error.message.includes('missing required id'))) {
|
||||
// Skip records with missing required fields
|
||||
return
|
||||
}
|
||||
console.error("Failed to sanitize record:", error, record)
|
||||
failedRecords.push(record)
|
||||
}
|
||||
})
|
||||
|
||||
// put / remove the records in the store
|
||||
console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
|
||||
// Log patch application for debugging
|
||||
console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`)
|
||||
|
||||
if (failedRecords.length > 0) {
|
||||
console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
|
||||
}
|
||||
|
||||
if (failedRecords.length > 0) {
|
||||
console.error("Failed to sanitize records:", failedRecords)
|
||||
}
|
||||
|
||||
// CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level
|
||||
// Also ensure text shapes don't have props.text (should use props.richText instead)
|
||||
const finalSanitized = toPut.map(record => {
|
||||
if (record.typeName === 'shape' && record.type === 'geo') {
|
||||
// Store values before removing from top level
|
||||
const wValue = 'w' in record ? (record as any).w : undefined
|
||||
const hValue = 'h' in record ? (record as any).h : undefined
|
||||
const geoValue = 'geo' in record ? (record as any).geo : undefined
|
||||
|
||||
// Create cleaned record without w/h/geo at top level
|
||||
const cleaned: any = {}
|
||||
for (const key in record) {
|
||||
if (key !== 'w' && key !== 'h' && key !== 'geo') {
|
||||
cleaned[key] = (record as any)[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure props exists and move values there if needed
|
||||
if (!cleaned.props) cleaned.props = {}
|
||||
if (wValue !== undefined && (!('w' in cleaned.props) || cleaned.props.w === undefined)) {
|
||||
cleaned.props.w = wValue
|
||||
}
|
||||
if (hValue !== undefined && (!('h' in cleaned.props) || cleaned.props.h === undefined)) {
|
||||
cleaned.props.h = hValue
|
||||
}
|
||||
if (geoValue !== undefined && (!('geo' in cleaned.props) || cleaned.props.geo === undefined)) {
|
||||
cleaned.props.geo = geoValue
|
||||
}
|
||||
|
||||
return cleaned as TLRecord
|
||||
}
|
||||
|
||||
// CRITICAL: Remove props.text from text shapes (TLDraw schema doesn't allow it)
|
||||
if (record.typeName === 'shape' && record.type === 'text' && (record as any).props && 'text' in (record as any).props) {
|
||||
const cleaned = { ...record }
|
||||
if (cleaned.props && 'text' in cleaned.props) {
|
||||
delete (cleaned.props as any).text
|
||||
}
|
||||
return cleaned as TLRecord
|
||||
}
|
||||
|
||||
return record
|
||||
})
|
||||
|
||||
store.mergeRemoteChanges(() => {
|
||||
if (toRemove.length) store.remove(toRemove)
|
||||
if (toPut.length) store.put(toPut)
|
||||
if (finalSanitized.length) store.put(finalSanitized)
|
||||
})
|
||||
}
|
||||
|
||||
// Sanitize record to remove invalid properties
|
||||
// Helper function to clean NaN values from richText content
|
||||
// This prevents SVG export errors when TLDraw tries to render text with invalid coordinates
|
||||
function cleanRichTextNaN(richText: any): any {
|
||||
if (!richText || typeof richText !== 'object') {
|
||||
return richText
|
||||
}
|
||||
|
||||
// Deep clone to avoid mutating the original
|
||||
const cleaned = JSON.parse(JSON.stringify(richText))
|
||||
|
||||
// Recursively clean content array
|
||||
if (Array.isArray(cleaned.content)) {
|
||||
cleaned.content = cleaned.content.map((item: any) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
// Remove any NaN values from the item
|
||||
const cleanedItem: any = {}
|
||||
for (const key in item) {
|
||||
const value = item[key]
|
||||
// Skip NaN values - they cause SVG export errors
|
||||
if (typeof value === 'number' && isNaN(value)) {
|
||||
// Skip NaN values
|
||||
continue
|
||||
}
|
||||
// Recursively clean nested objects
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
cleanedItem[key] = cleanRichTextNaN(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
cleanedItem[key] = value.map((v: any) =>
|
||||
typeof v === 'object' && v !== null ? cleanRichTextNaN(v) : v
|
||||
)
|
||||
} else {
|
||||
cleanedItem[key] = value
|
||||
}
|
||||
}
|
||||
return cleanedItem
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Minimal sanitization - only fix critical issues that break TLDraw
|
||||
function sanitizeRecord(record: any): TLRecord {
|
||||
const sanitized = { ...record }
|
||||
|
||||
// Ensure required fields exist for all records
|
||||
if (!sanitized.id) {
|
||||
console.error("Record missing required id field:", record)
|
||||
// CRITICAL FIXES ONLY - preserve all other properties
|
||||
|
||||
// Only fix critical structural issues
|
||||
if (!sanitized.id || sanitized.id === '') {
|
||||
throw new Error("Record missing required id field")
|
||||
}
|
||||
|
||||
if (!sanitized.typeName) {
|
||||
console.error("Record missing required typeName field:", record)
|
||||
if (!sanitized.typeName || sanitized.typeName === '') {
|
||||
throw new Error("Record missing required typeName field")
|
||||
}
|
||||
|
||||
// Remove invalid properties from shapes
|
||||
// For shapes, only ensure basic required fields exist
|
||||
if (sanitized.typeName === 'shape') {
|
||||
// Ensure required shape fields exist
|
||||
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||
console.error("Shape missing or invalid type field:", {
|
||||
id: sanitized.id,
|
||||
typeName: sanitized.typeName,
|
||||
currentType: sanitized.type,
|
||||
record: sanitized
|
||||
})
|
||||
// Try to infer type from other properties or use a default
|
||||
if (sanitized.props?.geo) {
|
||||
sanitized.type = 'geo'
|
||||
} else if (sanitized.props?.text) {
|
||||
sanitized.type = 'text'
|
||||
} else if (sanitized.props?.roomUrl) {
|
||||
sanitized.type = 'VideoChat'
|
||||
} else if (sanitized.props?.roomId) {
|
||||
sanitized.type = 'ChatBox'
|
||||
} else if (sanitized.props?.url) {
|
||||
sanitized.type = 'Embed'
|
||||
} else if (sanitized.props?.prompt) {
|
||||
sanitized.type = 'Prompt'
|
||||
} else if (sanitized.props?.isMinimized !== undefined) {
|
||||
sanitized.type = 'SharedPiano'
|
||||
} else if (sanitized.props?.isTranscribing !== undefined) {
|
||||
sanitized.type = 'Transcription'
|
||||
} else if (sanitized.props?.noteId) {
|
||||
sanitized.type = 'ObsNote'
|
||||
} else {
|
||||
sanitized.type = 'geo' // Default fallback
|
||||
}
|
||||
console.log(`🔧 Fixed missing/invalid type field for shape ${sanitized.id}, set to: ${sanitized.type}`)
|
||||
}
|
||||
|
||||
// Ensure type is a valid string
|
||||
if (typeof sanitized.type !== 'string') {
|
||||
console.error("Shape type is not a string:", sanitized.type, "for shape:", sanitized.id)
|
||||
sanitized.type = 'geo' // Force to valid string
|
||||
}
|
||||
|
||||
// Ensure other required shape fields exist
|
||||
if (typeof sanitized.x !== 'number') {
|
||||
sanitized.x = 0
|
||||
}
|
||||
if (typeof sanitized.y !== 'number') {
|
||||
sanitized.y = 0
|
||||
}
|
||||
if (typeof sanitized.rotation !== 'number') {
|
||||
sanitized.rotation = 0
|
||||
}
|
||||
if (typeof sanitized.isLocked !== 'boolean') {
|
||||
sanitized.isLocked = false
|
||||
}
|
||||
if (typeof sanitized.opacity !== 'number') {
|
||||
sanitized.opacity = 1
|
||||
}
|
||||
if (typeof sanitized.x !== 'number') sanitized.x = 0
|
||||
if (typeof sanitized.y !== 'number') sanitized.y = 0
|
||||
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
|
||||
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
|
||||
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
||||
// CRITICAL: Preserve all existing meta properties - only create empty object if meta doesn't exist
|
||||
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||
sanitized.meta = {}
|
||||
} else {
|
||||
// Ensure meta is a mutable copy to preserve all properties (including text for rectangles)
|
||||
sanitized.meta = { ...sanitized.meta }
|
||||
}
|
||||
// Remove top-level properties that should only be in props
|
||||
const invalidTopLevelProperties = ['insets', 'scribbles', 'duplicateProps', 'geo', 'w', 'h']
|
||||
invalidTopLevelProperties.forEach(prop => {
|
||||
if (prop in sanitized) {
|
||||
console.log(`Moving ${prop} property from top-level to props for shape during patch application:`, {
|
||||
id: sanitized.id,
|
||||
type: sanitized.type,
|
||||
originalValue: sanitized[prop]
|
||||
})
|
||||
|
||||
// Move to props if props exists, otherwise create props
|
||||
if (!sanitized.props) {
|
||||
sanitized.props = {}
|
||||
}
|
||||
sanitized.props[prop] = sanitized[prop]
|
||||
delete sanitized[prop]
|
||||
}
|
||||
})
|
||||
if (!sanitized.index) sanitized.index = 'a1'
|
||||
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||
if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {}
|
||||
|
||||
// Ensure props object exists for all shapes
|
||||
if (!sanitized.props) {
|
||||
sanitized.props = {}
|
||||
// CRITICAL: Ensure props is a deep mutable copy to preserve all nested properties
|
||||
// This is essential for custom shapes like ObsNote and for preserving richText in geo shapes
|
||||
// Use JSON parse/stringify to create a deep copy of nested objects (like richText.content)
|
||||
sanitized.props = JSON.parse(JSON.stringify(sanitized.props))
|
||||
|
||||
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
|
||||
// This ensures arrows and other shapes are properly recognized
|
||||
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||
// Check for arrow-specific properties first
|
||||
if (sanitized.props?.start !== undefined ||
|
||||
sanitized.props?.end !== undefined ||
|
||||
sanitized.props?.arrowheadStart !== undefined ||
|
||||
sanitized.props?.arrowheadEnd !== undefined ||
|
||||
sanitized.props?.kind === 'line' ||
|
||||
sanitized.props?.kind === 'curved' ||
|
||||
sanitized.props?.kind === 'straight') {
|
||||
sanitized.type = 'arrow'
|
||||
}
|
||||
// Check for line-specific properties
|
||||
else if (sanitized.props?.points !== undefined) {
|
||||
sanitized.type = 'line'
|
||||
}
|
||||
// Check for geo-specific properties (w/h/geo)
|
||||
else if (sanitized.props?.geo !== undefined ||
|
||||
('w' in sanitized && 'h' in sanitized) ||
|
||||
('w' in sanitized.props && 'h' in sanitized.props)) {
|
||||
sanitized.type = 'geo'
|
||||
}
|
||||
// Check for note-specific properties
|
||||
else if (sanitized.props?.growY !== undefined ||
|
||||
sanitized.props?.verticalAlign !== undefined) {
|
||||
sanitized.type = 'note'
|
||||
}
|
||||
// Check for text-specific properties
|
||||
else if (sanitized.props?.textAlign !== undefined ||
|
||||
sanitized.props?.autoSize !== undefined) {
|
||||
sanitized.type = 'text'
|
||||
}
|
||||
// Check for draw-specific properties
|
||||
else if (sanitized.props?.segments !== undefined) {
|
||||
sanitized.type = 'draw'
|
||||
}
|
||||
// Default to geo only if no other indicators found
|
||||
else {
|
||||
sanitized.type = 'geo'
|
||||
}
|
||||
}
|
||||
|
||||
// Fix geo shape specific properties
|
||||
if (sanitized.type === 'geo') {
|
||||
// Ensure geo shape has proper structure
|
||||
if (!sanitized.props.geo) {
|
||||
sanitized.props.geo = 'rectangle'
|
||||
}
|
||||
if (!sanitized.props.w) {
|
||||
sanitized.props.w = 100
|
||||
}
|
||||
if (!sanitized.props.h) {
|
||||
sanitized.props.h = 100
|
||||
// CRITICAL: For geo shapes, move w/h/geo from top level to props (required by TLDraw schema)
|
||||
if (sanitized.type === 'geo' || ('w' in sanitized && 'h' in sanitized && sanitized.type !== 'arrow')) {
|
||||
// If type is missing but has w/h, assume it's a geo shape (but only if not already identified as arrow)
|
||||
if (!sanitized.type || sanitized.type === 'geo') {
|
||||
sanitized.type = 'geo'
|
||||
}
|
||||
|
||||
// Remove invalid properties for geo shapes (including insets)
|
||||
const invalidGeoProps = ['transcript', 'isTranscribing', 'isPaused', 'isEditing', 'roomUrl', 'roomId', 'prompt', 'value', 'agentBinding', 'isMinimized', 'noteId', 'title', 'content', 'tags', 'showPreview', 'backgroundColor', 'textColor', 'editingContent', 'vaultName', 'insets']
|
||||
invalidGeoProps.forEach(prop => {
|
||||
if (prop in sanitized.props) {
|
||||
console.log(`Removing invalid ${prop} property from geo shape:`, sanitized.id)
|
||||
delete sanitized.props[prop]
|
||||
// Ensure props exists
|
||||
if (!sanitized.props) sanitized.props = {}
|
||||
|
||||
// Store values before removing from top level
|
||||
const wValue = 'w' in sanitized ? (sanitized as any).w : undefined
|
||||
const hValue = 'h' in sanitized ? (sanitized as any).h : undefined
|
||||
const geoValue = 'geo' in sanitized ? (sanitized as any).geo : undefined
|
||||
|
||||
// Move w from top level to props (if present at top level)
|
||||
if (wValue !== undefined) {
|
||||
if (!('w' in sanitized.props) || sanitized.props.w === undefined) {
|
||||
sanitized.props.w = wValue
|
||||
}
|
||||
})
|
||||
delete (sanitized as any).w
|
||||
}
|
||||
|
||||
// Move h from top level to props (if present at top level)
|
||||
if (hValue !== undefined) {
|
||||
if (!('h' in sanitized.props) || sanitized.props.h === undefined) {
|
||||
sanitized.props.h = hValue
|
||||
}
|
||||
delete (sanitized as any).h
|
||||
}
|
||||
|
||||
// Move geo from top level to props (if present at top level)
|
||||
if (geoValue !== undefined) {
|
||||
if (!('geo' in sanitized.props) || sanitized.props.geo === undefined) {
|
||||
sanitized.props.geo = geoValue
|
||||
}
|
||||
delete (sanitized as any).geo
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Fix note shape specific properties
|
||||
// Only fix type if completely missing
|
||||
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||
// Simple type inference - only if absolutely necessary
|
||||
if (sanitized.props?.geo) {
|
||||
sanitized.type = 'geo'
|
||||
} else {
|
||||
sanitized.type = 'geo' // Safe default
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Fix crop structure for image/video shapes if it exists
|
||||
if (sanitized.type === 'image' || sanitized.type === 'video') {
|
||||
if (sanitized.props.crop !== null && sanitized.props.crop !== undefined) {
|
||||
if (!sanitized.props.crop.topLeft || !sanitized.props.crop.bottomRight) {
|
||||
if (sanitized.props.crop.x !== undefined && sanitized.props.crop.y !== undefined) {
|
||||
// Convert old format to new format
|
||||
sanitized.props.crop = {
|
||||
topLeft: { x: sanitized.props.crop.x || 0, y: sanitized.props.crop.y || 0 },
|
||||
bottomRight: {
|
||||
x: (sanitized.props.crop.x || 0) + (sanitized.props.crop.w || 1),
|
||||
y: (sanitized.props.crop.y || 0) + (sanitized.props.crop.h || 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sanitized.props.crop = {
|
||||
topLeft: { x: 0, y: 0 },
|
||||
bottomRight: { x: 1, y: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Fix line shapes - ensure valid points structure (required by schema)
|
||||
if (sanitized.type === 'line') {
|
||||
// Remove invalid w/h from props (they cause validation errors)
|
||||
if ('w' in sanitized.props) delete sanitized.props.w
|
||||
if ('h' in sanitized.props) delete sanitized.props.h
|
||||
|
||||
// Line shapes REQUIRE points property
|
||||
if (!sanitized.props.points || typeof sanitized.props.points !== 'object' || Array.isArray(sanitized.props.points)) {
|
||||
sanitized.props.points = {
|
||||
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
|
||||
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Fix group shapes - remove invalid w/h from props
|
||||
if (sanitized.type === 'group') {
|
||||
if ('w' in sanitized.props) delete sanitized.props.w
|
||||
if ('h' in sanitized.props) delete sanitized.props.h
|
||||
}
|
||||
|
||||
// CRITICAL: Fix note shapes - ensure richText structure if it exists
|
||||
if (sanitized.type === 'note') {
|
||||
// Remove w/h properties from note shapes as they're not valid
|
||||
if ('w' in sanitized.props) {
|
||||
console.log(`Removing invalid w property from note shape:`, sanitized.id)
|
||||
delete sanitized.props.w
|
||||
}
|
||||
if ('h' in sanitized.props) {
|
||||
console.log(`Removing invalid h property from note shape:`, sanitized.id)
|
||||
delete sanitized.props.h
|
||||
}
|
||||
}
|
||||
|
||||
// Convert custom shape types to valid TLDraw types
|
||||
const customShapeTypeMap: { [key: string]: string } = {
|
||||
'VideoChat': 'embed',
|
||||
'Transcription': 'text',
|
||||
'SharedPiano': 'embed',
|
||||
'Prompt': 'text',
|
||||
'ChatBox': 'embed',
|
||||
'Embed': 'embed',
|
||||
'Markdown': 'text',
|
||||
'MycrozineTemplate': 'embed',
|
||||
'Slide': 'embed',
|
||||
'ObsNote': 'text'
|
||||
}
|
||||
|
||||
if (customShapeTypeMap[sanitized.type]) {
|
||||
console.log(`Converting custom shape type ${sanitized.type} to ${customShapeTypeMap[sanitized.type]} for shape:`, sanitized.id)
|
||||
sanitized.type = customShapeTypeMap[sanitized.type]
|
||||
}
|
||||
|
||||
// Ensure proper props for converted shape types
|
||||
if (sanitized.type === 'embed') {
|
||||
// Ensure embed shapes have required properties
|
||||
if (!sanitized.props.url) {
|
||||
sanitized.props.url = ''
|
||||
}
|
||||
if (!sanitized.props.w) {
|
||||
sanitized.props.w = 400
|
||||
}
|
||||
if (!sanitized.props.h) {
|
||||
sanitized.props.h = 300
|
||||
}
|
||||
// Remove invalid properties for embed shapes
|
||||
const invalidEmbedProps = ['isMinimized', 'roomUrl', 'roomId', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'richText']
|
||||
invalidEmbedProps.forEach(prop => {
|
||||
if (prop in sanitized.props) {
|
||||
console.log(`Removing invalid ${prop} property from embed shape:`, sanitized.id)
|
||||
delete sanitized.props[prop]
|
||||
if (sanitized.props.richText) {
|
||||
if (Array.isArray(sanitized.props.richText)) {
|
||||
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||
}
|
||||
})
|
||||
}
|
||||
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||
if (sanitized.props.richText) {
|
||||
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.type === 'text') {
|
||||
// Ensure text shapes have required properties
|
||||
if (!sanitized.props.text) {
|
||||
sanitized.props.text = ''
|
||||
// CRITICAL: Fix richText structure for geo shapes (preserve content)
|
||||
if (sanitized.type === 'geo' && sanitized.props.richText) {
|
||||
if (Array.isArray(sanitized.props.richText)) {
|
||||
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||
}
|
||||
if (!sanitized.props.w) {
|
||||
sanitized.props.w = 200
|
||||
}
|
||||
if (!sanitized.props.color) {
|
||||
sanitized.props.color = 'black'
|
||||
}
|
||||
if (!sanitized.props.size) {
|
||||
sanitized.props.size = 'm'
|
||||
}
|
||||
if (!sanitized.props.font) {
|
||||
sanitized.props.font = 'draw'
|
||||
}
|
||||
if (!sanitized.props.textAlign) {
|
||||
sanitized.props.textAlign = 'start'
|
||||
}
|
||||
// Text shapes don't have h property
|
||||
if ('h' in sanitized.props) {
|
||||
delete sanitized.props.h
|
||||
}
|
||||
// Remove invalid properties for text shapes
|
||||
const invalidTextProps = ['isMinimized', 'roomUrl', 'roomId', 'geo', 'insets', 'scribbles']
|
||||
invalidTextProps.forEach(prop => {
|
||||
if (prop in sanitized.props) {
|
||||
console.log(`Removing invalid ${prop} property from text shape:`, sanitized.id)
|
||||
delete sanitized.props[prop]
|
||||
}
|
||||
})
|
||||
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||
}
|
||||
|
||||
// General cleanup: remove any properties that might cause validation errors
|
||||
const validShapeProps: { [key: string]: string[] } = {
|
||||
'geo': ['w', 'h', 'geo', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
||||
'text': ['w', 'text', 'color', 'fill', 'dash', 'size', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
||||
'embed': ['w', 'h', 'url', 'doesResize', 'doesResizeHeight'],
|
||||
'note': ['color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
||||
'arrow': ['start', 'end', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url', 'arrowheadStart', 'arrowheadEnd'],
|
||||
'draw': ['points', 'color', 'fill', 'dash', 'size'],
|
||||
'bookmark': ['w', 'h', 'url', 'doesResize', 'doesResizeHeight'],
|
||||
'image': ['w', 'h', 'assetId', 'crop', 'doesResize', 'doesResizeHeight'],
|
||||
'video': ['w', 'h', 'assetId', 'crop', 'doesResize', 'doesResizeHeight'],
|
||||
'frame': ['w', 'h', 'name', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
||||
'group': ['w', 'h'],
|
||||
'highlight': ['w', 'h', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
||||
'line': ['x', 'y', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url']
|
||||
// CRITICAL: Fix richText structure for text shapes
|
||||
if (sanitized.type === 'text' && sanitized.props.richText) {
|
||||
if (Array.isArray(sanitized.props.richText)) {
|
||||
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||
}
|
||||
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||
}
|
||||
|
||||
// Remove invalid properties based on shape type
|
||||
if (validShapeProps[sanitized.type]) {
|
||||
const validProps = validShapeProps[sanitized.type]
|
||||
Object.keys(sanitized.props).forEach(prop => {
|
||||
if (!validProps.includes(prop)) {
|
||||
console.log(`Removing invalid property ${prop} from ${sanitized.type} shape:`, sanitized.id)
|
||||
delete sanitized.props[prop]
|
||||
}
|
||||
})
|
||||
// CRITICAL: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text)
|
||||
// Text shapes should only use props.richText, not props.text
|
||||
if (sanitized.type === 'text' && 'text' in sanitized.props) {
|
||||
delete sanitized.props.text
|
||||
}
|
||||
|
||||
// CRITICAL: Only convert unknown shapes with richText to text if they're truly unknown
|
||||
// DO NOT convert geo/note shapes - they can legitimately have richText
|
||||
if (sanitized.props?.richText && sanitized.type !== 'text' && sanitized.type !== 'geo' && sanitized.type !== 'note') {
|
||||
// This is an unknown shape type with richText - convert to text shape
|
||||
// But preserve all existing properties first
|
||||
const existingProps = { ...sanitized.props }
|
||||
sanitized.type = 'text'
|
||||
sanitized.props = existingProps
|
||||
|
||||
// Fix richText structure if needed
|
||||
if (Array.isArray(sanitized.props.richText)) {
|
||||
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||
}
|
||||
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||
|
||||
// Only remove properties that cause validation errors (not all "invalid" ones)
|
||||
if ('h' in sanitized.props) delete sanitized.props.h
|
||||
if ('geo' in sanitized.props) delete sanitized.props.geo
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,14 +566,27 @@ const applyInsertToObject = (patch: Automerge.InsertPatch, object: any): TLRecor
|
|||
const insertionPoint = path[path.length - 1] as number
|
||||
const pathEnd = path[path.length - 2] as string
|
||||
const parts = path.slice(2, -2)
|
||||
|
||||
// Create missing properties as we navigate
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) {
|
||||
throw new Error("NO WAY")
|
||||
if (current[part] === undefined || current[part] === null) {
|
||||
// Create missing property - use array for numeric indices
|
||||
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
|
||||
current[part] = []
|
||||
} else {
|
||||
current[part] = {}
|
||||
}
|
||||
}
|
||||
current = current[part]
|
||||
}
|
||||
|
||||
// Ensure pathEnd exists and is an array
|
||||
if (current[pathEnd] === undefined || current[pathEnd] === null) {
|
||||
current[pathEnd] = []
|
||||
}
|
||||
|
||||
// splice is a mutator... yay.
|
||||
const clone = current[pathEnd].slice(0)
|
||||
const clone = Array.isArray(current[pathEnd]) ? current[pathEnd].slice(0) : []
|
||||
clone.splice(insertionPoint, 0, ...values)
|
||||
current[pathEnd] = clone
|
||||
return object
|
||||
|
|
@ -383,10 +609,24 @@ const applyPutToObject = (patch: Automerge.PutPatch, object: any): TLRecord => {
|
|||
return { ...object, [property]: value }
|
||||
}
|
||||
|
||||
// default case
|
||||
// default case - create missing properties as we navigate
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined || current[part] === null) {
|
||||
// Create missing property - use object for named properties, array for numeric indices
|
||||
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
|
||||
current[part] = []
|
||||
} else {
|
||||
current[part] = {}
|
||||
}
|
||||
}
|
||||
current = current[part]
|
||||
}
|
||||
|
||||
// Ensure target exists
|
||||
if (current[target] === undefined || current[target] === null) {
|
||||
current[target] = {}
|
||||
}
|
||||
|
||||
current[target] = { ...current[target], [property]: value }
|
||||
return object
|
||||
}
|
||||
|
|
@ -397,12 +637,25 @@ const applySpliceToObject = (patch: Automerge.SpliceTextPatch, object: any): TLR
|
|||
const insertionPoint = path[path.length - 1] as number
|
||||
const pathEnd = path[path.length - 2] as string
|
||||
const parts = path.slice(2, -2)
|
||||
|
||||
// Create missing properties as we navigate
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) {
|
||||
throw new Error("NO WAY")
|
||||
if (current[part] === undefined || current[part] === null) {
|
||||
// Create missing property - use array for numeric indices or when splicing
|
||||
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
|
||||
current[part] = []
|
||||
} else {
|
||||
current[part] = {}
|
||||
}
|
||||
}
|
||||
current = current[part]
|
||||
}
|
||||
|
||||
// Ensure pathEnd exists and is an array for splicing
|
||||
if (current[pathEnd] === undefined || current[pathEnd] === null) {
|
||||
current[pathEnd] = []
|
||||
}
|
||||
|
||||
// TODO: we're not supporting actual splices yet because TLDraw won't generate them natively
|
||||
if (insertionPoint !== 0) {
|
||||
throw new Error("Splices are not supported yet")
|
||||
|
|
|
|||
|
|
@ -153,12 +153,18 @@ export class CloudflareAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
class CloudflareNetworkAdapter extends NetworkAdapter {
|
||||
export class CloudflareNetworkAdapter extends NetworkAdapter {
|
||||
private workerUrl: string
|
||||
private websocket: WebSocket | null = null
|
||||
private roomId: string | null = null
|
||||
private readyPromise: Promise<void>
|
||||
private readyResolve: (() => void) | null = null
|
||||
private keepAliveInterval: NodeJS.Timeout | null = null
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null
|
||||
private reconnectAttempts: number = 0
|
||||
private maxReconnectAttempts: number = 5
|
||||
private reconnectDelay: number = 1000
|
||||
private isConnecting: boolean = false
|
||||
|
||||
constructor(workerUrl: string, roomId?: string) {
|
||||
super()
|
||||
|
|
@ -178,40 +184,93 @@ class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
}
|
||||
|
||||
connect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
|
||||
if (this.isConnecting) {
|
||||
console.log('🔌 CloudflareAdapter: Connection already in progress, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up existing connection
|
||||
this.cleanup()
|
||||
|
||||
// Use the room ID from constructor or default
|
||||
// Add sessionId as a query parameter as required by AutomergeDurableObject
|
||||
const sessionId = peerId || `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const wsUrl = `${this.workerUrl.replace('http', 'ws')}/connect/${this.roomId}?sessionId=${sessionId}`
|
||||
|
||||
this.isConnecting = true
|
||||
|
||||
// Add a small delay to ensure the server is ready
|
||||
setTimeout(() => {
|
||||
try {
|
||||
console.log('🔌 CloudflareAdapter: Creating WebSocket connection to:', wsUrl)
|
||||
this.websocket = new WebSocket(wsUrl)
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
console.log('🔌 CloudflareAdapter: WebSocket connection opened successfully')
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this.readyResolve?.()
|
||||
this.startKeepAlive()
|
||||
}
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data)
|
||||
|
||||
// Convert the message to the format expected by Automerge
|
||||
if (message.type === 'sync' && message.data) {
|
||||
// For now, we'll handle the JSON data directly
|
||||
// In a full implementation, this would be binary sync data
|
||||
// Automerge's native protocol uses binary messages
|
||||
// We need to handle both binary and text messages
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
console.log('🔌 CloudflareAdapter: Received binary message (Automerge protocol)')
|
||||
// Handle binary Automerge sync messages - pass directly to Repo
|
||||
// Automerge Repo expects binary sync messages as ArrayBuffer
|
||||
this.emit('message', {
|
||||
type: 'sync',
|
||||
senderId: message.senderId,
|
||||
targetId: message.targetId,
|
||||
documentId: message.documentId,
|
||||
data: message.data
|
||||
data: event.data
|
||||
})
|
||||
} else if (event.data instanceof Blob) {
|
||||
// Handle Blob messages (convert to ArrayBuffer)
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
console.log('🔌 CloudflareAdapter: Received Blob message, converted to ArrayBuffer')
|
||||
this.emit('message', {
|
||||
type: 'sync',
|
||||
data: buffer
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.emit('message', message)
|
||||
// Handle text messages (our custom protocol for backward compatibility)
|
||||
const message = JSON.parse(event.data)
|
||||
console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type)
|
||||
|
||||
// Handle ping/pong messages for keep-alive
|
||||
if (message.type === 'ping') {
|
||||
this.sendPong()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle test messages
|
||||
if (message.type === 'test') {
|
||||
console.log('🔌 CloudflareAdapter: Received test message:', message.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert the message to the format expected by Automerge
|
||||
if (message.type === 'sync' && message.data) {
|
||||
console.log('🔌 CloudflareAdapter: Received sync message with data:', {
|
||||
hasStore: !!message.data.store,
|
||||
storeKeys: message.data.store ? Object.keys(message.data.store).length : 0
|
||||
})
|
||||
// For backward compatibility, handle JSON sync data
|
||||
this.emit('message', {
|
||||
type: 'sync',
|
||||
senderId: message.senderId,
|
||||
targetId: message.targetId,
|
||||
documentId: message.documentId,
|
||||
data: message.data
|
||||
})
|
||||
} else {
|
||||
this.emit('message', message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error)
|
||||
console.error('❌ CloudflareAdapter: Error parsing WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,16 +278,30 @@ class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
console.log('Disconnected from Cloudflare WebSocket', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean
|
||||
wasClean: event.wasClean,
|
||||
url: wsUrl,
|
||||
reconnectAttempts: this.reconnectAttempts
|
||||
})
|
||||
|
||||
this.isConnecting = false
|
||||
this.stopKeepAlive()
|
||||
|
||||
// Log specific error codes for debugging
|
||||
if (event.code === 1005) {
|
||||
console.error('❌ WebSocket closed with code 1005 (No Status Received) - this usually indicates a connection issue or idle timeout')
|
||||
} else if (event.code === 1006) {
|
||||
console.error('❌ WebSocket closed with code 1006 (Abnormal Closure) - connection was lost unexpectedly')
|
||||
} else if (event.code === 1011) {
|
||||
console.error('❌ WebSocket closed with code 1011 (Server Error) - server encountered an error')
|
||||
} else if (event.code === 1000) {
|
||||
console.log('✅ WebSocket closed normally (code 1000)')
|
||||
return // Don't reconnect on normal closure
|
||||
}
|
||||
|
||||
this.emit('close')
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(() => {
|
||||
if (this.roomId) {
|
||||
console.log('Attempting to reconnect WebSocket...')
|
||||
this.connect(peerId, peerMetadata)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
this.scheduleReconnect(peerId, peerMetadata)
|
||||
}
|
||||
|
||||
this.websocket.onerror = (error) => {
|
||||
|
|
@ -240,9 +313,11 @@ class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
target: error.target,
|
||||
isTrusted: error.isTrusted
|
||||
})
|
||||
this.isConnecting = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error)
|
||||
this.isConnecting = false
|
||||
return
|
||||
}
|
||||
}, 100)
|
||||
|
|
@ -250,8 +325,31 @@ class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
|
||||
send(message: Message): void {
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
console.log('Sending WebSocket message:', message.type)
|
||||
this.websocket.send(JSON.stringify(message))
|
||||
// Check if this is a binary sync message from Automerge Repo
|
||||
if (message.type === 'sync' && (message as any).data instanceof ArrayBuffer) {
|
||||
console.log('🔌 CloudflareAdapter: Sending binary sync message (Automerge protocol)')
|
||||
// Send binary data directly for Automerge's native sync protocol
|
||||
this.websocket.send((message as any).data)
|
||||
} else if (message.type === 'sync' && (message as any).data instanceof Uint8Array) {
|
||||
console.log('🔌 CloudflareAdapter: Sending Uint8Array sync message (Automerge protocol)')
|
||||
// Convert Uint8Array to ArrayBuffer and send
|
||||
this.websocket.send((message as any).data.buffer)
|
||||
} else {
|
||||
// Handle text-based messages (backward compatibility and control messages)
|
||||
console.log('Sending WebSocket message:', message.type)
|
||||
// Debug: Log patch content if it's a patch message
|
||||
if (message.type === 'patch' && (message as any).patches) {
|
||||
console.log('🔍 Sending patches:', (message as any).patches.length, 'patches')
|
||||
;(message as any).patches.forEach((patch: any, index: number) => {
|
||||
console.log(` Patch ${index}:`, {
|
||||
action: patch.action,
|
||||
path: patch.path,
|
||||
value: patch.value ? (typeof patch.value === 'object' ? 'object' : patch.value) : 'undefined'
|
||||
})
|
||||
})
|
||||
}
|
||||
this.websocket.send(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,11 +360,73 @@ class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.websocket) {
|
||||
this.websocket.close()
|
||||
this.websocket = null
|
||||
}
|
||||
this.cleanup()
|
||||
this.roomId = null
|
||||
this.emit('close')
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.stopKeepAlive()
|
||||
this.clearReconnectTimeout()
|
||||
|
||||
if (this.websocket) {
|
||||
this.websocket.close(1000, 'Client disconnecting')
|
||||
this.websocket = null
|
||||
}
|
||||
}
|
||||
|
||||
private startKeepAlive(): void {
|
||||
// Send ping every 30 seconds to prevent idle timeout
|
||||
this.keepAliveInterval = setInterval(() => {
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
console.log('🔌 CloudflareAdapter: Sending keep-alive ping')
|
||||
this.websocket.send(JSON.stringify({
|
||||
type: 'ping',
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
}
|
||||
}, 30000) // 30 seconds
|
||||
}
|
||||
|
||||
private stopKeepAlive(): void {
|
||||
if (this.keepAliveInterval) {
|
||||
clearInterval(this.keepAliveInterval)
|
||||
this.keepAliveInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private sendPong(): void {
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
this.websocket.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('❌ CloudflareAdapter: Max reconnection attempts reached, giving up')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000) // Max 30 seconds
|
||||
|
||||
console.log(`🔄 CloudflareAdapter: Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`)
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
if (this.roomId) {
|
||||
console.log(`🔄 CloudflareAdapter: Attempting reconnect ${this.reconnectAttempts}/${this.maxReconnectAttempts}`)
|
||||
this.connect(peerId, peerMetadata)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearReconnectTimeout(): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// Minimal sanitization - only fix critical issues that break TLDraw
|
||||
function minimalSanitizeRecord(record: any): any {
|
||||
const sanitized = { ...record }
|
||||
|
||||
// Only fix critical structural issues
|
||||
if (!sanitized.id) {
|
||||
throw new Error("Record missing required id field")
|
||||
}
|
||||
|
||||
if (!sanitized.typeName) {
|
||||
throw new Error("Record missing required typeName field")
|
||||
}
|
||||
|
||||
// For shapes, only ensure basic required fields exist
|
||||
if (sanitized.typeName === 'shape') {
|
||||
// Ensure required shape fields exist with defaults
|
||||
if (typeof sanitized.x !== 'number') sanitized.x = 0
|
||||
if (typeof sanitized.y !== 'number') sanitized.y = 0
|
||||
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
|
||||
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
|
||||
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
||||
if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {}
|
||||
if (!sanitized.index) sanitized.index = 'a1'
|
||||
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||
|
||||
// Ensure props object exists
|
||||
if (!sanitized.props || typeof sanitized.props !== 'object') {
|
||||
sanitized.props = {}
|
||||
}
|
||||
|
||||
// Only fix type if completely missing
|
||||
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||
// Simple type inference - check for obvious indicators
|
||||
// CRITICAL: Don't infer text type just because richText exists - geo and note shapes can have richText
|
||||
// Only infer text if there's no geo property and richText exists
|
||||
if ((sanitized.props?.richText || sanitized.props?.text) && !sanitized.props?.geo) {
|
||||
sanitized.type = 'text'
|
||||
} else if (sanitized.props?.geo) {
|
||||
sanitized.type = 'geo'
|
||||
} else {
|
||||
sanitized.type = 'geo' // Safe default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -6,8 +6,8 @@ This directory contains the Automerge-based sync implementation that replaces th
|
|||
|
||||
- `AutomergeToTLStore.ts` - Converts Automerge patches to TLdraw store updates
|
||||
- `TLStoreToAutomerge.ts` - Converts TLdraw store changes to Automerge document updates
|
||||
- `useAutomergeStore.ts` - React hook for managing Automerge document state
|
||||
- `useAutomergeSync.ts` - Main sync hook that replaces `useSync` from TLdraw
|
||||
- `useAutomergeStoreV2.ts` - Core React hook for managing Automerge document state with TLdraw
|
||||
- `useAutomergeSync.ts` - Main sync hook that replaces `useSync` from TLdraw (uses V2 internally)
|
||||
- `CloudflareAdapter.ts` - Adapter for Cloudflare Durable Objects and R2 storage
|
||||
- `default_store.ts` - Default TLdraw store structure for new documents
|
||||
- `index.ts` - Main exports
|
||||
|
|
|
|||
|
|
@ -1,191 +1,298 @@
|
|||
import { RecordsDiff, TLRecord } from "@tldraw/tldraw"
|
||||
|
||||
// Helper function to clean NaN values from richText content
|
||||
// This prevents SVG export errors when TLDraw tries to render text with invalid coordinates
|
||||
function cleanRichTextNaN(richText: any): any {
|
||||
if (!richText || typeof richText !== 'object') {
|
||||
return richText
|
||||
}
|
||||
|
||||
// Deep clone to avoid mutating the original
|
||||
const cleaned = JSON.parse(JSON.stringify(richText))
|
||||
|
||||
// Recursively clean content array
|
||||
if (Array.isArray(cleaned.content)) {
|
||||
cleaned.content = cleaned.content.map((item: any) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
// Remove any NaN values from the item
|
||||
const cleanedItem: any = {}
|
||||
for (const key in item) {
|
||||
const value = item[key]
|
||||
// Skip NaN values - they cause SVG export errors
|
||||
if (typeof value === 'number' && isNaN(value)) {
|
||||
// Skip NaN values
|
||||
continue
|
||||
}
|
||||
// Recursively clean nested objects
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
cleanedItem[key] = cleanRichTextNaN(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
cleanedItem[key] = value.map((v: any) =>
|
||||
typeof v === 'object' && v !== null ? cleanRichTextNaN(v) : v
|
||||
)
|
||||
} else {
|
||||
cleanedItem[key] = value
|
||||
}
|
||||
}
|
||||
return cleanedItem
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
function sanitizeRecord(record: TLRecord): TLRecord {
|
||||
const sanitized = { ...record }
|
||||
|
||||
// First, fix any problematic array fields that might cause validation errors
|
||||
// This is a catch-all for any record type that has these fields
|
||||
if ('insets' in sanitized && (sanitized.insets === undefined || !Array.isArray(sanitized.insets))) {
|
||||
console.log(`Fixing insets field for ${sanitized.typeName} record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.insets,
|
||||
originalType: typeof sanitized.insets
|
||||
})
|
||||
;(sanitized as any).insets = [false, false, false, false]
|
||||
}
|
||||
if ('scribbles' in sanitized && (sanitized.scribbles === undefined || !Array.isArray(sanitized.scribbles))) {
|
||||
console.log(`Fixing scribbles field for ${sanitized.typeName} record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.scribbles,
|
||||
originalType: typeof sanitized.scribbles
|
||||
})
|
||||
;(sanitized as any).scribbles = []
|
||||
}
|
||||
// CRITICAL FIXES ONLY - preserve all other properties
|
||||
// This function preserves ALL shape types (native and custom):
|
||||
// - Geo shapes (rectangles, ellipses, etc.) - handled below
|
||||
// - Arrow shapes - handled below
|
||||
// - Custom shapes (ObsNote, Holon, etc.) - all props preserved via deep copy
|
||||
// - All other native shapes (text, note, draw, line, group, image, video, etc.)
|
||||
|
||||
// Fix object fields that might be undefined
|
||||
if ('duplicateProps' in sanitized && (sanitized.duplicateProps === undefined || typeof sanitized.duplicateProps !== 'object')) {
|
||||
console.log(`Fixing duplicateProps field for ${sanitized.typeName} record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.duplicateProps,
|
||||
originalType: typeof sanitized.duplicateProps
|
||||
})
|
||||
;(sanitized as any).duplicateProps = {
|
||||
shapeIds: [],
|
||||
offset: { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
// Fix nested object properties
|
||||
else if ('duplicateProps' in sanitized && sanitized.duplicateProps && typeof sanitized.duplicateProps === 'object') {
|
||||
if (!('shapeIds' in sanitized.duplicateProps) || !Array.isArray(sanitized.duplicateProps.shapeIds)) {
|
||||
console.log(`Fixing duplicateProps.shapeIds field for ${sanitized.typeName} record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.duplicateProps.shapeIds,
|
||||
originalType: typeof sanitized.duplicateProps.shapeIds
|
||||
})
|
||||
;(sanitized as any).duplicateProps.shapeIds = []
|
||||
}
|
||||
// Fix missing offset field
|
||||
if (!('offset' in sanitized.duplicateProps) || typeof sanitized.duplicateProps.offset !== 'object') {
|
||||
console.log(`Fixing duplicateProps.offset field for ${sanitized.typeName} record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.duplicateProps.offset,
|
||||
originalType: typeof sanitized.duplicateProps.offset
|
||||
})
|
||||
;(sanitized as any).duplicateProps.offset = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// Only add fields appropriate for the record type
|
||||
// Ensure required top-level fields exist
|
||||
if (sanitized.typeName === 'shape') {
|
||||
// Shape-specific fields
|
||||
if (!sanitized.x) sanitized.x = 0
|
||||
if (!sanitized.y) sanitized.y = 0
|
||||
if (!sanitized.rotation) sanitized.rotation = 0
|
||||
if (!sanitized.isLocked) sanitized.isLocked = false
|
||||
if (!sanitized.opacity) sanitized.opacity = 1
|
||||
if (!sanitized.meta) sanitized.meta = {}
|
||||
if (typeof sanitized.x !== 'number') sanitized.x = 0
|
||||
if (typeof sanitized.y !== 'number') sanitized.y = 0
|
||||
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
|
||||
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
|
||||
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
||||
// CRITICAL: Preserve all existing meta properties - only create empty object if meta doesn't exist
|
||||
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||
sanitized.meta = {}
|
||||
} else {
|
||||
// Ensure meta is a mutable copy to preserve all properties (including text for rectangles)
|
||||
sanitized.meta = { ...sanitized.meta }
|
||||
}
|
||||
if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {}
|
||||
|
||||
// Geo shape specific fields
|
||||
// CRITICAL: Extract richText BEFORE deep copy to handle TLDraw RichText instances properly
|
||||
// TLDraw RichText objects may have methods/getters that don't serialize well
|
||||
let richTextValue: any = undefined
|
||||
try {
|
||||
// Safely check if richText exists using 'in' operator to avoid triggering getters
|
||||
const props = sanitized.props || {}
|
||||
if ('richText' in props) {
|
||||
try {
|
||||
// Use Object.getOwnPropertyDescriptor to safely check if it's a getter
|
||||
const descriptor = Object.getOwnPropertyDescriptor(props, 'richText')
|
||||
let rt: any = undefined
|
||||
|
||||
if (descriptor && descriptor.get) {
|
||||
// It's a getter - try to call it safely
|
||||
try {
|
||||
rt = descriptor.get.call(props)
|
||||
} catch (getterError) {
|
||||
console.warn(`🔧 TLStoreToAutomerge: Error calling richText getter for shape ${sanitized.id}:`, getterError)
|
||||
rt = undefined
|
||||
}
|
||||
} else {
|
||||
// It's a regular property - access it directly
|
||||
rt = (props as any).richText
|
||||
}
|
||||
|
||||
// Now process the value
|
||||
if (rt !== undefined && rt !== null) {
|
||||
// Check if it's a function (shouldn't happen, but be safe)
|
||||
if (typeof rt === 'function') {
|
||||
console.warn(`🔧 TLStoreToAutomerge: richText is a function for shape ${sanitized.id}, skipping`)
|
||||
richTextValue = { content: [], type: 'doc' }
|
||||
}
|
||||
// Check if it's an array
|
||||
else if (Array.isArray(rt)) {
|
||||
richTextValue = { content: JSON.parse(JSON.stringify(rt)), type: 'doc' }
|
||||
}
|
||||
// Check if it's an object
|
||||
else if (typeof rt === 'object') {
|
||||
// Extract plain object representation - use JSON to ensure it's serializable
|
||||
try {
|
||||
const serialized = JSON.parse(JSON.stringify(rt))
|
||||
richTextValue = {
|
||||
type: serialized.type || 'doc',
|
||||
content: serialized.content !== undefined ? serialized.content : []
|
||||
}
|
||||
} catch (serializeError) {
|
||||
// If serialization fails, try to extract manually
|
||||
richTextValue = {
|
||||
type: (rt as any).type || 'doc',
|
||||
content: (rt as any).content !== undefined ? (rt as any).content : []
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalid type
|
||||
else {
|
||||
console.warn(`🔧 TLStoreToAutomerge: Invalid richText type for shape ${sanitized.id}:`, typeof rt)
|
||||
richTextValue = { content: [], type: 'doc' }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`🔧 TLStoreToAutomerge: Error extracting richText for shape ${sanitized.id}:`, e)
|
||||
richTextValue = { content: [], type: 'doc' }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`🔧 TLStoreToAutomerge: Error checking richText for shape ${sanitized.id}:`, e)
|
||||
}
|
||||
|
||||
// CRITICAL: For all shapes, ensure props is a deep mutable copy to preserve all properties
|
||||
// This is essential for custom shapes like ObsNote and for preserving richText in geo shapes
|
||||
// Use JSON parse/stringify to create a deep copy of nested objects (like richText.content)
|
||||
// Remove richText temporarily to avoid serialization issues
|
||||
try {
|
||||
const propsWithoutRichText: any = {}
|
||||
// Copy all props except richText
|
||||
for (const key in sanitized.props) {
|
||||
if (key !== 'richText') {
|
||||
propsWithoutRichText[key] = (sanitized.props as any)[key]
|
||||
}
|
||||
}
|
||||
sanitized.props = JSON.parse(JSON.stringify(propsWithoutRichText))
|
||||
} catch (e) {
|
||||
console.warn(`🔧 TLStoreToAutomerge: Error deep copying props for shape ${sanitized.id}:`, e)
|
||||
// Fallback: just copy props without deep copy
|
||||
sanitized.props = { ...sanitized.props }
|
||||
if (richTextValue !== undefined) {
|
||||
delete (sanitized.props as any).richText
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: For geo shapes, move w/h/geo from top-level to props (required by TLDraw schema)
|
||||
if (sanitized.type === 'geo') {
|
||||
if (!(sanitized as any).insets) {
|
||||
(sanitized as any).insets = [0, 0, 0, 0]
|
||||
|
||||
// Move w from top-level to props if needed
|
||||
if ('w' in sanitized && sanitized.w !== undefined) {
|
||||
if ((sanitized.props as any).w === undefined) {
|
||||
(sanitized.props as any).w = (sanitized as any).w
|
||||
}
|
||||
delete (sanitized as any).w
|
||||
}
|
||||
if (!(sanitized as any).geo) {
|
||||
(sanitized as any).geo = 'rectangle'
|
||||
|
||||
// Move h from top-level to props if needed
|
||||
if ('h' in sanitized && sanitized.h !== undefined) {
|
||||
if ((sanitized.props as any).h === undefined) {
|
||||
(sanitized.props as any).h = (sanitized as any).h
|
||||
}
|
||||
delete (sanitized as any).h
|
||||
}
|
||||
if (!(sanitized as any).w) {
|
||||
(sanitized as any).w = 100
|
||||
|
||||
// Move geo from top-level to props if needed
|
||||
if ('geo' in sanitized && sanitized.geo !== undefined) {
|
||||
if ((sanitized.props as any).geo === undefined) {
|
||||
(sanitized.props as any).geo = (sanitized as any).geo
|
||||
}
|
||||
delete (sanitized as any).geo
|
||||
}
|
||||
if (!(sanitized as any).h) {
|
||||
(sanitized as any).h = 100
|
||||
|
||||
// CRITICAL: Restore richText for geo shapes after deep copy
|
||||
// Fix richText structure if it exists (preserve content, ensure proper format)
|
||||
if (richTextValue !== undefined) {
|
||||
// Clean NaN values to prevent SVG export errors
|
||||
(sanitized.props as any).richText = cleanRichTextNaN(richTextValue)
|
||||
}
|
||||
// CRITICAL: Preserve meta.text for geo shapes - it's used by runLLMprompt for backwards compatibility
|
||||
// Ensure meta.text is preserved if it exists
|
||||
if ((sanitized.meta as any)?.text !== undefined) {
|
||||
// meta.text is already preserved since we copied meta above
|
||||
// Just ensure it's not accidentally deleted
|
||||
}
|
||||
// Note: We don't delete richText if it's missing - it's optional for geo shapes
|
||||
}
|
||||
|
||||
// CRITICAL: For arrow shapes, preserve text property
|
||||
if (sanitized.type === 'arrow') {
|
||||
// CRITICAL: Preserve text property - only set default if truly missing (preserve empty strings and all other values)
|
||||
if ((sanitized.props as any).text === undefined || (sanitized.props as any).text === null) {
|
||||
(sanitized.props as any).text = ''
|
||||
}
|
||||
// Note: We preserve text even if it's an empty string - that's a valid value
|
||||
}
|
||||
|
||||
// CRITICAL: For note shapes, preserve richText property (required for note shapes)
|
||||
if (sanitized.type === 'note') {
|
||||
// CRITICAL: Use the extracted richText value if available, otherwise create default
|
||||
if (richTextValue !== undefined) {
|
||||
// Clean NaN values to prevent SVG export errors
|
||||
(sanitized.props as any).richText = cleanRichTextNaN(richTextValue)
|
||||
} else {
|
||||
// Note shapes require richText - create default if missing
|
||||
(sanitized.props as any).richText = { content: [], type: 'doc' }
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: For ObsNote shapes, ensure all props are preserved (title, content, tags, etc.)
|
||||
if (sanitized.type === 'ObsNote') {
|
||||
// Props are already a mutable copy from above, so all properties are preserved
|
||||
// No special handling needed - just ensure props exists (which we did above)
|
||||
}
|
||||
|
||||
// CRITICAL: For image/video shapes, fix crop structure if it exists
|
||||
if (sanitized.type === 'image' || sanitized.type === 'video') {
|
||||
const props = (sanitized.props as any)
|
||||
|
||||
if (props.crop !== null && props.crop !== undefined) {
|
||||
// Fix crop structure if it has wrong format
|
||||
if (!props.crop.topLeft || !props.crop.bottomRight) {
|
||||
if (props.crop.x !== undefined && props.crop.y !== undefined) {
|
||||
// Convert old format { x, y, w, h } to new format
|
||||
props.crop = {
|
||||
topLeft: { x: props.crop.x || 0, y: props.crop.y || 0 },
|
||||
bottomRight: {
|
||||
x: (props.crop.x || 0) + (props.crop.w || 1),
|
||||
y: (props.crop.y || 0) + (props.crop.h || 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalid structure: set to default
|
||||
props.crop = {
|
||||
topLeft: { x: 0, y: 0 },
|
||||
bottomRight: { x: 1, y: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: For group shapes, remove w/h from props (they cause validation errors)
|
||||
if (sanitized.type === 'group') {
|
||||
if ('w' in sanitized.props) delete (sanitized.props as any).w
|
||||
if ('h' in sanitized.props) delete (sanitized.props as any).h
|
||||
}
|
||||
} else if (sanitized.typeName === 'document') {
|
||||
// Document-specific fields only
|
||||
if (!sanitized.meta) sanitized.meta = {}
|
||||
} else if (sanitized.typeName === 'instance') {
|
||||
// Instance-specific fields only
|
||||
if (!sanitized.meta) sanitized.meta = {}
|
||||
|
||||
// Fix properties that need to be objects instead of null/undefined
|
||||
if ('scribble' in sanitized) {
|
||||
console.log(`Removing invalid scribble property from instance record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.scribble
|
||||
})
|
||||
delete (sanitized as any).scribble
|
||||
// CRITICAL: Preserve all existing meta properties
|
||||
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||
sanitized.meta = {}
|
||||
} else {
|
||||
sanitized.meta = { ...sanitized.meta }
|
||||
}
|
||||
} else if (sanitized.typeName === 'instance') {
|
||||
// CRITICAL: Preserve all existing meta properties
|
||||
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||
sanitized.meta = {}
|
||||
} else {
|
||||
sanitized.meta = { ...sanitized.meta }
|
||||
}
|
||||
// Only fix critical instance fields that cause validation errors
|
||||
if ('brush' in sanitized && (sanitized.brush === null || sanitized.brush === undefined)) {
|
||||
console.log(`Fixing brush property to be an object for instance record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.brush
|
||||
})
|
||||
;(sanitized as any).brush = { x: 0, y: 0, w: 0, h: 0 }
|
||||
(sanitized as any).brush = { x: 0, y: 0, w: 0, h: 0 }
|
||||
}
|
||||
if ('zoomBrush' in sanitized && (sanitized.zoomBrush === null || sanitized.zoomBrush === undefined)) {
|
||||
console.log(`Fixing zoomBrush property to be an object for instance record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.zoomBrush
|
||||
})
|
||||
;(sanitized as any).zoomBrush = {}
|
||||
(sanitized as any).zoomBrush = { x: 0, y: 0, w: 0, h: 0 }
|
||||
}
|
||||
if ('insets' in sanitized && (sanitized.insets === undefined || !Array.isArray(sanitized.insets))) {
|
||||
console.log(`Fixing insets property to be an array for instance record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.insets
|
||||
})
|
||||
;(sanitized as any).insets = [false, false, false, false]
|
||||
(sanitized as any).insets = [false, false, false, false]
|
||||
}
|
||||
if ('canMoveCamera' in sanitized) {
|
||||
console.log(`Removing invalid canMoveCamera property from instance record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.canMoveCamera
|
||||
})
|
||||
delete (sanitized as any).canMoveCamera
|
||||
if ('scribbles' in sanitized && (sanitized.scribbles === undefined || !Array.isArray(sanitized.scribbles))) {
|
||||
(sanitized as any).scribbles = []
|
||||
}
|
||||
|
||||
// Fix isCoarsePointer property to be a boolean
|
||||
if ('isCoarsePointer' in sanitized && typeof sanitized.isCoarsePointer !== 'boolean') {
|
||||
console.log(`Fixing isCoarsePointer property to be a boolean for instance record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.isCoarsePointer
|
||||
})
|
||||
;(sanitized as any).isCoarsePointer = false
|
||||
}
|
||||
|
||||
// Fix isHoveringCanvas property to be a boolean
|
||||
if ('isHoveringCanvas' in sanitized && typeof sanitized.isHoveringCanvas !== 'boolean') {
|
||||
console.log(`Fixing isHoveringCanvas property to be a boolean for instance record:`, {
|
||||
id: sanitized.id,
|
||||
originalValue: sanitized.isHoveringCanvas
|
||||
})
|
||||
;(sanitized as any).isHoveringCanvas = false
|
||||
}
|
||||
|
||||
|
||||
// Add required fields that might be missing
|
||||
const requiredFields = {
|
||||
followingUserId: null,
|
||||
opacityForNextShape: 1,
|
||||
stylesForNextShape: {},
|
||||
brush: { x: 0, y: 0, w: 0, h: 0 },
|
||||
zoomBrush: { x: 0, y: 0, w: 0, h: 0 },
|
||||
scribbles: [],
|
||||
cursor: { type: "default", rotation: 0 },
|
||||
isFocusMode: false,
|
||||
exportBackground: true,
|
||||
isDebugMode: false,
|
||||
isToolLocked: false,
|
||||
screenBounds: { x: 0, y: 0, w: 720, h: 400 },
|
||||
isGridMode: false,
|
||||
isPenMode: false,
|
||||
chatMessage: "",
|
||||
isChatting: false,
|
||||
highlightedUserIds: [],
|
||||
isFocused: true,
|
||||
devicePixelRatio: 2,
|
||||
insets: [false, false, false, false],
|
||||
isCoarsePointer: false,
|
||||
isHoveringCanvas: false,
|
||||
openMenus: [],
|
||||
isChangingStyle: false,
|
||||
isReadonly: false,
|
||||
duplicateProps: { // Object field that was missing
|
||||
if ('duplicateProps' in sanitized && (sanitized.duplicateProps === undefined || typeof sanitized.duplicateProps !== 'object')) {
|
||||
(sanitized as any).duplicateProps = {
|
||||
shapeIds: [],
|
||||
offset: { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing required fields
|
||||
Object.entries(requiredFields).forEach(([key, defaultValue]) => {
|
||||
if (!(key in sanitized)) {
|
||||
console.log(`Adding missing ${key} field to instance record:`, {
|
||||
id: sanitized.id,
|
||||
defaultValue
|
||||
})
|
||||
;(sanitized as any)[key] = defaultValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return sanitized
|
||||
|
|
@ -206,15 +313,159 @@ export function applyTLStoreChangesToAutomerge(
|
|||
Object.values(changes.added).forEach((record) => {
|
||||
// Sanitize record before saving to ensure all required fields are present
|
||||
const sanitizedRecord = sanitizeRecord(record)
|
||||
doc.store[record.id] = sanitizedRecord
|
||||
// CRITICAL: Create a deep copy to ensure all properties (including richText and text) are preserved
|
||||
// This prevents Automerge from treating the object as read-only
|
||||
const recordToSave = JSON.parse(JSON.stringify(sanitizedRecord))
|
||||
// Let Automerge handle the assignment - it will merge automatically
|
||||
doc.store[record.id] = recordToSave
|
||||
})
|
||||
}
|
||||
|
||||
// Handle updated records
|
||||
// Simplified: Replace entire record and let Automerge handle merging
|
||||
// This is simpler than deep comparison and leverages Automerge's conflict resolution
|
||||
if (changes.updated) {
|
||||
Object.values(changes.updated).forEach(([_, record]) => {
|
||||
// DEBUG: Log richText, meta.text, and Obsidian note properties before sanitization
|
||||
if (record.typeName === 'shape') {
|
||||
if (record.type === 'geo' && (record.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has richText before sanitization:`, {
|
||||
hasRichText: !!(record.props as any).richText,
|
||||
richTextType: typeof (record.props as any).richText,
|
||||
richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content'
|
||||
})
|
||||
}
|
||||
if (record.type === 'geo' && (record.meta as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has meta.text before sanitization:`, {
|
||||
hasMetaText: !!(record.meta as any).text,
|
||||
metaTextValue: (record.meta as any).text,
|
||||
metaTextType: typeof (record.meta as any).text
|
||||
})
|
||||
}
|
||||
if (record.type === 'note' && (record.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Note shape ${record.id} has richText before sanitization:`, {
|
||||
hasRichText: !!(record.props as any).richText,
|
||||
richTextType: typeof (record.props as any).richText,
|
||||
richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray((record.props as any).richText?.content) ? (record.props as any).richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (record.type === 'arrow' && (record.props as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${record.id} has text before sanitization:`, {
|
||||
hasText: !!(record.props as any).text,
|
||||
textValue: (record.props as any).text,
|
||||
textType: typeof (record.props as any).text
|
||||
})
|
||||
}
|
||||
if (record.type === 'ObsNote') {
|
||||
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${record.id} before sanitization:`, {
|
||||
hasTitle: !!(record.props as any).title,
|
||||
hasContent: !!(record.props as any).content,
|
||||
hasTags: Array.isArray((record.props as any).tags),
|
||||
title: (record.props as any).title,
|
||||
contentLength: (record.props as any).content?.length || 0,
|
||||
tagsCount: Array.isArray((record.props as any).tags) ? (record.props as any).tags.length : 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedRecord = sanitizeRecord(record)
|
||||
deepCompareAndUpdate(doc.store[record.id], sanitizedRecord)
|
||||
|
||||
// DEBUG: Log richText, meta.text, and Obsidian note properties after sanitization
|
||||
if (sanitizedRecord.typeName === 'shape') {
|
||||
if (sanitizedRecord.type === 'geo' && (sanitizedRecord.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has richText after sanitization:`, {
|
||||
hasRichText: !!(sanitizedRecord.props as any).richText,
|
||||
richTextType: typeof (sanitizedRecord.props as any).richText,
|
||||
richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content'
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'geo' && (sanitizedRecord.meta as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has meta.text after sanitization:`, {
|
||||
hasMetaText: !!(sanitizedRecord.meta as any).text,
|
||||
metaTextValue: (sanitizedRecord.meta as any).text,
|
||||
metaTextType: typeof (sanitizedRecord.meta as any).text
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'note' && (sanitizedRecord.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Note shape ${sanitizedRecord.id} has richText after sanitization:`, {
|
||||
hasRichText: !!(sanitizedRecord.props as any).richText,
|
||||
richTextType: typeof (sanitizedRecord.props as any).richText,
|
||||
richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray((sanitizedRecord.props as any).richText?.content) ? (sanitizedRecord.props as any).richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'arrow' && (sanitizedRecord.props as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${sanitizedRecord.id} has text after sanitization:`, {
|
||||
hasText: !!(sanitizedRecord.props as any).text,
|
||||
textValue: (sanitizedRecord.props as any).text,
|
||||
textType: typeof (sanitizedRecord.props as any).text
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'ObsNote') {
|
||||
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${sanitizedRecord.id} after sanitization:`, {
|
||||
hasTitle: !!(sanitizedRecord.props as any).title,
|
||||
hasContent: !!(sanitizedRecord.props as any).content,
|
||||
hasTags: Array.isArray((sanitizedRecord.props as any).tags),
|
||||
title: (sanitizedRecord.props as any).title,
|
||||
contentLength: (sanitizedRecord.props as any).content?.length || 0,
|
||||
tagsCount: Array.isArray((sanitizedRecord.props as any).tags) ? (sanitizedRecord.props as any).tags.length : 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Create a deep copy to ensure all properties (including richText and text) are preserved
|
||||
// This prevents Automerge from treating the object as read-only
|
||||
// Note: sanitizedRecord.props is already a deep copy from sanitizeRecord, but we need to deep copy the entire record
|
||||
const recordToSave = JSON.parse(JSON.stringify(sanitizedRecord))
|
||||
|
||||
// DEBUG: Log richText, meta.text, and Obsidian note properties after deep copy
|
||||
if (recordToSave.typeName === 'shape') {
|
||||
if (recordToSave.type === 'geo' && recordToSave.props?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has richText after deep copy:`, {
|
||||
hasRichText: !!recordToSave.props.richText,
|
||||
richTextType: typeof recordToSave.props.richText,
|
||||
richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'geo' && recordToSave.meta?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has meta.text after deep copy:`, {
|
||||
hasMetaText: !!recordToSave.meta.text,
|
||||
metaTextValue: recordToSave.meta.text,
|
||||
metaTextType: typeof recordToSave.meta.text
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'note' && recordToSave.props?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Note shape ${recordToSave.id} has richText after deep copy:`, {
|
||||
hasRichText: !!recordToSave.props.richText,
|
||||
richTextType: typeof recordToSave.props.richText,
|
||||
richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'arrow' && recordToSave.props?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${recordToSave.id} has text after deep copy:`, {
|
||||
hasText: !!recordToSave.props.text,
|
||||
textValue: recordToSave.props.text,
|
||||
textType: typeof recordToSave.props.text
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'ObsNote') {
|
||||
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${recordToSave.id} after deep copy:`, {
|
||||
hasTitle: !!recordToSave.props.title,
|
||||
hasContent: !!recordToSave.props.content,
|
||||
hasTags: Array.isArray(recordToSave.props.tags),
|
||||
title: recordToSave.props.title,
|
||||
contentLength: recordToSave.props.content?.length || 0,
|
||||
tagsCount: Array.isArray(recordToSave.props.tags) ? recordToSave.props.tags.length : 0,
|
||||
allPropsKeys: Object.keys(recordToSave.props || {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the entire record - Automerge will handle merging with concurrent changes
|
||||
doc.store[record.id] = recordToSave
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -227,55 +478,5 @@ export function applyTLStoreChangesToAutomerge(
|
|||
|
||||
}
|
||||
|
||||
function deepCompareAndUpdate(objectA: any, objectB: any) {
|
||||
if (Array.isArray(objectB)) {
|
||||
if (!Array.isArray(objectA)) {
|
||||
// if objectA is not an array, replace it with objectB
|
||||
objectA = objectB.slice()
|
||||
} else {
|
||||
// compare and update array elements
|
||||
for (let i = 0; i < objectB.length; i++) {
|
||||
if (i >= objectA.length) {
|
||||
objectA.push(objectB[i])
|
||||
} else {
|
||||
if (isObject(objectB[i]) || Array.isArray(objectB[i])) {
|
||||
// if element is an object or array, recursively compare and update
|
||||
deepCompareAndUpdate(objectA[i], objectB[i])
|
||||
} else if (objectA[i] !== objectB[i]) {
|
||||
// update the element
|
||||
objectA[i] = objectB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
// remove extra elements
|
||||
if (objectA.length > objectB.length) {
|
||||
objectA.splice(objectB.length)
|
||||
}
|
||||
}
|
||||
} else if (isObject(objectB)) {
|
||||
for (const [key, value] of Object.entries(objectB)) {
|
||||
if (objectA[key] === undefined) {
|
||||
// if key is not in objectA, add it
|
||||
objectA[key] = value
|
||||
} else {
|
||||
if (isObject(value) || Array.isArray(value)) {
|
||||
// if value is an object or array, recursively compare and update
|
||||
deepCompareAndUpdate(objectA[key], value)
|
||||
} else if (objectA[key] !== value) {
|
||||
// update the value
|
||||
objectA[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(objectA)) {
|
||||
if ((objectB as any)[key] === undefined) {
|
||||
// if key is not in objectB, remove it
|
||||
delete objectA[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
// Removed deepCompareAndUpdate - we now replace entire records and let Automerge handle merging
|
||||
// This simplifies the code and leverages Automerge's built-in conflict resolution
|
||||
|
|
|
|||
|
|
@ -115,7 +115,8 @@ export const DEFAULT_STORE = {
|
|||
"com.tldraw.shape.container": 0,
|
||||
"com.tldraw.shape.element": 0,
|
||||
"com.tldraw.binding.arrow": 0,
|
||||
"com.tldraw.binding.layout": 0
|
||||
"com.tldraw.binding.layout": 0,
|
||||
"obsidian_vault": 1
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ export function init(doc: TLStoreSnapshot) {
|
|||
Object.assign(doc, DEFAULT_STORE)
|
||||
}
|
||||
|
||||
// Export the new V2 approach as the default
|
||||
// Export the V2 implementation
|
||||
export * from "./useAutomergeStoreV2"
|
||||
export * from "./useAutomergeSync"
|
||||
|
||||
// Keep the old store for backward compatibility (deprecated)
|
||||
// export * from "./useAutomergeStore"
|
||||
|
|
|
|||
|
|
@ -1,622 +0,0 @@
|
|||
import {
|
||||
TLAnyShapeUtilConstructor,
|
||||
TLRecord,
|
||||
TLStoreWithStatus,
|
||||
createTLStore,
|
||||
defaultShapeUtils,
|
||||
HistoryEntry,
|
||||
getUserPreferences,
|
||||
setUserPreferences,
|
||||
defaultUserPreferences,
|
||||
createPresenceStateDerivation,
|
||||
InstancePresenceRecordType,
|
||||
computed,
|
||||
react,
|
||||
TLStoreSnapshot,
|
||||
sortById,
|
||||
loadSnapshot,
|
||||
} from "@tldraw/tldraw"
|
||||
import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema"
|
||||
import { useEffect, useState } from "react"
|
||||
import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo"
|
||||
import {
|
||||
useLocalAwareness,
|
||||
useRemoteAwareness,
|
||||
} from "@automerge/automerge-repo-react-hooks"
|
||||
|
||||
import { applyAutomergePatchesToTLStore } from "./AutomergeToTLStore.js"
|
||||
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
|
||||
|
||||
// Import custom shape utilities
|
||||
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
|
||||
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
|
||||
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
||||
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
|
||||
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
|
||||
import { SlideShape } from "@/shapes/SlideShapeUtil"
|
||||
import { PromptShape } from "@/shapes/PromptShapeUtil"
|
||||
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
|
||||
|
||||
export function useAutomergeStore({
|
||||
handle,
|
||||
}: {
|
||||
handle: DocHandle<TLStoreSnapshot>
|
||||
userId: string
|
||||
}): TLStoreWithStatus {
|
||||
// Deprecation warning
|
||||
console.warn(
|
||||
"⚠️ useAutomergeStore is deprecated and has known migration issues. " +
|
||||
"Please use useAutomergeStoreV2 or useAutomergeSync instead for better reliability."
|
||||
)
|
||||
// Create a custom schema that includes all the custom shapes
|
||||
const customSchema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
ChatBox: {
|
||||
props: ChatBoxShape.props,
|
||||
},
|
||||
VideoChat: {
|
||||
props: VideoChatShape.props,
|
||||
},
|
||||
Embed: {
|
||||
props: EmbedShape.props,
|
||||
},
|
||||
Markdown: {
|
||||
props: MarkdownShape.props,
|
||||
},
|
||||
MycrozineTemplate: {
|
||||
props: MycrozineTemplateShape.props,
|
||||
},
|
||||
Slide: {
|
||||
props: SlideShape.props,
|
||||
},
|
||||
Prompt: {
|
||||
props: PromptShape.props,
|
||||
},
|
||||
SharedPiano: {
|
||||
props: SharedPianoShape.props,
|
||||
},
|
||||
},
|
||||
bindings: defaultBindingSchemas,
|
||||
})
|
||||
|
||||
const [store] = useState(() => {
|
||||
const store = createTLStore({
|
||||
schema: customSchema,
|
||||
})
|
||||
return store
|
||||
})
|
||||
|
||||
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
|
||||
status: "loading",
|
||||
})
|
||||
|
||||
/* -------------------- TLDraw <--> Automerge -------------------- */
|
||||
useEffect(() => {
|
||||
// Early return if handle is not available
|
||||
if (!handle) {
|
||||
setStoreWithStatus({ status: "loading" })
|
||||
return
|
||||
}
|
||||
|
||||
const unsubs: (() => void)[] = []
|
||||
|
||||
// A hacky workaround to prevent local changes from being applied twice
|
||||
// once into the automerge doc and then back again.
|
||||
let preventPatchApplications = false
|
||||
|
||||
/* TLDraw to Automerge */
|
||||
function syncStoreChangesToAutomergeDoc({
|
||||
changes,
|
||||
}: HistoryEntry<TLRecord>) {
|
||||
preventPatchApplications = true
|
||||
handle.change((doc) => {
|
||||
applyTLStoreChangesToAutomerge(doc, changes)
|
||||
})
|
||||
preventPatchApplications = false
|
||||
}
|
||||
|
||||
unsubs.push(
|
||||
store.listen(syncStoreChangesToAutomergeDoc, {
|
||||
source: "user",
|
||||
scope: "document",
|
||||
})
|
||||
)
|
||||
|
||||
/* Automerge to TLDraw */
|
||||
const syncAutomergeDocChangesToStore = ({
|
||||
patches,
|
||||
}: DocHandleChangePayload<any>) => {
|
||||
if (preventPatchApplications) return
|
||||
|
||||
applyAutomergePatchesToTLStore(patches, store)
|
||||
}
|
||||
|
||||
handle.on("change", syncAutomergeDocChangesToStore)
|
||||
unsubs.push(() => handle.off("change", syncAutomergeDocChangesToStore))
|
||||
|
||||
/* Defer rendering until the document is ready */
|
||||
// TODO: need to think through the various status possibilities here and how they map
|
||||
handle.whenReady().then(() => {
|
||||
try {
|
||||
const doc = handle.doc()
|
||||
if (!doc) throw new Error("Document not found")
|
||||
if (!doc.store) throw new Error("Document store not initialized")
|
||||
|
||||
// Clean the store data to remove any problematic text properties that might cause migration issues
|
||||
const cleanedStore = JSON.parse(JSON.stringify(doc.store))
|
||||
|
||||
// Clean up any problematic text properties that might cause migration issues
|
||||
const shapesToRemove: string[] = []
|
||||
|
||||
Object.keys(cleanedStore).forEach(key => {
|
||||
const record = cleanedStore[key]
|
||||
if (record && record.typeName === 'shape') {
|
||||
let shouldRemove = false
|
||||
|
||||
// Migrate old Transcribe shapes to geo shapes
|
||||
if (record.type === 'Transcribe') {
|
||||
console.log(`Migrating old Transcribe shape ${key} to geo shape`)
|
||||
record.type = 'geo'
|
||||
|
||||
// Ensure required geo props exist
|
||||
if (!record.props.geo) record.props.geo = 'rectangle'
|
||||
if (!record.props.fill) record.props.fill = 'solid'
|
||||
if (!record.props.color) record.props.color = 'white'
|
||||
if (!record.props.dash) record.props.dash = 'draw'
|
||||
if (!record.props.size) record.props.size = 'm'
|
||||
if (!record.props.font) record.props.font = 'draw'
|
||||
if (!record.props.align) record.props.align = 'start'
|
||||
if (!record.props.verticalAlign) record.props.verticalAlign = 'start'
|
||||
if (!record.props.growY) record.props.growY = 0
|
||||
if (!record.props.url) record.props.url = ''
|
||||
if (!record.props.scale) record.props.scale = 1
|
||||
if (!record.props.labelColor) record.props.labelColor = 'black'
|
||||
if (!record.props.richText) record.props.richText = [] as any
|
||||
|
||||
// Move transcript text from props to meta
|
||||
if (record.props.transcript) {
|
||||
if (!record.meta) record.meta = {}
|
||||
record.meta.text = record.props.transcript
|
||||
delete record.props.transcript
|
||||
}
|
||||
|
||||
// Clean up other old Transcribe-specific props
|
||||
const oldProps = ['isRecording', 'transcriptSegments', 'speakers', 'currentSpeakerId',
|
||||
'interimText', 'isCompleted', 'aiSummary', 'language', 'autoScroll',
|
||||
'showTimestamps', 'showSpeakerLabels', 'manualClear']
|
||||
oldProps.forEach(prop => {
|
||||
if (record.props[prop] !== undefined) {
|
||||
delete record.props[prop]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle text shapes
|
||||
if (record.type === 'text' && record.props) {
|
||||
// Ensure text property is a string
|
||||
if (typeof record.props.text !== 'string') {
|
||||
console.warn('Fixing invalid text property for text shape:', key)
|
||||
record.props.text = record.props.text || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other shapes that might have text properties
|
||||
if (record.props && record.props.text !== undefined) {
|
||||
if (typeof record.props.text !== 'string') {
|
||||
console.warn('Fixing invalid text property for shape:', key, 'type:', record.type)
|
||||
record.props.text = record.props.text || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle rich text content that might be undefined or invalid
|
||||
if (record.props && record.props.richText !== undefined) {
|
||||
if (!Array.isArray(record.props.richText)) {
|
||||
console.warn('Fixing invalid richText property for shape:', key, 'type:', record.type)
|
||||
record.props.richText = [] as any
|
||||
} else {
|
||||
// Clean up any invalid rich text entries
|
||||
record.props.richText = record.props.richText.filter((item: any) =>
|
||||
item && typeof item === 'object' && item.type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any other potentially problematic properties that might cause migration issues
|
||||
if (record.props) {
|
||||
// Remove any properties that are null or undefined
|
||||
Object.keys(record.props).forEach(propKey => {
|
||||
if (record.props[propKey] === null || record.props[propKey] === undefined) {
|
||||
console.warn(`Removing null/undefined property ${propKey} from shape:`, key, 'type:', record.type)
|
||||
delete record.props[propKey]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If the shape still looks problematic, mark it for removal
|
||||
if (record.props && Object.keys(record.props).length === 0) {
|
||||
console.warn('Removing shape with empty props:', key, 'type:', record.type)
|
||||
shouldRemove = true
|
||||
}
|
||||
|
||||
// For geo shapes, ensure basic properties exist
|
||||
if (record.type === 'geo' && record.props) {
|
||||
if (!record.props.geo) record.props.geo = 'rectangle'
|
||||
if (!record.props.fill) record.props.fill = 'solid'
|
||||
if (!record.props.color) record.props.color = 'white'
|
||||
}
|
||||
|
||||
if (shouldRemove) {
|
||||
shapesToRemove.push(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Remove problematic shapes
|
||||
shapesToRemove.forEach(key => {
|
||||
console.warn('Removing problematic shape:', key)
|
||||
delete cleanedStore[key]
|
||||
})
|
||||
|
||||
// Log the final state of the cleaned store
|
||||
const remainingShapes = Object.values(cleanedStore).filter((record: any) =>
|
||||
record && record.typeName === 'shape'
|
||||
)
|
||||
console.log(`Cleaned store: ${remainingShapes.length} shapes remaining`)
|
||||
|
||||
// Additional aggressive cleaning to prevent migration errors
|
||||
// Set ALL richText properties to proper structure instead of deleting them
|
||||
Object.keys(cleanedStore).forEach(key => {
|
||||
const record = cleanedStore[key]
|
||||
if (record && record.typeName === 'shape' && record.props && record.props.richText !== undefined) {
|
||||
console.warn('Setting richText to proper structure to prevent migration error:', key, 'type:', record.type)
|
||||
record.props.richText = [] as any
|
||||
}
|
||||
})
|
||||
|
||||
// Remove ALL text properties that might be causing issues
|
||||
Object.keys(cleanedStore).forEach(key => {
|
||||
const record = cleanedStore[key]
|
||||
if (record && record.typeName === 'shape' && record.props && record.props.text !== undefined) {
|
||||
// Only keep text for actual text shapes
|
||||
if (record.type !== 'text') {
|
||||
console.warn('Removing text property from non-text shape to prevent migration error:', key, 'type:', record.type)
|
||||
delete record.props.text
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Final cleanup: remove any shapes that still have problematic properties
|
||||
const finalShapesToRemove: string[] = []
|
||||
Object.keys(cleanedStore).forEach(key => {
|
||||
const record = cleanedStore[key]
|
||||
if (record && record.typeName === 'shape') {
|
||||
// Remove any shape that has problematic text properties (but keep richText as proper structure)
|
||||
if (record.props && (record.props.text !== undefined && record.type !== 'text')) {
|
||||
console.warn('Removing shape with remaining problematic text properties:', key, 'type:', record.type)
|
||||
finalShapesToRemove.push(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Remove the final problematic shapes
|
||||
finalShapesToRemove.forEach(key => {
|
||||
console.warn('Final removal of problematic shape:', key)
|
||||
delete cleanedStore[key]
|
||||
})
|
||||
|
||||
// Log the final cleaned state
|
||||
const finalShapes = Object.values(cleanedStore).filter((record: any) =>
|
||||
record && record.typeName === 'shape'
|
||||
)
|
||||
console.log(`Final cleaned store: ${finalShapes.length} shapes remaining`)
|
||||
|
||||
// Try to load the snapshot with a more defensive approach
|
||||
let loadSuccess = false
|
||||
|
||||
// Skip loadSnapshot entirely to avoid migration issues
|
||||
console.log('Skipping loadSnapshot to avoid migration errors - starting with clean store')
|
||||
|
||||
// Manually add the cleaned shapes back to the store without going through migration
|
||||
try {
|
||||
store.mergeRemoteChanges(() => {
|
||||
// Add only the essential store records first
|
||||
const essentialRecords: any[] = []
|
||||
Object.values(cleanedStore).forEach((record: any) => {
|
||||
if (record && record.typeName === 'store' && record.id) {
|
||||
essentialRecords.push(record)
|
||||
}
|
||||
})
|
||||
|
||||
if (essentialRecords.length > 0) {
|
||||
store.put(essentialRecords)
|
||||
console.log(`Added ${essentialRecords.length} essential records to store`)
|
||||
}
|
||||
|
||||
// Add the cleaned shapes
|
||||
const safeShapes: any[] = []
|
||||
Object.values(cleanedStore).forEach((record: any) => {
|
||||
if (record && record.typeName === 'shape' && record.type && record.id) {
|
||||
// Only add shapes that are safe (no text properties, but richText can be proper structure)
|
||||
if (record.props &&
|
||||
!record.props.text &&
|
||||
record.type !== 'text') {
|
||||
safeShapes.push(record)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (safeShapes.length > 0) {
|
||||
store.put(safeShapes)
|
||||
console.log(`Added ${safeShapes.length} safe shapes to store`)
|
||||
}
|
||||
})
|
||||
loadSuccess = true
|
||||
} catch (manualError) {
|
||||
console.error('Manual shape addition failed:', manualError)
|
||||
loadSuccess = true // Still consider it successful, just with empty store
|
||||
}
|
||||
|
||||
// If we still haven't succeeded, try to completely bypass the migration by creating a new store
|
||||
if (!loadSuccess) {
|
||||
console.log('Attempting to create a completely new store to bypass migration...')
|
||||
try {
|
||||
// Create a new store with the same schema
|
||||
const newStore = createTLStore({
|
||||
schema: customSchema,
|
||||
})
|
||||
|
||||
// Replace the current store with the new one
|
||||
Object.assign(store, newStore)
|
||||
|
||||
// Try to manually add safe shapes to the new store
|
||||
store.mergeRemoteChanges(() => {
|
||||
const safeShapes: any[] = []
|
||||
Object.values(cleanedStore).forEach((record: any) => {
|
||||
if (record && record.typeName === 'shape' && record.type && record.id) {
|
||||
// Only add shapes that don't have problematic properties
|
||||
if (record.props &&
|
||||
(!record.props.text || typeof record.props.text === 'string') &&
|
||||
(!record.props.richText || Array.isArray(record.props.richText))) {
|
||||
safeShapes.push(record)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Found ${safeShapes.length} safe shapes to add to new store`)
|
||||
if (safeShapes.length > 0) {
|
||||
store.put(safeShapes)
|
||||
console.log(`Added ${safeShapes.length} safe shapes to new store`)
|
||||
}
|
||||
})
|
||||
|
||||
loadSuccess = true
|
||||
} catch (newStoreError) {
|
||||
console.error('New store creation also failed:', newStoreError)
|
||||
console.log('Continuing with completely empty store')
|
||||
}
|
||||
}
|
||||
|
||||
// If we still haven't succeeded, try to completely bypass the migration by using a different approach
|
||||
if (!loadSuccess) {
|
||||
console.log('Attempting to completely bypass migration...')
|
||||
try {
|
||||
// Create a completely new store and manually add only the essential data
|
||||
const newStore = createTLStore({
|
||||
schema: customSchema,
|
||||
})
|
||||
|
||||
// Replace the current store with the new one
|
||||
Object.assign(store, newStore)
|
||||
|
||||
// Manually add only the essential data without going through migration
|
||||
store.mergeRemoteChanges(() => {
|
||||
// Add only the essential store records
|
||||
const essentialRecords: any[] = []
|
||||
Object.values(cleanedStore).forEach((record: any) => {
|
||||
if (record && record.typeName === 'store' && record.id) {
|
||||
essentialRecords.push(record)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Found ${essentialRecords.length} essential records to add`)
|
||||
if (essentialRecords.length > 0) {
|
||||
store.put(essentialRecords)
|
||||
console.log(`Added ${essentialRecords.length} essential records to new store`)
|
||||
}
|
||||
})
|
||||
|
||||
loadSuccess = true
|
||||
} catch (bypassError) {
|
||||
console.error('Migration bypass also failed:', bypassError)
|
||||
console.log('Continuing with completely empty store')
|
||||
}
|
||||
}
|
||||
|
||||
// If we still haven't succeeded, try the most aggressive approach: completely bypass loadSnapshot
|
||||
if (!loadSuccess) {
|
||||
console.log('Attempting most aggressive bypass - skipping loadSnapshot entirely...')
|
||||
try {
|
||||
// Create a completely new store
|
||||
const newStore = createTLStore({
|
||||
schema: customSchema,
|
||||
})
|
||||
|
||||
// Replace the current store with the new one
|
||||
Object.assign(store, newStore)
|
||||
|
||||
// Don't try to load any snapshot data - just start with a clean store
|
||||
console.log('Starting with completely clean store to avoid migration issues')
|
||||
loadSuccess = true
|
||||
} catch (aggressiveError) {
|
||||
console.error('Most aggressive bypass also failed:', aggressiveError)
|
||||
console.log('Continuing with completely empty store')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus: "online",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in handle.whenReady():', error)
|
||||
setStoreWithStatus({
|
||||
status: "error",
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
})
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Promise rejection in handle.whenReady():', error)
|
||||
setStoreWithStatus({
|
||||
status: "error",
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
})
|
||||
})
|
||||
|
||||
// Add a global error handler for unhandled promise rejections
|
||||
const originalConsoleError = console.error
|
||||
console.error = (...args) => {
|
||||
if (args[0] && typeof args[0] === 'string' && args[0].includes('Cannot read properties of undefined (reading \'split\')')) {
|
||||
console.warn('Caught migration error, attempting recovery...')
|
||||
// Try to recover by setting a clean store status
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus: "online",
|
||||
})
|
||||
return
|
||||
}
|
||||
originalConsoleError.apply(console, args)
|
||||
}
|
||||
|
||||
// Add a global error handler for unhandled errors
|
||||
const originalErrorHandler = window.onerror
|
||||
window.onerror = (message, source, lineno, colno, error) => {
|
||||
if (message && typeof message === 'string' && message.includes('Cannot read properties of undefined (reading \'split\')')) {
|
||||
console.warn('Caught global migration error, attempting recovery...')
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus: "online",
|
||||
})
|
||||
return true // Prevent default error handling
|
||||
}
|
||||
if (originalErrorHandler) {
|
||||
return originalErrorHandler(message, source, lineno, colno, error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Add a global handler for unhandled promise rejections
|
||||
const originalUnhandledRejection = window.onunhandledrejection
|
||||
window.onunhandledrejection = (event) => {
|
||||
if (event.reason && event.reason.message && event.reason.message.includes('Cannot read properties of undefined (reading \'split\')')) {
|
||||
console.warn('Caught unhandled promise rejection migration error, attempting recovery...')
|
||||
event.preventDefault() // Prevent the error from being logged
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus: "online",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (originalUnhandledRejection) {
|
||||
return (originalUnhandledRejection as any)(event)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((fn) => fn())
|
||||
unsubs.length = 0
|
||||
}
|
||||
}, [handle, store])
|
||||
|
||||
return storeWithStatus
|
||||
}
|
||||
|
||||
export function useAutomergePresence({ handle, store, userMetadata }:
|
||||
{ handle: DocHandle<TLStoreSnapshot> | null, store: TLStoreWithStatus, userMetadata: any }) {
|
||||
|
||||
const innerStore = store?.store
|
||||
|
||||
const { userId, name, color } = userMetadata
|
||||
|
||||
// Only use awareness hooks if we have a valid handle and the store is ready
|
||||
const shouldUseAwareness = handle && store?.status === "synced-remote"
|
||||
|
||||
// Create a safe handle that won't cause null errors
|
||||
const safeHandle = shouldUseAwareness ? handle : {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
removeListener: () => {}, // Add the missing removeListener method
|
||||
whenReady: () => Promise.resolve(),
|
||||
doc: () => null,
|
||||
change: () => {},
|
||||
broadcast: () => {}, // Add the missing broadcast method
|
||||
} as any
|
||||
|
||||
const [, updateLocalState] = useLocalAwareness({
|
||||
handle: safeHandle,
|
||||
userId,
|
||||
initialState: {},
|
||||
})
|
||||
|
||||
const [peerStates] = useRemoteAwareness({
|
||||
handle: safeHandle,
|
||||
localUserId: userId,
|
||||
})
|
||||
|
||||
/* ----------- Presence stuff ----------- */
|
||||
useEffect(() => {
|
||||
if (!innerStore || !shouldUseAwareness) return
|
||||
|
||||
const toPut: TLRecord[] =
|
||||
Object.values(peerStates)
|
||||
.filter((record) => record && Object.keys(record).length !== 0)
|
||||
|
||||
// put / remove the records in the store
|
||||
const toRemove = innerStore.query.records('instance_presence').get().sort(sortById)
|
||||
.map((record) => record.id)
|
||||
.filter((id) => !toPut.find((record) => record.id === id))
|
||||
|
||||
if (toRemove.length) innerStore.remove(toRemove)
|
||||
if (toPut.length) innerStore.put(toPut)
|
||||
}, [innerStore, peerStates, shouldUseAwareness])
|
||||
|
||||
useEffect(() => {
|
||||
if (!innerStore || !shouldUseAwareness) return
|
||||
/* ----------- Presence stuff ----------- */
|
||||
setUserPreferences({ id: userId, color, name })
|
||||
|
||||
const userPreferences = computed<{
|
||||
id: string
|
||||
color: string
|
||||
name: string
|
||||
}>("userPreferences", () => {
|
||||
const user = getUserPreferences()
|
||||
return {
|
||||
id: user.id,
|
||||
color: user.color ?? defaultUserPreferences.color,
|
||||
name: user.name ?? defaultUserPreferences.name,
|
||||
}
|
||||
})
|
||||
|
||||
const presenceId = InstancePresenceRecordType.createId(userId)
|
||||
const presenceDerivation = createPresenceStateDerivation(
|
||||
userPreferences,
|
||||
presenceId
|
||||
)(innerStore)
|
||||
|
||||
return react("when presence changes", () => {
|
||||
const presence = presenceDerivation.get()
|
||||
requestAnimationFrame(() => {
|
||||
updateLocalState(presence)
|
||||
})
|
||||
})
|
||||
}, [innerStore, userId, updateLocalState, shouldUseAwareness])
|
||||
/* ----------- End presence stuff ----------- */
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,7 +15,7 @@ interface AutomergeSyncConfig {
|
|||
}
|
||||
}
|
||||
|
||||
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus {
|
||||
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: any | null } {
|
||||
const { uri, user } = config
|
||||
|
||||
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
|
||||
|
|
@ -96,7 +96,44 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
handle.change((doc) => {
|
||||
if (snapshotData.store) {
|
||||
doc.store = snapshotData.store
|
||||
// Pre-sanitize snapshot data to remove invalid properties
|
||||
const sanitizedStore = { ...snapshotData.store }
|
||||
let sanitizedCount = 0
|
||||
|
||||
Object.keys(sanitizedStore).forEach(key => {
|
||||
const record = (sanitizedStore as any)[key]
|
||||
if (record && record.typeName === 'shape') {
|
||||
// Remove invalid properties from embed shapes (both custom Embed and default embed)
|
||||
if ((record.type === 'Embed' || record.type === 'embed') && record.props) {
|
||||
const invalidEmbedProps = ['doesResize', 'doesResizeHeight', 'richText']
|
||||
invalidEmbedProps.forEach(prop => {
|
||||
if (prop in record.props) {
|
||||
console.log(`🔧 Pre-sanitizing snapshot: Removing invalid prop '${prop}' from embed shape ${record.id}`)
|
||||
delete record.props[prop]
|
||||
sanitizedCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Remove invalid properties from text shapes
|
||||
if (record.type === 'text' && record.props) {
|
||||
const invalidTextProps = ['text', 'richText']
|
||||
invalidTextProps.forEach(prop => {
|
||||
if (prop in record.props) {
|
||||
console.log(`🔧 Pre-sanitizing snapshot: Removing invalid prop '${prop}' from text shape ${record.id}`)
|
||||
delete record.props[prop]
|
||||
sanitizedCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (sanitizedCount > 0) {
|
||||
console.log(`🔧 Pre-sanitized ${sanitizedCount} invalid properties from snapshot data`)
|
||||
}
|
||||
|
||||
doc.store = sanitizedStore
|
||||
console.log("Loaded snapshot store data into Automerge document:", {
|
||||
storeKeys: Object.keys(doc.store).length,
|
||||
shapeCount: Object.values(doc.store).filter((r: any) => r.typeName === 'shape').length,
|
||||
|
|
@ -187,8 +224,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
// Return loading state while initializing
|
||||
if (isLoading || !handle) {
|
||||
return { status: "loading" }
|
||||
return { ...store, handle: null }
|
||||
}
|
||||
|
||||
return store
|
||||
return { ...store, handle }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { useMemo, useEffect, useState, useCallback } from "react"
|
||||
import { TLStoreSnapshot } from "@tldraw/tldraw"
|
||||
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
|
||||
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
|
||||
import { TLStoreWithStatus } from "@tldraw/tldraw"
|
||||
import { Repo } from "@automerge/automerge-repo"
|
||||
|
||||
interface AutomergeSyncConfig {
|
||||
uri: string
|
||||
assets?: any
|
||||
shapeUtils?: any[]
|
||||
bindingUtils?: any[]
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus {
|
||||
const { uri, user } = config
|
||||
|
||||
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
|
||||
const roomId = useMemo(() => {
|
||||
const match = uri.match(/\/connect\/([^\/]+)$/)
|
||||
return match ? match[1] : "default-room"
|
||||
}, [uri])
|
||||
|
||||
// Extract worker URL from URI (remove /connect/roomId part)
|
||||
const workerUrl = useMemo(() => {
|
||||
return uri.replace(/\/connect\/.*$/, '')
|
||||
}, [uri])
|
||||
|
||||
const [repo] = useState(() => new Repo({
|
||||
network: [new CloudflareNetworkAdapter(workerUrl, roomId)]
|
||||
}))
|
||||
const [handle, setHandle] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Initialize Automerge document handle
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const initializeHandle = async () => {
|
||||
try {
|
||||
console.log("🔌 Initializing Automerge Repo with NetworkAdapter")
|
||||
|
||||
if (mounted) {
|
||||
// Create a new document - Automerge will generate the proper document ID
|
||||
// Force refresh to clear cache
|
||||
const handle = repo.create()
|
||||
|
||||
console.log("Created Automerge handle via Repo:", {
|
||||
handleId: handle.documentId,
|
||||
isReady: handle.isReady()
|
||||
})
|
||||
|
||||
// Wait for the handle to be ready
|
||||
await handle.whenReady()
|
||||
|
||||
console.log("Automerge handle is ready:", {
|
||||
hasDoc: !!handle.doc(),
|
||||
docKeys: handle.doc() ? Object.keys(handle.doc()).length : 0
|
||||
})
|
||||
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing Automerge handle:", error)
|
||||
if (mounted) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeHandle()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [repo, roomId])
|
||||
|
||||
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
|
||||
useEffect(() => {
|
||||
if (!handle) return
|
||||
|
||||
let saveTimeout: NodeJS.Timeout
|
||||
|
||||
const scheduleSave = () => {
|
||||
// Clear existing timeout
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
|
||||
// Schedule save with a short debounce (500ms) to batch rapid changes
|
||||
saveTimeout = setTimeout(async () => {
|
||||
try {
|
||||
// With Repo, we don't need manual saving - the NetworkAdapter handles sync
|
||||
console.log("🔍 Automerge changes detected - NetworkAdapter will handle sync")
|
||||
} catch (error) {
|
||||
console.error('Error in change-triggered save:', error)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// Listen for changes to the Automerge document
|
||||
const changeHandler = (payload: any) => {
|
||||
console.log('🔍 Automerge document changed:', {
|
||||
hasPatches: !!payload.patches,
|
||||
patchCount: payload.patches?.length || 0,
|
||||
patches: payload.patches?.map((p: any) => ({
|
||||
action: p.action,
|
||||
path: p.path,
|
||||
value: p.value ? (typeof p.value === 'object' ? 'object' : p.value) : 'undefined'
|
||||
}))
|
||||
})
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
handle.on('change', changeHandler)
|
||||
|
||||
return () => {
|
||||
handle.off('change', changeHandler)
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
}
|
||||
}, [handle])
|
||||
|
||||
// Get the store from the Automerge document
|
||||
const store = useMemo(() => {
|
||||
if (!handle?.doc()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const doc = handle.doc()
|
||||
if (!doc.store) {
|
||||
return null
|
||||
}
|
||||
|
||||
return doc.store
|
||||
}, [handle])
|
||||
|
||||
// Get the store with status
|
||||
const storeWithStatus = useMemo((): TLStoreWithStatus => {
|
||||
if (!store) {
|
||||
return {
|
||||
status: isLoading ? 'loading' : 'not-synced',
|
||||
connectionStatus: 'offline',
|
||||
store: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'synced',
|
||||
connectionStatus: 'online',
|
||||
store
|
||||
}
|
||||
}, [store, isLoading])
|
||||
|
||||
// Get presence data (only when handle is ready)
|
||||
const presence = useAutomergePresence({
|
||||
handle: handle || null,
|
||||
store: store || null,
|
||||
userMetadata: user || { userId: 'anonymous', name: 'Anonymous', color: '#000000' }
|
||||
})
|
||||
|
||||
return {
|
||||
...storeWithStatus,
|
||||
presence
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,479 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { useEditor } from 'tldraw'
|
||||
import { createShapeId } from 'tldraw'
|
||||
import { WORKER_URL, LOCAL_WORKER_URL } from '../constants/workerUrl'
|
||||
|
||||
interface FathomMeeting {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
created_at: string
|
||||
duration: number
|
||||
summary?: {
|
||||
markdown_formatted: string
|
||||
}
|
||||
}
|
||||
|
||||
interface FathomMeetingsPanelProps {
|
||||
onClose: () => void
|
||||
shapeMode?: boolean
|
||||
}
|
||||
|
||||
export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetingsPanelProps) {
|
||||
const editor = useEditor()
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
|
||||
const [meetings, setMeetings] = useState<FathomMeeting[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if API key is already stored
|
||||
const storedApiKey = localStorage.getItem('fathom_api_key')
|
||||
if (storedApiKey) {
|
||||
setApiKey(storedApiKey)
|
||||
fetchMeetings()
|
||||
} else {
|
||||
setShowApiKeyInput(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchMeetings = async () => {
|
||||
if (!apiKey) {
|
||||
setError('Please enter your Fathom API key')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Try production worker first, fallback to local if needed
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${WORKER_URL}/fathom/meetings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('Production worker failed, trying local worker...')
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Check if response is JSON
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const errorData = await response.json() as { error?: string }
|
||||
setError(errorData.error || `HTTP ${response.status}: ${response.statusText}`)
|
||||
} else {
|
||||
setError(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json() as { data?: FathomMeeting[] }
|
||||
setMeetings(data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching meetings:', error)
|
||||
setError(`Failed to fetch meetings: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveApiKey = () => {
|
||||
if (apiKey) {
|
||||
localStorage.setItem('fathom_api_key', apiKey)
|
||||
setShowApiKeyInput(false)
|
||||
fetchMeetings()
|
||||
}
|
||||
}
|
||||
|
||||
const addMeetingToCanvas = async (meeting: FathomMeeting) => {
|
||||
try {
|
||||
// Fetch full meeting details
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${WORKER_URL}/fathom/meetings/${meeting.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('Production worker failed, trying local worker...')
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setError(`Failed to fetch meeting details: ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const fullMeeting = await response.json() as any
|
||||
|
||||
// Create Fathom transcript shape
|
||||
const shapeId = createShapeId()
|
||||
editor.createShape({
|
||||
id: shapeId,
|
||||
type: 'FathomTranscript',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
meetingId: fullMeeting.id || '',
|
||||
meetingTitle: fullMeeting.title || '',
|
||||
meetingUrl: fullMeeting.url || '',
|
||||
summary: fullMeeting.default_summary?.markdown_formatted || '',
|
||||
transcript: fullMeeting.transcript?.map((entry: any) => ({
|
||||
speaker: entry.speaker?.display_name || 'Unknown',
|
||||
text: entry.text,
|
||||
timestamp: entry.timestamp
|
||||
})) || [],
|
||||
actionItems: fullMeeting.action_items?.map((item: any) => ({
|
||||
text: item.text,
|
||||
assignee: item.assignee,
|
||||
dueDate: item.due_date
|
||||
})) || [],
|
||||
isExpanded: false,
|
||||
showTranscript: true,
|
||||
showActionItems: true,
|
||||
}
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Error adding meeting to canvas:', error)
|
||||
setError(`Failed to add meeting: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// If in shape mode, don't use modal overlay
|
||||
const contentStyle: React.CSSProperties = shapeMode ? {
|
||||
backgroundColor: 'white',
|
||||
padding: '20px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
userSelect: 'text',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
} : {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
width: '90%',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||
position: 'relative',
|
||||
zIndex: 10001,
|
||||
userSelect: 'text'
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div style={contentStyle} onClick={(e) => shapeMode ? undefined : e.stopPropagation()}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '10px',
|
||||
borderBottom: '1px solid #eee'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}>
|
||||
🎥 Fathom Meetings
|
||||
</h2>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '5px',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showApiKeyInput ? (
|
||||
<div>
|
||||
<p style={{
|
||||
marginBottom: '10px',
|
||||
fontSize: '14px',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}>
|
||||
Enter your Fathom API key to access your meetings:
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Your Fathom API key"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '10px',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={saveApiKey}
|
||||
disabled={!apiKey}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: apiKey ? '#007bff' : '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: apiKey ? 'pointer' : 'not-allowed',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
Save & Load Meetings
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
|
||||
<button
|
||||
onClick={fetchMeetings}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: loading ? '#6c757d' : '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Refresh Meetings'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('fathom_api_key')
|
||||
setApiKey('')
|
||||
setShowApiKeyInput(true)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
Change API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
backgroundColor: '#f8d7da',
|
||||
color: '#721c24',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '20px',
|
||||
border: '1px solid #f5c6cb',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{meetings.length === 0 ? (
|
||||
<p style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}>
|
||||
No meetings found. Click "Refresh Meetings" to load your Fathom meetings.
|
||||
</p>
|
||||
) : (
|
||||
meetings.map((meeting) => (
|
||||
<div
|
||||
key={meeting.id}
|
||||
style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
marginBottom: '10px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, userSelect: 'text', cursor: 'text' }}>
|
||||
<h3 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginBottom: '8px',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}>
|
||||
<div>📅 {formatDate(meeting.created_at)}</div>
|
||||
<div>⏱️ Duration: {formatDuration(meeting.duration)}</div>
|
||||
</div>
|
||||
{meeting.summary && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#333',
|
||||
marginBottom: '8px',
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}>
|
||||
<strong>Summary:</strong> {meeting.summary.markdown_formatted.substring(0, 100)}...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addMeetingToCanvas(meeting)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
marginLeft: '10px',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
Add to Canvas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// If in shape mode, return content directly
|
||||
if (shapeMode) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Otherwise, return with modal overlay
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { holosphereService, HoloSphereService, HolonData, HolonLens } from '@/lib/HoloSphereService'
|
||||
import * as h3 from 'h3-js'
|
||||
|
||||
interface HolonBrowserProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelectHolon: (holonData: HolonData) => void
|
||||
shapeMode?: boolean
|
||||
}
|
||||
|
||||
interface HolonInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
resolution: number
|
||||
resolutionName: string
|
||||
data: Record<string, any>
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false }: HolonBrowserProps) {
|
||||
const [holonId, setHolonId] = useState('')
|
||||
const [holonInfo, setHolonInfo] = useState<HolonInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lenses, setLenses] = useState<string[]>([])
|
||||
const [selectedLens, setSelectedLens] = useState<string>('')
|
||||
const [lensData, setLensData] = useState<any>(null)
|
||||
const [isLoadingData, setIsLoadingData] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSearchHolon = async () => {
|
||||
if (!holonId.trim()) {
|
||||
setError('Please enter a Holon ID')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setHolonInfo(null)
|
||||
|
||||
try {
|
||||
// Validate that the holonId is a valid H3 index
|
||||
if (!h3.isValidCell(holonId)) {
|
||||
throw new Error('Invalid H3 cell ID')
|
||||
}
|
||||
|
||||
// Get holon information
|
||||
const resolution = h3.getResolution(holonId)
|
||||
const [lat, lng] = h3.cellToLatLng(holonId)
|
||||
|
||||
// Try to get metadata from the holon
|
||||
let metadata = null
|
||||
try {
|
||||
metadata = await holosphereService.getData(holonId, 'metadata')
|
||||
} catch (error) {
|
||||
console.log('No metadata found for holon')
|
||||
}
|
||||
|
||||
// Get available lenses by trying to fetch data from common lens types
|
||||
// Use the improved categories from HolonShapeUtil
|
||||
const commonLenses = [
|
||||
'active_users', 'users', 'rankings', 'stats', 'tasks', 'progress',
|
||||
'events', 'activities', 'items', 'shopping', 'active_items',
|
||||
'proposals', 'offers', 'requests', 'checklists', 'roles',
|
||||
'general', 'metadata', 'environment', 'social', 'economic', 'cultural', 'data'
|
||||
]
|
||||
const availableLenses: string[] = []
|
||||
|
||||
for (const lens of commonLenses) {
|
||||
try {
|
||||
// Use getDataWithWait for better Gun data retrieval (shorter timeout for browser)
|
||||
const data = await holosphereService.getDataWithWait(holonId, lens, 1000)
|
||||
if (data && (Array.isArray(data) ? data.length > 0 : Object.keys(data).length > 0)) {
|
||||
availableLenses.push(lens)
|
||||
console.log(`✓ Found lens: ${lens} with ${Object.keys(data).length} keys`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Lens doesn't exist or is empty, skip
|
||||
}
|
||||
}
|
||||
|
||||
// If no lenses found, add 'general' as default
|
||||
if (availableLenses.length === 0) {
|
||||
availableLenses.push('general')
|
||||
}
|
||||
|
||||
const holonData: HolonInfo = {
|
||||
id: holonId,
|
||||
name: metadata?.name || `Holon ${holonId.slice(-8)}`,
|
||||
description: metadata?.description || '',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
resolution: resolution,
|
||||
resolutionName: HoloSphereService.getResolutionName(resolution),
|
||||
data: {},
|
||||
lastUpdated: metadata?.lastUpdated || Date.now()
|
||||
}
|
||||
|
||||
setHolonInfo(holonData)
|
||||
setLenses(availableLenses)
|
||||
setSelectedLens(availableLenses[0])
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error searching holon:', error)
|
||||
setError(`Failed to load holon: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadLensData = async (lens: string) => {
|
||||
if (!holonInfo) return
|
||||
|
||||
setIsLoadingData(true)
|
||||
try {
|
||||
// Use getDataWithWait for better Gun data retrieval
|
||||
const data = await holosphereService.getDataWithWait(holonInfo.id, lens, 2000)
|
||||
setLensData(data)
|
||||
console.log(`📊 Loaded lens data for ${lens}:`, data)
|
||||
} catch (error) {
|
||||
console.error('Error loading lens data:', error)
|
||||
setLensData(null)
|
||||
} finally {
|
||||
setIsLoadingData(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLens && holonInfo) {
|
||||
handleLoadLensData(selectedLens)
|
||||
}
|
||||
}, [selectedLens, holonInfo])
|
||||
|
||||
const handleSelectHolon = () => {
|
||||
if (holonInfo) {
|
||||
const holonData: HolonData = {
|
||||
id: holonInfo.id,
|
||||
name: holonInfo.name,
|
||||
description: holonInfo.description,
|
||||
latitude: holonInfo.latitude,
|
||||
longitude: holonInfo.longitude,
|
||||
resolution: holonInfo.resolution,
|
||||
data: holonInfo.data,
|
||||
timestamp: holonInfo.lastUpdated
|
||||
}
|
||||
onSelectHolon(holonData)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearchHolon()
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const contentStyle: React.CSSProperties = shapeMode ? {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
} : {}
|
||||
|
||||
const renderContent = () => (
|
||||
<>
|
||||
{!shapeMode && (
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">🌐 Holon Browser</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Enter a Holon ID to browse its data and import it to your canvas
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={shapeMode ? { display: 'flex', flexDirection: 'column', gap: '24px', flex: 1, overflow: 'auto' } : { padding: '24px', display: 'flex', flexDirection: 'column', gap: '24px', maxHeight: 'calc(90vh - 120px)', overflowY: 'auto' }}>
|
||||
{/* Holon ID Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Holon ID
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={holonId}
|
||||
onChange={(e) => setHolonId(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g., 1002848305066"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 z-[10001] relative"
|
||||
disabled={isLoading}
|
||||
style={{ zIndex: 10001 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearchHolon}
|
||||
disabled={isLoading || !holonId.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed z-[10001] relative"
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
{isLoading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-red-600 text-sm mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holon Information */}
|
||||
{holonInfo && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
📍 {holonInfo.name}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Coordinates</p>
|
||||
<p className="font-mono text-sm">
|
||||
{holonInfo.latitude.toFixed(6)}, {holonInfo.longitude.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Resolution</p>
|
||||
<p className="text-sm">
|
||||
{holonInfo.resolutionName} (Level {holonInfo.resolution})
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Holon ID</p>
|
||||
<p className="font-mono text-xs break-all">{holonInfo.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Updated</p>
|
||||
<p className="text-sm">
|
||||
{new Date(holonInfo.lastUpdated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{holonInfo.description && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">Description</p>
|
||||
<p className="text-sm text-gray-800">{holonInfo.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Lenses */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600 mb-2">Available Data Categories</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{lenses.map((lens) => (
|
||||
<button
|
||||
key={lens}
|
||||
onClick={() => setSelectedLens(lens)}
|
||||
className={`px-3 py-1 rounded-full text-sm z-[10001] relative ${
|
||||
selectedLens === lens
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
{lens}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lens Data */}
|
||||
{selectedLens && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-md font-medium text-gray-900">
|
||||
Data: {selectedLens}
|
||||
</h4>
|
||||
{isLoadingData && (
|
||||
<span className="text-sm text-gray-500">Loading...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lensData && (
|
||||
<div className="bg-gray-50 rounded-md p-3 max-h-48 overflow-y-auto">
|
||||
<pre className="text-xs text-gray-800 whitespace-pre-wrap">
|
||||
{JSON.stringify(lensData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!lensData && !isLoadingData && (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No data available for this category
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSelectHolon}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 z-[10001] relative"
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
Import to Canvas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHolonInfo(null)
|
||||
setHolonId('')
|
||||
setError(null)
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 z-[10001] relative"
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
Search Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
// If in shape mode, return content without modal overlay
|
||||
if (shapeMode) {
|
||||
return (
|
||||
<div style={contentStyle}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise, return with modal overlay
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden z-[10000]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,253 @@
|
|||
import React, { useState, ReactNode } from 'react'
|
||||
|
||||
export interface StandardizedToolWrapperProps {
|
||||
/** The title to display in the header */
|
||||
title: string
|
||||
/** The primary color for this tool (used for header and accents) */
|
||||
primaryColor: string
|
||||
/** The content to render inside the wrapper */
|
||||
children: ReactNode
|
||||
/** Whether the shape is currently selected */
|
||||
isSelected: boolean
|
||||
/** Width of the tool */
|
||||
width: number
|
||||
/** Height of the tool */
|
||||
height: number
|
||||
/** Callback when close button is clicked */
|
||||
onClose: () => void
|
||||
/** Callback when minimize button is clicked */
|
||||
onMinimize?: () => void
|
||||
/** Whether the tool is minimized */
|
||||
isMinimized?: boolean
|
||||
/** Optional custom header content */
|
||||
headerContent?: ReactNode
|
||||
/** Editor instance for shape selection */
|
||||
editor?: any
|
||||
/** Shape ID for selection handling */
|
||||
shapeId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized wrapper component for all custom tools on the canvas.
|
||||
* Provides consistent header bar with close/minimize buttons, sizing, and color theming.
|
||||
*/
|
||||
export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = ({
|
||||
title,
|
||||
primaryColor,
|
||||
children,
|
||||
isSelected,
|
||||
width,
|
||||
height,
|
||||
onClose,
|
||||
onMinimize,
|
||||
isMinimized = false,
|
||||
headerContent,
|
||||
editor,
|
||||
shapeId,
|
||||
}) => {
|
||||
const [isHoveringHeader, setIsHoveringHeader] = useState(false)
|
||||
|
||||
|
||||
// Calculate header background color (lighter shade of primary color)
|
||||
const headerBgColor = isSelected
|
||||
? primaryColor
|
||||
: isHoveringHeader
|
||||
? `${primaryColor}15` // 15% opacity
|
||||
: `${primaryColor}10` // 10% opacity
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: isMinimized ? 40 : (typeof height === 'number' ? `${height}px` : height), // Minimized height is just the header
|
||||
backgroundColor: "white",
|
||||
border: isSelected ? `2px solid ${primaryColor}` : `1px solid ${primaryColor}40`,
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 2px ${primaryColor}40, 0 4px 8px rgba(0,0,0,0.15)`
|
||||
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: "Inter, sans-serif",
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto',
|
||||
transition: 'height 0.2s ease, box-shadow 0.2s ease',
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
height: '40px',
|
||||
backgroundColor: headerBgColor,
|
||||
borderBottom: `1px solid ${primaryColor}30`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
cursor: 'move',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'auto',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: isSelected ? 'white' : primaryColor,
|
||||
flex: 1,
|
||||
pointerEvents: 'none',
|
||||
transition: 'color 0.2s ease',
|
||||
}
|
||||
|
||||
const buttonContainerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const buttonBaseStyle: React.CSSProperties = {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
transition: 'background-color 0.15s ease, color 0.15s ease',
|
||||
pointerEvents: 'auto',
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
const minimizeButtonStyle: React.CSSProperties = {
|
||||
...buttonBaseStyle,
|
||||
backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`,
|
||||
color: isSelected ? 'white' : primaryColor,
|
||||
}
|
||||
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
...buttonBaseStyle,
|
||||
backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`,
|
||||
color: isSelected ? 'white' : primaryColor,
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: isMinimized ? 0 : 'calc(100% - 40px)',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto',
|
||||
transition: 'height 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
|
||||
const handleHeaderPointerDown = (e: React.PointerEvent) => {
|
||||
// Check if this is an interactive element (button)
|
||||
const target = e.target as HTMLElement
|
||||
const isInteractive =
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('button') ||
|
||||
target.closest('[role="button"]')
|
||||
|
||||
if (isInteractive) {
|
||||
// Buttons handle their own behavior and stop propagation
|
||||
return
|
||||
}
|
||||
|
||||
// Don't stop the event - let tldraw handle it naturally
|
||||
// The hand tool override will detect shapes and handle dragging
|
||||
}
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent, action: () => void) => {
|
||||
e.stopPropagation()
|
||||
action()
|
||||
}
|
||||
|
||||
const handleContentPointerDown = (e: React.PointerEvent) => {
|
||||
// Only stop propagation for interactive elements to allow tldraw to handle dragging on white space
|
||||
const target = e.target as HTMLElement
|
||||
const isInteractive =
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.closest('button') ||
|
||||
target.closest('input') ||
|
||||
target.closest('textarea') ||
|
||||
target.closest('select') ||
|
||||
target.closest('[role="button"]') ||
|
||||
target.closest('a') ||
|
||||
target.closest('[data-interactive]') // Allow components to mark interactive areas
|
||||
|
||||
if (isInteractive) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
// Don't stop propagation for non-interactive elements - let tldraw handle dragging
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
{/* Header Bar */}
|
||||
<div
|
||||
style={headerStyle}
|
||||
onPointerDown={handleHeaderPointerDown}
|
||||
onMouseEnter={() => setIsHoveringHeader(true)}
|
||||
onMouseLeave={() => setIsHoveringHeader(false)}
|
||||
onMouseDown={(e) => {
|
||||
// Ensure selection happens on mouse down for immediate visual feedback
|
||||
if (editor && shapeId && !isSelected) {
|
||||
editor.setSelectedShapes([shapeId])
|
||||
}
|
||||
}}
|
||||
data-draggable="true"
|
||||
>
|
||||
<div style={titleStyle}>
|
||||
{headerContent || title}
|
||||
</div>
|
||||
<div style={buttonContainerStyle}>
|
||||
<button
|
||||
style={minimizeButtonStyle}
|
||||
onClick={(e) => {
|
||||
if (onMinimize) {
|
||||
handleButtonClick(e, onMinimize)
|
||||
} else {
|
||||
// Default minimize behavior if no handler provided
|
||||
console.warn('Minimize button clicked but no onMinimize handler provided')
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Minimize"
|
||||
aria-label="Minimize"
|
||||
disabled={!onMinimize}
|
||||
>
|
||||
_
|
||||
</button>
|
||||
<button
|
||||
style={closeButtonStyle}
|
||||
onClick={(e) => handleButtonClick(e, onClose)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
{!isMinimized && (
|
||||
<div
|
||||
style={contentStyle}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -25,13 +25,14 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
|
|||
setIsEditingVault(false);
|
||||
};
|
||||
|
||||
const handleClearVaultPath = () => {
|
||||
const handleDisconnectVault = () => {
|
||||
setVaultPath('');
|
||||
updateSession({
|
||||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined
|
||||
});
|
||||
setIsEditingVault(false);
|
||||
console.log('🔧 Vault disconnected from profile');
|
||||
};
|
||||
|
||||
const handleChangeVault = () => {
|
||||
|
|
@ -95,8 +96,8 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
|
|||
{session.obsidianVaultName ? 'Change Obsidian Vault' : 'Set Obsidian Vault'}
|
||||
</button>
|
||||
{session.obsidianVaultPath && (
|
||||
<button onClick={handleClearVaultPath} className="clear-vault-button">
|
||||
Clear Vault
|
||||
<button onClick={handleDisconnectVault} className="disconnect-vault-button">
|
||||
🔌 Disconnect Vault
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@/context/AuthContext"
|
||||
import { LocationStorageService, type LocationData } from "@/lib/location/locationStorage"
|
||||
import type { GeolocationPosition } from "@/lib/location/types"
|
||||
|
||||
interface LocationCaptureProps {
|
||||
onLocationCaptured?: (location: LocationData) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export const LocationCapture: React.FC<LocationCaptureProps> = ({ onLocationCaptured, onError }) => {
|
||||
const { session, fileSystem } = useAuth()
|
||||
const [isCapturing, setIsCapturing] = useState(false)
|
||||
const [permissionState, setPermissionState] = useState<"prompt" | "granted" | "denied">("prompt")
|
||||
const [currentLocation, setCurrentLocation] = useState<GeolocationPosition | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Show loading state while auth is initializing
|
||||
if (session.loading) {
|
||||
return (
|
||||
<div className="location-capture-loading flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2 animate-spin">⏳</div>
|
||||
<p className="text-sm text-muted-foreground">Loading authentication...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check permission status on mount
|
||||
useEffect(() => {
|
||||
if ("permissions" in navigator) {
|
||||
navigator.permissions.query({ name: "geolocation" }).then((result) => {
|
||||
setPermissionState(result.state as "prompt" | "granted" | "denied")
|
||||
|
||||
result.addEventListener("change", () => {
|
||||
setPermissionState(result.state as "prompt" | "granted" | "denied")
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const captureLocation = async () => {
|
||||
// Don't proceed if still loading
|
||||
if (session.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.authed) {
|
||||
const errorMsg = "You must be logged in to share your location. Please log in and try again."
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fileSystem) {
|
||||
const errorMsg = "File system not available. Please refresh the page and try again."
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
setIsCapturing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Request geolocation
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos as GeolocationPosition),
|
||||
(err) => reject(err),
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
setCurrentLocation(position)
|
||||
|
||||
// Create location data
|
||||
const locationData: LocationData = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: session.username,
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
timestamp: position.timestamp,
|
||||
expiresAt: null, // Will be set when creating a share
|
||||
precision: "exact",
|
||||
}
|
||||
|
||||
// Save to filesystem
|
||||
const storageService = new LocationStorageService(fileSystem)
|
||||
await storageService.initialize()
|
||||
await storageService.saveLocation(locationData)
|
||||
|
||||
onLocationCaptured?.(locationData)
|
||||
} catch (err: any) {
|
||||
let errorMsg = "Failed to capture location"
|
||||
|
||||
if (err.code === 1) {
|
||||
errorMsg = "Location permission denied. Please enable location access in your browser settings."
|
||||
setPermissionState("denied")
|
||||
} else if (err.code === 2) {
|
||||
errorMsg = "Location unavailable. Please check your device settings."
|
||||
} else if (err.code === 3) {
|
||||
errorMsg = "Location request timed out. Please try again."
|
||||
}
|
||||
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
} finally {
|
||||
setIsCapturing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-capture">
|
||||
<div className="capture-header">
|
||||
<h2 className="text-2xl font-semibold text-balance">Share Your Location</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">Securely share your current location with others</p>
|
||||
</div>
|
||||
|
||||
{/* Permission status */}
|
||||
{permissionState === "denied" && (
|
||||
<div className="permission-denied bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Location access is blocked. Please enable it in your browser settings to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current location display */}
|
||||
{currentLocation && (
|
||||
<div className="current-location bg-muted/50 rounded-lg p-4 mt-4">
|
||||
<h3 className="text-sm font-medium mb-2">Current Location</h3>
|
||||
<div className="location-details text-xs space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Latitude:</span> {currentLocation.coords.latitude.toFixed(6)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Longitude:</span> {currentLocation.coords.longitude.toFixed(6)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Accuracy:</span> ±{Math.round(currentLocation.coords.accuracy)}m
|
||||
</p>
|
||||
<p className="text-muted-foreground">Captured {new Date(currentLocation.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Capture button */}
|
||||
<button
|
||||
onClick={captureLocation}
|
||||
disabled={isCapturing || permissionState === "denied" || !session.authed}
|
||||
className="capture-button w-full mt-6 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-6 py-3 font-medium transition-colors"
|
||||
>
|
||||
{isCapturing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="spinner" />
|
||||
Capturing Location...
|
||||
</span>
|
||||
) : (
|
||||
"Capture My Location"
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!session.authed && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">Please log in to share your location</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@/context/AuthContext"
|
||||
import { LocationStorageService, type LocationData, type LocationShare } from "@/lib/location/locationStorage"
|
||||
import { LocationMap } from "./LocationMap"
|
||||
|
||||
interface ShareWithLocation {
|
||||
share: LocationShare
|
||||
location: LocationData
|
||||
}
|
||||
|
||||
export const LocationDashboard: React.FC = () => {
|
||||
const { session, fileSystem } = useAuth()
|
||||
const [shares, setShares] = useState<ShareWithLocation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedShare, setSelectedShare] = useState<ShareWithLocation | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadShares = async () => {
|
||||
if (!fileSystem) {
|
||||
setError("File system not available")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const storageService = new LocationStorageService(fileSystem)
|
||||
await storageService.initialize()
|
||||
|
||||
// Get all shares
|
||||
const allShares = await storageService.getAllShares()
|
||||
|
||||
// Get locations for each share
|
||||
const sharesWithLocations: ShareWithLocation[] = []
|
||||
|
||||
for (const share of allShares) {
|
||||
const location = await storageService.getLocation(share.locationId)
|
||||
if (location) {
|
||||
sharesWithLocations.push({ share, location })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
sharesWithLocations.sort((a, b) => b.share.createdAt - a.share.createdAt)
|
||||
|
||||
setShares(sharesWithLocations)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error("Error loading shares:", err)
|
||||
setError("Failed to load location shares")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (session.authed && fileSystem) {
|
||||
loadShares()
|
||||
}
|
||||
}, [session.authed, fileSystem])
|
||||
|
||||
const handleCopyLink = async (shareToken: string) => {
|
||||
const baseUrl = window.location.origin
|
||||
const link = `${baseUrl}/location/${shareToken}`
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
alert("Link copied to clipboard!")
|
||||
} catch (err) {
|
||||
console.error("Failed to copy link:", err)
|
||||
alert("Failed to copy link")
|
||||
}
|
||||
}
|
||||
|
||||
const isExpired = (share: LocationShare): boolean => {
|
||||
return share.expiresAt ? share.expiresAt < Date.now() : false
|
||||
}
|
||||
|
||||
const isMaxViewsReached = (share: LocationShare): boolean => {
|
||||
return share.maxViews ? share.viewCount >= share.maxViews : false
|
||||
}
|
||||
|
||||
const getShareStatus = (share: LocationShare): { label: string; color: string } => {
|
||||
if (isExpired(share)) {
|
||||
return { label: "Expired", color: "text-destructive" }
|
||||
}
|
||||
if (isMaxViewsReached(share)) {
|
||||
return { label: "Max Views Reached", color: "text-destructive" }
|
||||
}
|
||||
return { label: "Active", color: "text-green-600" }
|
||||
}
|
||||
|
||||
if (!session.authed) {
|
||||
return (
|
||||
<div className="location-dashboard-auth flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="text-4xl mb-4">🔒</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
|
||||
<p className="text-sm text-muted-foreground">Please log in to view your location shares</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="spinner" />
|
||||
<p className="text-sm text-muted-foreground">Loading your shares...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Error Loading Dashboard</h2>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<button
|
||||
onClick={loadShares}
|
||||
className="mt-4 px-6 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-dashboard max-w-6xl mx-auto p-6">
|
||||
<div className="dashboard-header mb-8">
|
||||
<h1 className="text-3xl font-bold text-balance">Location Shares</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Manage your shared locations and privacy settings</p>
|
||||
</div>
|
||||
|
||||
{shares.length === 0 ? (
|
||||
<div className="empty-state flex flex-col items-center justify-center min-h-[400px] text-center">
|
||||
<div className="text-6xl mb-4">📍</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No Location Shares Yet</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
You haven't shared any locations yet. Create your first share to get started.
|
||||
</p>
|
||||
<a
|
||||
href="/share-location"
|
||||
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors font-medium"
|
||||
>
|
||||
Share Your Location
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-content">
|
||||
{/* Stats Overview */}
|
||||
<div className="stats-grid grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="stat-label text-sm text-muted-foreground mb-1">Total Shares</div>
|
||||
<div className="stat-value text-3xl font-bold">{shares.length}</div>
|
||||
</div>
|
||||
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="stat-label text-sm text-muted-foreground mb-1">Active Shares</div>
|
||||
<div className="stat-value text-3xl font-bold text-green-600">
|
||||
{shares.filter((s) => !isExpired(s.share) && !isMaxViewsReached(s.share)).length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="stat-label text-sm text-muted-foreground mb-1">Total Views</div>
|
||||
<div className="stat-value text-3xl font-bold">
|
||||
{shares.reduce((sum, s) => sum + s.share.viewCount, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shares List */}
|
||||
<div className="shares-list space-y-4">
|
||||
{shares.map(({ share, location }) => {
|
||||
const status = getShareStatus(share)
|
||||
const isSelected = selectedShare?.share.id === share.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className={`share-card bg-background rounded-lg border-2 transition-colors ${
|
||||
isSelected ? "border-primary" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="share-card-header p-4 flex items-start justify-between gap-4">
|
||||
<div className="share-info flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-semibold">Location Share</h3>
|
||||
<span className={`text-xs font-medium ${status.color}`}>{status.label}</span>
|
||||
</div>
|
||||
<div className="share-meta text-xs text-muted-foreground space-y-1">
|
||||
<p>Created: {new Date(share.createdAt).toLocaleString()}</p>
|
||||
{share.expiresAt && <p>Expires: {new Date(share.expiresAt).toLocaleString()}</p>}
|
||||
<p>
|
||||
Views: {share.viewCount}
|
||||
{share.maxViews && ` / ${share.maxViews}`}
|
||||
</p>
|
||||
<p>
|
||||
Precision: <span className="capitalize">{share.precision}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="share-actions flex gap-2">
|
||||
<button
|
||||
onClick={() => handleCopyLink(share.shareToken)}
|
||||
disabled={isExpired(share) || isMaxViewsReached(share)}
|
||||
className="px-4 py-2 rounded-lg border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedShare(isSelected ? null : { share, location })}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm"
|
||||
>
|
||||
{isSelected ? "Hide" : "View"} Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="share-card-body p-4 pt-0 border-t border-border mt-4">
|
||||
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="300px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { LocationData } from "@/lib/location/locationStorage"
|
||||
import { obfuscateLocation } from "@/lib/location/locationStorage"
|
||||
import type { PrecisionLevel } from "@/lib/location/types"
|
||||
|
||||
// Leaflet types
|
||||
interface LeafletMap {
|
||||
setView: (coords: [number, number], zoom: number) => void
|
||||
remove: () => void
|
||||
}
|
||||
|
||||
interface LeafletMarker {
|
||||
addTo: (map: LeafletMap) => LeafletMarker
|
||||
bindPopup: (content: string) => LeafletMarker
|
||||
}
|
||||
|
||||
interface LeafletCircle {
|
||||
addTo: (map: LeafletMap) => LeafletCircle
|
||||
}
|
||||
|
||||
interface LeafletTileLayer {
|
||||
addTo: (map: LeafletMap) => LeafletTileLayer
|
||||
}
|
||||
|
||||
interface Leaflet {
|
||||
map: (element: HTMLElement, options?: any) => LeafletMap
|
||||
marker: (coords: [number, number], options?: any) => LeafletMarker
|
||||
circle: (coords: [number, number], options?: any) => LeafletCircle
|
||||
tileLayer: (url: string, options?: any) => LeafletTileLayer
|
||||
icon: (options: any) => any
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
L?: Leaflet
|
||||
}
|
||||
}
|
||||
|
||||
interface LocationMapProps {
|
||||
location: LocationData
|
||||
precision?: PrecisionLevel
|
||||
showAccuracy?: boolean
|
||||
height?: string
|
||||
}
|
||||
|
||||
export const LocationMap: React.FC<LocationMapProps> = ({
|
||||
location,
|
||||
precision = "exact",
|
||||
showAccuracy = true,
|
||||
height = "400px",
|
||||
}) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null)
|
||||
const mapInstance = useRef<LeafletMap | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Load Leaflet CSS and JS
|
||||
const loadLeaflet = async () => {
|
||||
try {
|
||||
// Load CSS
|
||||
if (!document.querySelector('link[href*="leaflet.css"]')) {
|
||||
const link = document.createElement("link")
|
||||
link.rel = "stylesheet"
|
||||
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
link.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
link.crossOrigin = ""
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
// Load JS
|
||||
if (!window.L) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
script.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
script.crossOrigin = ""
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error("Failed to load Leaflet"))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
setError("Failed to load map library")
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadLeaflet()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainer.current || !window.L || isLoading) return
|
||||
|
||||
// Clean up existing map
|
||||
if (mapInstance.current) {
|
||||
mapInstance.current.remove()
|
||||
}
|
||||
|
||||
const L = window.L!
|
||||
|
||||
// Get obfuscated location based on precision
|
||||
const { lat, lng, radius } = obfuscateLocation(location.latitude, location.longitude, precision)
|
||||
|
||||
// Create map
|
||||
const map = L.map(mapContainer.current, {
|
||||
center: [lat, lng],
|
||||
zoom: precision === "exact" ? 15 : precision === "street" ? 14 : precision === "neighborhood" ? 12 : 10,
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
})
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([lat, lng], {
|
||||
icon: L.icon({
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
}),
|
||||
}).addTo(map)
|
||||
|
||||
// Add popup with location info
|
||||
const popupContent = `
|
||||
<div style="font-family: system-ui, sans-serif;">
|
||||
<strong>Shared Location</strong><br/>
|
||||
<small style="color: #666;">
|
||||
Precision: ${precision}<br/>
|
||||
${new Date(location.timestamp).toLocaleString()}
|
||||
</small>
|
||||
</div>
|
||||
`
|
||||
marker.bindPopup(popupContent)
|
||||
|
||||
// Add accuracy circle if showing accuracy
|
||||
if (showAccuracy && radius > 0) {
|
||||
L.circle([lat, lng], {
|
||||
radius: radius,
|
||||
color: "#3b82f6",
|
||||
fillColor: "#3b82f6",
|
||||
fillOpacity: 0.1,
|
||||
weight: 2,
|
||||
}).addTo(map)
|
||||
}
|
||||
|
||||
mapInstance.current = map
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (mapInstance.current) {
|
||||
mapInstance.current.remove()
|
||||
mapInstance.current = null
|
||||
}
|
||||
}
|
||||
}, [location, precision, showAccuracy, isLoading])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="map-error flex items-center justify-center bg-muted/50 rounded-lg border border-border"
|
||||
style={{ height }}
|
||||
>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="map-loading flex items-center justify-center bg-muted/50 rounded-lg border border-border"
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="spinner" />
|
||||
<p className="text-sm text-muted-foreground">Loading map...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-map-wrapper">
|
||||
<div
|
||||
ref={mapContainer}
|
||||
className="location-map rounded-lg border border-border overflow-hidden"
|
||||
style={{ height, width: "100%" }}
|
||||
/>
|
||||
<div className="map-info mt-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Showing {precision} location • Last updated {new Date(location.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import {
|
||||
TLUiDialogProps,
|
||||
TldrawUiDialogBody,
|
||||
TldrawUiDialogCloseButton,
|
||||
TldrawUiDialogHeader,
|
||||
TldrawUiDialogTitle,
|
||||
} from "tldraw"
|
||||
import React from "react"
|
||||
import { ShareLocation } from "./ShareLocation"
|
||||
|
||||
export function LocationShareDialog({ onClose }: TLUiDialogProps) {
|
||||
return (
|
||||
<>
|
||||
<TldrawUiDialogHeader>
|
||||
<TldrawUiDialogTitle>Share Location</TldrawUiDialogTitle>
|
||||
<TldrawUiDialogCloseButton />
|
||||
</TldrawUiDialogHeader>
|
||||
<TldrawUiDialogBody style={{ maxWidth: 800, maxHeight: "90vh", overflow: "auto" }}>
|
||||
<ShareLocation />
|
||||
</TldrawUiDialogBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { LocationMap } from "./LocationMap"
|
||||
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
|
||||
import { LocationStorageService } from "@/lib/location/locationStorage"
|
||||
import { useAuth } from "@/context/AuthContext"
|
||||
|
||||
interface LocationViewerProps {
|
||||
shareToken: string
|
||||
}
|
||||
|
||||
export const LocationViewer: React.FC<LocationViewerProps> = ({ shareToken }) => {
|
||||
const { fileSystem } = useAuth()
|
||||
const [location, setLocation] = useState<LocationData | null>(null)
|
||||
const [share, setShare] = useState<LocationShare | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSharedLocation = async () => {
|
||||
if (!fileSystem) {
|
||||
setError("File system not available")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const storageService = new LocationStorageService(fileSystem)
|
||||
await storageService.initialize()
|
||||
|
||||
// Get share by token
|
||||
const shareData = await storageService.getShareByToken(shareToken)
|
||||
if (!shareData) {
|
||||
setError("Share not found or expired")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if share is expired
|
||||
if (shareData.expiresAt && shareData.expiresAt < Date.now()) {
|
||||
setError("This share has expired")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if max views reached
|
||||
if (shareData.maxViews && shareData.viewCount >= shareData.maxViews) {
|
||||
setError("This share has reached its maximum view limit")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get location data
|
||||
const locationData = await storageService.getLocation(shareData.locationId)
|
||||
if (!locationData) {
|
||||
setError("Location data not found")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setShare(shareData)
|
||||
setLocation(locationData)
|
||||
|
||||
// Increment view count
|
||||
await storageService.incrementShareViews(shareData.id)
|
||||
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error("Error loading shared location:", err)
|
||||
setError("Failed to load shared location")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSharedLocation()
|
||||
}, [shareToken, fileSystem])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="location-viewer flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="spinner" />
|
||||
<p className="text-sm text-muted-foreground">Loading shared location...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="location-viewer flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="text-4xl mb-4">📍</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Unable to Load Location</h2>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!location || !share) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-viewer max-w-4xl mx-auto p-6">
|
||||
<div className="viewer-header mb-6">
|
||||
<h1 className="text-3xl font-bold text-balance">Shared Location</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Someone has shared their location with you</p>
|
||||
</div>
|
||||
|
||||
<div className="viewer-content space-y-6">
|
||||
{/* Map Display */}
|
||||
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="500px" />
|
||||
|
||||
{/* Share Info */}
|
||||
<div className="share-info bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div className="info-row flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Precision Level:</span>
|
||||
<span className="font-medium capitalize">{share.precision}</span>
|
||||
</div>
|
||||
<div className="info-row flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Views:</span>
|
||||
<span className="font-medium">
|
||||
{share.viewCount} {share.maxViews ? `/ ${share.maxViews}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{share.expiresAt && (
|
||||
<div className="info-row flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Expires:</span>
|
||||
<span className="font-medium">{new Date(share.expiresAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="info-row flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Shared:</span>
|
||||
<span className="font-medium">{new Date(share.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Notice */}
|
||||
<div className="privacy-notice bg-primary/5 border border-primary/20 rounded-lg p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This location is shared securely and will expire based on the sender's privacy settings. The location data
|
||||
is stored in a decentralized filesystem and is only accessible via this unique link.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
"use client"
|
||||
|
||||
import React, { useState } from "react"
|
||||
import { LocationCapture } from "./LocationCapture"
|
||||
import { ShareSettingsComponent } from "./ShareSettings"
|
||||
import { LocationMap } from "./LocationMap"
|
||||
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
|
||||
import { LocationStorageService, generateShareToken } from "@/lib/location/locationStorage"
|
||||
import type { ShareSettings } from "@/lib/location/types"
|
||||
import { useAuth } from "@/context/AuthContext"
|
||||
|
||||
export const ShareLocation: React.FC = () => {
|
||||
const { session, fileSystem } = useAuth()
|
||||
const [step, setStep] = useState<"capture" | "settings" | "share">("capture")
|
||||
const [capturedLocation, setCapturedLocation] = useState<LocationData | null>(null)
|
||||
const [shareSettings, setShareSettings] = useState<ShareSettings>({
|
||||
duration: 24 * 3600000, // 24 hours
|
||||
maxViews: null,
|
||||
precision: "street",
|
||||
})
|
||||
const [shareLink, setShareLink] = useState<string | null>(null)
|
||||
const [isCreatingShare, setIsCreatingShare] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Show loading state while auth is initializing
|
||||
if (session.loading) {
|
||||
return (
|
||||
<div className="share-location-loading flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="text-4xl mb-4 animate-spin">⏳</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Loading...</h2>
|
||||
<p className="text-sm text-muted-foreground">Initializing authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleLocationCaptured = (location: LocationData) => {
|
||||
setCapturedLocation(location)
|
||||
setStep("settings")
|
||||
}
|
||||
|
||||
const handleCreateShare = async () => {
|
||||
if (!capturedLocation || !fileSystem) {
|
||||
setError("Location or filesystem not available")
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreatingShare(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const storageService = new LocationStorageService(fileSystem)
|
||||
await storageService.initialize()
|
||||
|
||||
// Generate share token
|
||||
const shareToken = generateShareToken()
|
||||
|
||||
// Calculate expiration
|
||||
const expiresAt = shareSettings.duration ? Date.now() + shareSettings.duration : null
|
||||
|
||||
// Update location with expiration
|
||||
const updatedLocation: LocationData = {
|
||||
...capturedLocation,
|
||||
expiresAt,
|
||||
precision: shareSettings.precision,
|
||||
}
|
||||
|
||||
await storageService.saveLocation(updatedLocation)
|
||||
|
||||
// Create share
|
||||
const share: LocationShare = {
|
||||
id: crypto.randomUUID(),
|
||||
locationId: capturedLocation.id,
|
||||
shareToken,
|
||||
createdAt: Date.now(),
|
||||
expiresAt,
|
||||
maxViews: shareSettings.maxViews,
|
||||
viewCount: 0,
|
||||
precision: shareSettings.precision,
|
||||
}
|
||||
|
||||
await storageService.createShare(share)
|
||||
|
||||
// Generate share link
|
||||
const baseUrl = window.location.origin
|
||||
const link = `${baseUrl}/location/${shareToken}`
|
||||
|
||||
setShareLink(link)
|
||||
setStep("share")
|
||||
} catch (err) {
|
||||
console.error("Error creating share:", err)
|
||||
setError("Failed to create share link")
|
||||
} finally {
|
||||
setIsCreatingShare(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareLink) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink)
|
||||
// Could add a toast notification here
|
||||
alert("Link copied to clipboard!")
|
||||
} catch (err) {
|
||||
console.error("Failed to copy link:", err)
|
||||
alert("Failed to copy link. Please copy manually.")
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setStep("capture")
|
||||
setCapturedLocation(null)
|
||||
setShareLink(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
if (!session.authed) {
|
||||
return (
|
||||
<div className="share-location-auth flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="text-4xl mb-4">🔒</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
|
||||
<p className="text-sm text-muted-foreground">Please log in to share your location securely</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="share-location max-w-4xl mx-auto p-6">
|
||||
{/* Progress Steps */}
|
||||
<div className="progress-steps flex items-center justify-center gap-4 mb-8">
|
||||
{["capture", "settings", "share"].map((s, index) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="step-item flex items-center gap-2">
|
||||
<div
|
||||
className={`step-number w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
||||
step === s
|
||||
? "bg-primary text-primary-foreground"
|
||||
: index < ["capture", "settings", "share"].indexOf(step)
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`step-label text-sm font-medium capitalize ${
|
||||
step === s ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`step-connector h-0.5 w-12 ${
|
||||
index < ["capture", "settings", "share"].indexOf(step) ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="step-content">
|
||||
{step === "capture" && <LocationCapture onLocationCaptured={handleLocationCaptured} onError={setError} />}
|
||||
|
||||
{step === "settings" && capturedLocation && (
|
||||
<div className="settings-step space-y-6">
|
||||
<div className="location-preview">
|
||||
<h3 className="text-lg font-semibold mb-4">Preview Your Location</h3>
|
||||
<LocationMap
|
||||
location={capturedLocation}
|
||||
precision={shareSettings.precision}
|
||||
showAccuracy={true}
|
||||
height="300px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShareSettingsComponent onSettingsChange={setShareSettings} initialSettings={shareSettings} />
|
||||
|
||||
<div className="settings-actions flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep("capture")}
|
||||
className="flex-1 px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateShare}
|
||||
disabled={isCreatingShare}
|
||||
className="flex-1 px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{isCreatingShare ? "Creating Share..." : "Create Share Link"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "share" && shareLink && capturedLocation && (
|
||||
<div className="share-step space-y-6">
|
||||
<div className="share-success text-center mb-6">
|
||||
<div className="text-5xl mb-4">✓</div>
|
||||
<h2 className="text-2xl font-bold mb-2">Share Link Created!</h2>
|
||||
<p className="text-sm text-muted-foreground">Your location is ready to share securely</p>
|
||||
</div>
|
||||
|
||||
<div className="share-link-box bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<label className="block text-sm font-medium mb-2">Share Link</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm"
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="share-preview">
|
||||
<h3 className="text-lg font-semibold mb-4">Location Preview</h3>
|
||||
<LocationMap
|
||||
location={capturedLocation}
|
||||
precision={shareSettings.precision}
|
||||
showAccuracy={true}
|
||||
height="300px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="share-details bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<h4 className="font-medium mb-3">Share Settings</h4>
|
||||
<div className="detail-row flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Precision:</span>
|
||||
<span className="font-medium capitalize">{shareSettings.precision}</span>
|
||||
</div>
|
||||
<div className="detail-row flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-medium">
|
||||
{shareSettings.duration ? `${shareSettings.duration / 3600000} hours` : "No expiration"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Max Views:</span>
|
||||
<span className="font-medium">{shareSettings.maxViews || "Unlimited"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
Share Another Location
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
"use client"
|
||||
|
||||
import React, { useState } from "react"
|
||||
import type { ShareSettings, PrecisionLevel } from "@/lib/location/types"
|
||||
|
||||
interface ShareSettingsProps {
|
||||
onSettingsChange: (settings: ShareSettings) => void
|
||||
initialSettings?: Partial<ShareSettings>
|
||||
}
|
||||
|
||||
export const ShareSettingsComponent: React.FC<ShareSettingsProps> = ({ onSettingsChange, initialSettings = {} }) => {
|
||||
const [duration, setDuration] = useState<string>(
|
||||
initialSettings.duration ? String(initialSettings.duration / 3600000) : "24",
|
||||
)
|
||||
const [maxViews, setMaxViews] = useState<string>(
|
||||
initialSettings.maxViews ? String(initialSettings.maxViews) : "unlimited",
|
||||
)
|
||||
const [precision, setPrecision] = useState<PrecisionLevel>(initialSettings.precision || "street")
|
||||
|
||||
const handleChange = () => {
|
||||
const settings: ShareSettings = {
|
||||
duration: duration === "unlimited" ? null : Number(duration) * 3600000,
|
||||
maxViews: maxViews === "unlimited" ? null : Number(maxViews),
|
||||
precision,
|
||||
}
|
||||
onSettingsChange(settings)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
handleChange()
|
||||
}, [duration, maxViews, precision])
|
||||
|
||||
return (
|
||||
<div className="share-settings space-y-6">
|
||||
<div className="settings-header">
|
||||
<h3 className="text-lg font-semibold">Privacy Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">Control how your location is shared</p>
|
||||
</div>
|
||||
|
||||
{/* Precision Level */}
|
||||
<div className="setting-group">
|
||||
<label className="block text-sm font-medium mb-3">Location Precision</label>
|
||||
<div className="precision-options space-y-2">
|
||||
{[
|
||||
{ value: "exact", label: "Exact Location", desc: "Share precise coordinates" },
|
||||
{ value: "street", label: "Street Level", desc: "~100m radius" },
|
||||
{ value: "neighborhood", label: "Neighborhood", desc: "~1km radius" },
|
||||
{ value: "city", label: "City Level", desc: "~10km radius" },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`precision-option flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
precision === option.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="precision"
|
||||
value={option.value}
|
||||
checked={precision === option.value}
|
||||
onChange={(e) => setPrecision(e.target.value as PrecisionLevel)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{option.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="setting-group">
|
||||
<label htmlFor="duration" className="block text-sm font-medium mb-2">
|
||||
Share Duration
|
||||
</label>
|
||||
<select
|
||||
id="duration"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="1">1 hour</option>
|
||||
<option value="6">6 hours</option>
|
||||
<option value="24">24 hours</option>
|
||||
<option value="168">1 week</option>
|
||||
<option value="unlimited">No expiration</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Max Views */}
|
||||
<div className="setting-group">
|
||||
<label htmlFor="maxViews" className="block text-sm font-medium mb-2">
|
||||
Maximum Views
|
||||
</label>
|
||||
<select
|
||||
id="maxViews"
|
||||
value={maxViews}
|
||||
onChange={(e) => setMaxViews(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="1">1 view</option>
|
||||
<option value="5">5 views</option>
|
||||
<option value="10">10 views</option>
|
||||
<option value="unlimited">Unlimited</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Privacy Notice */}
|
||||
<div className="privacy-notice bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your location data is stored securely in your private filesystem. Only people with the share link can view
|
||||
your location, and shares automatically expire based on your settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// Environment-based worker URL configuration
|
||||
// You can easily switch between environments by changing the WORKER_ENV variable
|
||||
|
||||
// Available environments:
|
||||
// - 'local': Use local worker running on port 5172
|
||||
// - 'dev': Use Cloudflare dev environment (jeffemmett-canvas-automerge-dev)
|
||||
// - 'production': Use production environment (jeffemmett-canvas)
|
||||
|
||||
const WORKER_ENV = import.meta.env.VITE_WORKER_ENV || 'dev' // Default to dev for testing
|
||||
|
||||
const WORKER_URLS = {
|
||||
local: `http://${window.location.hostname}:5172`,
|
||||
dev: "https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev",
|
||||
production: "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||
}
|
||||
|
||||
// Main worker URL - automatically switches based on environment
|
||||
export const WORKER_URL = WORKER_URLS[WORKER_ENV as keyof typeof WORKER_URLS] || WORKER_URLS.dev
|
||||
|
||||
// Legacy support for existing code
|
||||
export const LOCAL_WORKER_URL = WORKER_URLS.local
|
||||
|
||||
// Helper function to get current environment info
|
||||
export const getWorkerInfo = () => ({
|
||||
environment: WORKER_ENV,
|
||||
url: WORKER_URL,
|
||||
isLocal: WORKER_ENV === 'local',
|
||||
isDev: WORKER_ENV === 'dev',
|
||||
isProduction: WORKER_ENV === 'production'
|
||||
})
|
||||
|
||||
// Log current environment on import (for debugging)
|
||||
console.log(`🔧 Worker Environment: ${WORKER_ENV}`)
|
||||
console.log(`🔧 Worker URL: ${WORKER_URL}`)
|
||||
console.log(`🔧 Available environments: local, dev, production`)
|
||||
console.log(`🔧 To switch: Set VITE_WORKER_ENV environment variable or change WORKER_ENV in this file`)
|
||||
|
|
@ -26,7 +26,7 @@ const initialSession: Session = {
|
|||
obsidianVaultName: undefined
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [session, setSessionState] = useState<Session>(initialSession);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import React, { createContext, useContext, ReactNode } from 'react'
|
||||
import { DocHandle } from '@automerge/automerge-repo'
|
||||
|
||||
interface AutomergeHandleContextType {
|
||||
handle: DocHandle<any> | null
|
||||
}
|
||||
|
||||
const AutomergeHandleContext = createContext<AutomergeHandleContextType>({
|
||||
handle: null,
|
||||
})
|
||||
|
||||
export const AutomergeHandleProvider: React.FC<{
|
||||
handle: DocHandle<any> | null
|
||||
children: ReactNode
|
||||
}> = ({ handle, children }) => {
|
||||
return (
|
||||
<AutomergeHandleContext.Provider value={{ handle }}>
|
||||
{children}
|
||||
</AutomergeHandleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAutomergeHandle = (): DocHandle<any> | null => {
|
||||
const context = useContext(AutomergeHandleContext)
|
||||
return context.handle
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
/* Location Sharing Components Styles */
|
||||
|
||||
/* Spinner animation */
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Location Capture */
|
||||
.location-capture {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.capture-header h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.capture-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Location Map */
|
||||
.location-map-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.location-map {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.map-loading,
|
||||
.map-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Share Settings */
|
||||
.share-settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.precision-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.precision-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.precision-option input[type="radio"] {
|
||||
margin-top: 0.125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.privacy-notice {
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(var(--muted), 0.5);
|
||||
}
|
||||
|
||||
/* Share Location Flow */
|
||||
.share-location {
|
||||
width: 100%;
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
height: 2px;
|
||||
width: 3rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.location-preview {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.share-success {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.share-link-box {
|
||||
background-color: rgba(var(--muted), 0.5);
|
||||
border: 1px solid rgba(var(--border), 1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.share-link-box input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(var(--border), 1);
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(var(--background), 1);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.share-details {
|
||||
background-color: rgba(var(--muted), 0.5);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Location Viewer */
|
||||
.location-viewer {
|
||||
width: 100%;
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.share-info {
|
||||
background-color: rgba(var(--muted), 0.5);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Location Dashboard */
|
||||
.location-dashboard {
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: rgba(var(--muted), 0.5);
|
||||
border: 1px solid rgba(var(--border), 1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(var(--muted-foreground), 1);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shares-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.share-card {
|
||||
background-color: rgba(var(--background), 1);
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid rgba(var(--border), 1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.share-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.share-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.share-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(var(--muted-foreground), 1);
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.share-card-body {
|
||||
padding: 1rem;
|
||||
padding-top: 0;
|
||||
border-top: 1px solid rgba(var(--border), 1);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Auth required messages */
|
||||
.share-location-auth,
|
||||
.location-dashboard-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Error messages */
|
||||
.error-message {
|
||||
background-color: rgba(var(--destructive), 0.1);
|
||||
border: 1px solid rgba(var(--destructive), 0.2);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.permission-denied {
|
||||
background-color: rgba(var(--destructive), 0.1);
|
||||
border: 1px solid rgba(var(--destructive), 0.2);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.current-location {
|
||||
background-color: rgba(var(--muted), 0.5);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.location-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.share-location,
|
||||
.location-viewer,
|
||||
.location-dashboard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.share-card-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.share-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -15,6 +15,23 @@
|
|||
pointer-events: auto; /* Ensure the browser is clickable */
|
||||
}
|
||||
|
||||
/* Shape mode: remove modal overlay styles */
|
||||
.obsidian-browser.shape-mode {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
background: transparent;
|
||||
z-index: auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.obsidian-browser > div {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
|
|
@ -39,6 +56,16 @@
|
|||
overflow-y: auto;
|
||||
position: relative; /* Allow absolute positioning of close button */
|
||||
pointer-events: auto; /* Ensure content is clickable */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Shape mode: adjust browser-content padding */
|
||||
.obsidian-browser.shape-mode .browser-content {
|
||||
padding: 0;
|
||||
padding-top: 0;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vault-title {
|
||||
|
|
@ -46,6 +73,14 @@
|
|||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Shape mode: reduce vault-title margin - hide completely when vault is connected */
|
||||
.obsidian-browser.shape-mode .vault-title {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
padding-top: 0;
|
||||
display: none; /* Hide completely since vault name is in header */
|
||||
}
|
||||
|
||||
.vault-title h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
|
|
@ -53,6 +88,24 @@
|
|||
color: #333;
|
||||
}
|
||||
|
||||
/* Shape mode: hide vault title when vault is connected (vault name is in header) */
|
||||
.obsidian-browser.shape-mode .vault-title h2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Shape mode: keep vault-connect-section visible when no vault */
|
||||
.obsidian-browser.shape-mode .vault-connect-section {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Show vault-title only when no vault is connected */
|
||||
.obsidian-browser.shape-mode .vault-title:has(.vault-connect-section) {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vault-connect-section {
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
|
|
@ -137,12 +190,24 @@
|
|||
pointer-events: auto; /* Ensure controls are clickable */
|
||||
}
|
||||
|
||||
/* Shape mode: adjust browser-controls padding - more compact */
|
||||
.obsidian-browser.shape-mode .browser-controls {
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Shape mode: reduce search-container margin */
|
||||
.obsidian-browser.shape-mode .search-container {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -177,6 +242,25 @@
|
|||
border-color: #007acc;
|
||||
}
|
||||
|
||||
.disconnect-vault-button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #dc3545;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.disconnect-vault-button:hover {
|
||||
background: #c82333;
|
||||
border-color: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -300,6 +384,7 @@
|
|||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.notes-display.grid {
|
||||
|
|
@ -316,6 +401,17 @@
|
|||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Shape mode: reduce notes-display padding for more space */
|
||||
.obsidian-browser.shape-mode .notes-display.grid {
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.obsidian-browser.shape-mode .notes-display.list {
|
||||
padding: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notes-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -327,6 +423,12 @@
|
|||
color: #666;
|
||||
}
|
||||
|
||||
/* Shape mode: reduce notes-header padding */
|
||||
.obsidian-browser.shape-mode .notes-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.last-imported {
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -335,6 +437,7 @@
|
|||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Note Cards */
|
||||
|
|
@ -1002,3 +1105,135 @@ mark {
|
|||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Folder Tree Styles */
|
||||
.folder-tree-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.folder-tree-item {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.folder-item.selected {
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
}
|
||||
|
||||
.folder-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-toggle:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.folder-count {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: #f0f0f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.note-item.selected {
|
||||
background-color: #e8f5e8;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.note-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.note-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.no-folder-tree {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Tree view specific adjustments */
|
||||
.notes-display.tree {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notes-display.tree .folder-tree-container {
|
||||
height: 100%;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -391,6 +391,19 @@ p:has(+ ol) {
|
|||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Ensure scrollable elements handle wheel events on the element being hovered */
|
||||
[style*="overflow-y: auto"],
|
||||
[style*="overflow-y: scroll"],
|
||||
[style*="overflow-x: auto"],
|
||||
[style*="overflow-x: scroll"],
|
||||
[style*="overflow: auto"],
|
||||
[style*="overflow: scroll"],
|
||||
.overflow-y-auto,
|
||||
.overflow-x-auto,
|
||||
.overflow-auto {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.tl-background {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@
|
|||
box-shadow: 0 4px 8px rgba(0, 122, 204, 0.3);
|
||||
}
|
||||
|
||||
.clear-vault-button {
|
||||
.disconnect-vault-button {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
|
|
@ -195,7 +195,7 @@
|
|||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-vault-button:hover {
|
||||
.disconnect-vault-button:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
interface SpeakerSegment {
|
||||
speaker: string
|
||||
text: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
confidence: number
|
||||
}
|
||||
|
||||
interface UseAdvancedSpeakerDiarizationOptions {
|
||||
onTranscriptUpdate?: (segments: SpeakerSegment[]) => void
|
||||
onError?: (error: Error) => void
|
||||
maxSpeakers?: number
|
||||
enableRealTime?: boolean
|
||||
}
|
||||
|
||||
export const useAdvancedSpeakerDiarization = ({
|
||||
onTranscriptUpdate,
|
||||
onError,
|
||||
maxSpeakers = 4,
|
||||
enableRealTime = false
|
||||
}: UseAdvancedSpeakerDiarizationOptions = {}) => {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [speakers, setSpeakers] = useState<string[]>([])
|
||||
const [segments, setSegments] = useState<SpeakerSegment[]>([])
|
||||
const [isSupported, setIsSupported] = useState(false)
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null)
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null)
|
||||
const audioBufferRef = useRef<Float32Array[]>([])
|
||||
|
||||
// Check if advanced features are supported
|
||||
useEffect(() => {
|
||||
// Check for Web Audio API support
|
||||
const hasWebAudio = !!(window.AudioContext || (window as any).webkitAudioContext)
|
||||
const hasMediaDevices = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
|
||||
|
||||
setIsSupported(hasWebAudio && hasMediaDevices)
|
||||
|
||||
if (!hasWebAudio) {
|
||||
onError?.(new Error('Web Audio API is not supported'))
|
||||
}
|
||||
if (!hasMediaDevices) {
|
||||
onError?.(new Error('Media Devices API is not supported'))
|
||||
}
|
||||
}, [onError])
|
||||
|
||||
// Simple speaker detection based on audio characteristics
|
||||
const detectSpeakerCharacteristics = useCallback((audioData: Float32Array) => {
|
||||
// Calculate basic audio features
|
||||
const rms = Math.sqrt(audioData.reduce((sum, val) => sum + val * val, 0) / audioData.length)
|
||||
const maxAmplitude = Math.max(...audioData.map(Math.abs))
|
||||
const zeroCrossings = audioData.slice(1).reduce((count, val, i) =>
|
||||
count + (Math.sign(val) !== Math.sign(audioData[i]) ? 1 : 0), 0
|
||||
)
|
||||
|
||||
// Simple speaker identification based on audio characteristics
|
||||
const speakerId = `Speaker_${Math.floor(rms * 1000) % maxSpeakers + 1}`
|
||||
|
||||
return {
|
||||
speakerId,
|
||||
confidence: Math.min(rms * 10, 1), // Simple confidence based on RMS
|
||||
features: {
|
||||
rms,
|
||||
maxAmplitude,
|
||||
zeroCrossings
|
||||
}
|
||||
}
|
||||
}, [maxSpeakers])
|
||||
|
||||
// Process audio data for speaker diarization
|
||||
const processAudioData = useCallback((audioData: Float32Array, timestamp: number) => {
|
||||
if (!enableRealTime) return
|
||||
|
||||
const speakerInfo = detectSpeakerCharacteristics(audioData)
|
||||
|
||||
// Create a simple segment
|
||||
const segment: SpeakerSegment = {
|
||||
speaker: speakerInfo.speakerId,
|
||||
text: '', // Would need transcription integration
|
||||
startTime: timestamp,
|
||||
endTime: timestamp + (audioData.length / 16000), // Assuming 16kHz
|
||||
confidence: speakerInfo.confidence
|
||||
}
|
||||
|
||||
// Update segments
|
||||
setSegments(prev => [...prev, segment])
|
||||
|
||||
// Update speakers list
|
||||
setSpeakers(prev => {
|
||||
if (!prev.includes(speakerInfo.speakerId)) {
|
||||
return [...prev, speakerInfo.speakerId]
|
||||
}
|
||||
return prev
|
||||
})
|
||||
|
||||
onTranscriptUpdate?.([segment])
|
||||
}, [enableRealTime, detectSpeakerCharacteristics, onTranscriptUpdate])
|
||||
|
||||
// Start audio processing
|
||||
const startProcessing = useCallback(async () => {
|
||||
if (!isSupported) {
|
||||
onError?.(new Error('Advanced speaker diarization not supported'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true)
|
||||
|
||||
// Get audio stream
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 16000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
})
|
||||
|
||||
mediaStreamRef.current = stream
|
||||
|
||||
// Create audio context
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 })
|
||||
audioContextRef.current = audioContext
|
||||
|
||||
// Create audio source
|
||||
const source = audioContext.createMediaStreamSource(stream)
|
||||
|
||||
// Create processor for real-time analysis
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1)
|
||||
processorRef.current = processor
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
const inputBuffer = event.inputBuffer
|
||||
const audioData = inputBuffer.getChannelData(0)
|
||||
const timestamp = audioContext.currentTime
|
||||
|
||||
processAudioData(audioData, timestamp)
|
||||
}
|
||||
|
||||
// Connect audio nodes
|
||||
source.connect(processor)
|
||||
processor.connect(audioContext.destination)
|
||||
|
||||
console.log('🎤 Advanced speaker diarization started')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting speaker diarization:', error)
|
||||
onError?.(error as Error)
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [isSupported, processAudioData, onError])
|
||||
|
||||
// Stop audio processing
|
||||
const stopProcessing = useCallback(() => {
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach(track => track.stop())
|
||||
mediaStreamRef.current = null
|
||||
}
|
||||
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect()
|
||||
processorRef.current = null
|
||||
}
|
||||
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close()
|
||||
audioContextRef.current = null
|
||||
}
|
||||
|
||||
setIsProcessing(false)
|
||||
console.log('🛑 Advanced speaker diarization stopped')
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopProcessing()
|
||||
}
|
||||
}, [stopProcessing])
|
||||
|
||||
// Format segments as readable text
|
||||
const formatSegmentsAsText = useCallback((segments: SpeakerSegment[]) => {
|
||||
return segments.map(segment =>
|
||||
`${segment.speaker}: ${segment.text}`
|
||||
).join('\n')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isProcessing,
|
||||
isSupported,
|
||||
speakers,
|
||||
segments,
|
||||
startProcessing,
|
||||
stopProcessing,
|
||||
formatSegmentsAsText
|
||||
}
|
||||
}
|
||||
|
||||
export default useAdvancedSpeakerDiarization
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
// TypeScript declarations for Web Speech API
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: typeof SpeechRecognition
|
||||
webkitSpeechRecognition: typeof SpeechRecognition
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
maxAlternatives: number
|
||||
start(): void
|
||||
stop(): void
|
||||
onstart: ((this: SpeechRecognition, ev: Event) => any) | null
|
||||
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null
|
||||
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null
|
||||
onend: ((this: SpeechRecognition, ev: Event) => any) | null
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
resultIndex: number
|
||||
results: SpeechRecognitionResultList
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
length: number
|
||||
item(index: number): SpeechRecognitionResult
|
||||
[index: number]: SpeechRecognitionResult
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
length: number
|
||||
item(index: number): SpeechRecognitionAlternative
|
||||
[index: number]: SpeechRecognitionAlternative
|
||||
isFinal: boolean
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
transcript: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
var SpeechRecognition: {
|
||||
prototype: SpeechRecognition
|
||||
new(): SpeechRecognition
|
||||
}
|
||||
}
|
||||
|
||||
interface UseWebSpeechTranscriptionOptions {
|
||||
onTranscriptUpdate?: (text: string) => void
|
||||
onError?: (error: Error) => void
|
||||
language?: string
|
||||
continuous?: boolean
|
||||
interimResults?: boolean
|
||||
}
|
||||
|
||||
export const useWebSpeechTranscription = ({
|
||||
onTranscriptUpdate,
|
||||
onError,
|
||||
language = 'en-US',
|
||||
continuous = true,
|
||||
interimResults = true
|
||||
}: UseWebSpeechTranscriptionOptions = {}) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||
const [transcript, setTranscript] = useState('')
|
||||
const [interimTranscript, setInterimTranscript] = useState('')
|
||||
const [isSupported, setIsSupported] = useState(false)
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const finalTranscriptRef = useRef('')
|
||||
const interimTranscriptRef = useRef('')
|
||||
const lastSpeechTimeRef = useRef<number>(0)
|
||||
const pauseTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const lastConfidenceRef = useRef<number>(0)
|
||||
const speakerChangeThreshold = 0.3 // Threshold for detecting speaker changes
|
||||
|
||||
// Function to add line breaks after pauses and improve punctuation
|
||||
const processTranscript = useCallback((text: string, isFinal: boolean = false, confidence?: number) => {
|
||||
if (!text.trim()) return text
|
||||
|
||||
let processedText = text.trim()
|
||||
|
||||
// Add punctuation if missing at the end
|
||||
if (isFinal && processedText && !/[.!?]$/.test(processedText)) {
|
||||
processedText += '.'
|
||||
}
|
||||
|
||||
// Add line break if there's been a pause (for final results)
|
||||
if (isFinal) {
|
||||
const now = Date.now()
|
||||
const timeSinceLastSpeech = now - lastSpeechTimeRef.current
|
||||
|
||||
// If more than 3 seconds since last speech, add a line break
|
||||
if (timeSinceLastSpeech > 3000 && lastSpeechTimeRef.current > 0) {
|
||||
processedText = '\n' + processedText
|
||||
}
|
||||
|
||||
lastSpeechTimeRef.current = now
|
||||
}
|
||||
|
||||
return processedText
|
||||
}, [])
|
||||
|
||||
// Function to detect speaker changes based on confidence and timing
|
||||
const detectSpeakerChange = useCallback((confidence: number) => {
|
||||
if (lastConfidenceRef.current === 0) {
|
||||
lastConfidenceRef.current = confidence
|
||||
return false
|
||||
}
|
||||
|
||||
const confidenceDiff = Math.abs(confidence - lastConfidenceRef.current)
|
||||
const now = Date.now()
|
||||
const timeSinceLastSpeech = now - lastSpeechTimeRef.current
|
||||
|
||||
// Detect speaker change if confidence changes significantly and there's been a pause
|
||||
const isSpeakerChange = confidenceDiff > speakerChangeThreshold && timeSinceLastSpeech > 1000
|
||||
|
||||
if (isSpeakerChange) {
|
||||
// Reduced debug logging
|
||||
lastConfidenceRef.current = confidence
|
||||
return true
|
||||
}
|
||||
|
||||
lastConfidenceRef.current = confidence
|
||||
return false
|
||||
}, [speakerChangeThreshold])
|
||||
|
||||
// Function to handle pause detection
|
||||
const handlePauseDetection = useCallback(() => {
|
||||
// Clear existing timeout
|
||||
if (pauseTimeoutRef.current) {
|
||||
clearTimeout(pauseTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set new timeout for pause detection
|
||||
pauseTimeoutRef.current = setTimeout(() => {
|
||||
const now = Date.now()
|
||||
const timeSinceLastSpeech = now - lastSpeechTimeRef.current
|
||||
|
||||
// If more than 2 seconds of silence, add a line break to interim transcript
|
||||
if (timeSinceLastSpeech > 2000 && lastSpeechTimeRef.current > 0) {
|
||||
const currentTranscript = finalTranscriptRef.current + '\n'
|
||||
setTranscript(currentTranscript)
|
||||
onTranscriptUpdate?.(currentTranscript)
|
||||
// Reduced debug logging
|
||||
}
|
||||
}, 2000) // Check after 2 seconds of silence
|
||||
}, [onTranscriptUpdate])
|
||||
|
||||
// Check if Web Speech API is supported
|
||||
useEffect(() => {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
if (SpeechRecognition) {
|
||||
setIsSupported(true)
|
||||
// Reduced debug logging
|
||||
} else {
|
||||
setIsSupported(false)
|
||||
console.log('❌ Web Speech API is not supported')
|
||||
onError?.(new Error('Web Speech API is not supported in this browser'))
|
||||
}
|
||||
}, [onError])
|
||||
|
||||
// Initialize speech recognition
|
||||
const initializeRecognition = useCallback(() => {
|
||||
if (!isSupported) return null
|
||||
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
const recognition = new SpeechRecognition()
|
||||
|
||||
recognition.continuous = continuous
|
||||
recognition.interimResults = interimResults
|
||||
recognition.lang = language
|
||||
recognition.maxAlternatives = 1
|
||||
|
||||
recognition.onstart = () => {
|
||||
console.log('🎤 Web Speech API started')
|
||||
setIsRecording(true)
|
||||
setIsTranscribing(true)
|
||||
}
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let interimTranscript = ''
|
||||
let finalTranscript = ''
|
||||
|
||||
// Process all results
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
const transcript = result[0].transcript
|
||||
|
||||
if (result.isFinal) {
|
||||
finalTranscript += transcript
|
||||
} else {
|
||||
interimTranscript += transcript
|
||||
}
|
||||
}
|
||||
|
||||
// Update final transcript with processing
|
||||
if (finalTranscript) {
|
||||
// Get confidence from the first result
|
||||
const confidence = event.results[event.results.length - 1]?.[0]?.confidence || 0
|
||||
|
||||
// Detect speaker change
|
||||
const isSpeakerChange = detectSpeakerChange(confidence)
|
||||
|
||||
// Add speaker indicator if change detected
|
||||
let speakerPrefix = ''
|
||||
if (isSpeakerChange) {
|
||||
speakerPrefix = '\n[Speaker Change]\n'
|
||||
}
|
||||
|
||||
const processedFinal = processTranscript(finalTranscript, true, confidence)
|
||||
const newText = speakerPrefix + processedFinal
|
||||
finalTranscriptRef.current += newText
|
||||
setTranscript(finalTranscriptRef.current)
|
||||
onTranscriptUpdate?.(newText) // Only send the new text portion
|
||||
console.log(`✅ Final transcript: "${processedFinal}" (confidence: ${confidence.toFixed(2)})`)
|
||||
|
||||
// Trigger pause detection
|
||||
handlePauseDetection()
|
||||
}
|
||||
|
||||
// Update interim transcript
|
||||
if (interimTranscript) {
|
||||
const processedInterim = processTranscript(interimTranscript, false)
|
||||
interimTranscriptRef.current = processedInterim
|
||||
setInterimTranscript(processedInterim)
|
||||
console.log(`🔄 Interim transcript: "${processedInterim}"`)
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('❌ Web Speech API error:', event.error)
|
||||
setIsRecording(false)
|
||||
setIsTranscribing(false)
|
||||
onError?.(new Error(`Speech recognition error: ${event.error}`))
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
console.log('🛑 Web Speech API ended')
|
||||
setIsRecording(false)
|
||||
setIsTranscribing(false)
|
||||
}
|
||||
|
||||
return recognition
|
||||
}, [isSupported, continuous, interimResults, language, onTranscriptUpdate, onError])
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
onError?.(new Error('Web Speech API is not supported'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🎤 Starting Web Speech API recording...')
|
||||
|
||||
// Don't reset transcripts for continuous transcription - keep existing content
|
||||
// finalTranscriptRef.current = ''
|
||||
// interimTranscriptRef.current = ''
|
||||
// setTranscript('')
|
||||
// setInterimTranscript('')
|
||||
lastSpeechTimeRef.current = 0
|
||||
lastConfidenceRef.current = 0
|
||||
|
||||
// Clear any existing pause timeout
|
||||
if (pauseTimeoutRef.current) {
|
||||
clearTimeout(pauseTimeoutRef.current)
|
||||
pauseTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// Initialize and start recognition
|
||||
const recognition = initializeRecognition()
|
||||
if (recognition) {
|
||||
recognitionRef.current = recognition
|
||||
recognition.start()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting Web Speech API:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [isSupported, initializeRecognition, onError])
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
console.log('🛑 Stopping Web Speech API recording...')
|
||||
recognitionRef.current.stop()
|
||||
recognitionRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup
|
||||
const cleanup = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop()
|
||||
recognitionRef.current = null
|
||||
}
|
||||
|
||||
// Clear pause timeout
|
||||
if (pauseTimeoutRef.current) {
|
||||
clearTimeout(pauseTimeoutRef.current)
|
||||
pauseTimeoutRef.current = null
|
||||
}
|
||||
|
||||
setIsRecording(false)
|
||||
setIsTranscribing(false)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return cleanup
|
||||
}, [cleanup])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
transcript,
|
||||
interimTranscript,
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Export as default for compatibility
|
||||
export default useWebSpeechTranscription
|
||||
|
|
@ -1,329 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getOpenAIConfig, isOpenAIConfigured } from '../lib/clientConfig'
|
||||
|
||||
interface UseWhisperTranscriptionOptions {
|
||||
apiKey?: string
|
||||
onTranscriptUpdate?: (text: string) => void
|
||||
onError?: (error: Error) => void
|
||||
language?: string
|
||||
enableStreaming?: boolean
|
||||
removeSilence?: boolean
|
||||
}
|
||||
|
||||
export const useWhisperTranscription = ({
|
||||
apiKey,
|
||||
onTranscriptUpdate,
|
||||
onError,
|
||||
language = 'en',
|
||||
enableStreaming: _enableStreaming = true,
|
||||
removeSilence: _removeSilence = true
|
||||
}: UseWhisperTranscriptionOptions = {}) => {
|
||||
const transcriptRef = useRef('')
|
||||
const isRecordingRef = useRef(false)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
|
||||
// Get OpenAI API key from user profile settings
|
||||
const openaiConfig = getOpenAIConfig()
|
||||
const isConfigured = isOpenAIConfigured()
|
||||
|
||||
// Custom state management
|
||||
const [recording, setRecording] = useState(false)
|
||||
const [speaking, setSpeaking] = useState(false)
|
||||
const [transcribing, setTranscribing] = useState(false)
|
||||
const [transcript, setTranscript] = useState({ text: '' })
|
||||
|
||||
// Custom startRecording implementation
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
console.log('🎤 Starting custom recording...')
|
||||
|
||||
// Get microphone access
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
streamRef.current = stream
|
||||
|
||||
// Debug the audio stream
|
||||
console.log('🎤 Audio stream created:', stream)
|
||||
console.log('🎤 Audio tracks:', stream.getAudioTracks().length)
|
||||
console.log('🎤 Track settings:', stream.getAudioTracks()[0]?.getSettings())
|
||||
|
||||
// Set up audio level monitoring
|
||||
const audioContext = new AudioContext()
|
||||
const analyser = audioContext.createAnalyser()
|
||||
const source = audioContext.createMediaStreamSource(stream)
|
||||
source.connect(analyser)
|
||||
analyser.fftSize = 256
|
||||
const bufferLength = analyser.frequencyBinCount
|
||||
const dataArray = new Uint8Array(bufferLength)
|
||||
|
||||
const checkAudioLevel = () => {
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
const average = dataArray.reduce((a, b) => a + b) / bufferLength
|
||||
console.log('🎵 Audio level:', average.toFixed(2))
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
requestAnimationFrame(checkAudioLevel)
|
||||
}
|
||||
}
|
||||
checkAudioLevel()
|
||||
|
||||
// Create MediaRecorder with fallback options
|
||||
let mediaRecorder: MediaRecorder
|
||||
const options = [
|
||||
{ mimeType: 'audio/webm;codecs=opus' },
|
||||
{ mimeType: 'audio/webm' },
|
||||
{ mimeType: 'audio/mp4' },
|
||||
{ mimeType: 'audio/wav' }
|
||||
]
|
||||
|
||||
for (const option of options) {
|
||||
if (MediaRecorder.isTypeSupported(option.mimeType)) {
|
||||
console.log('🎵 Using MIME type:', option.mimeType)
|
||||
mediaRecorder = new MediaRecorder(stream, option)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaRecorder!) {
|
||||
throw new Error('No supported audio format found')
|
||||
}
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
// Handle data available
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
console.log('🎵 Data available event fired!')
|
||||
console.log('🎵 Data size:', event.data.size, 'bytes')
|
||||
console.log('🎵 MediaRecorder state:', mediaRecorder.state)
|
||||
console.log('🎵 Event data type:', event.data.type)
|
||||
console.log('🎵 Current chunks count:', audioChunksRef.current.length)
|
||||
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
console.log('✅ Chunk added successfully, total chunks:', audioChunksRef.current.length)
|
||||
} else {
|
||||
console.log('⚠️ Empty data chunk received - this might be normal for the first chunk')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle MediaRecorder errors
|
||||
mediaRecorder.onerror = (event) => {
|
||||
console.error('❌ MediaRecorder error:', event)
|
||||
}
|
||||
|
||||
// Handle MediaRecorder state changes
|
||||
mediaRecorder.onstart = () => {
|
||||
console.log('🎤 MediaRecorder started')
|
||||
}
|
||||
|
||||
// Handle recording stop
|
||||
mediaRecorder.onstop = async () => {
|
||||
console.log('🛑 Recording stopped, processing audio...')
|
||||
console.log('🛑 Total chunks collected:', audioChunksRef.current.length)
|
||||
console.log('🛑 Chunk sizes:', audioChunksRef.current.map(chunk => chunk.size))
|
||||
setTranscribing(true)
|
||||
|
||||
try {
|
||||
// Create audio blob
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
|
||||
console.log('🎵 Audio blob created:', audioBlob.size, 'bytes')
|
||||
console.log('🎵 Audio chunks collected:', audioChunksRef.current.length)
|
||||
console.log('🎵 Blob type:', audioBlob.type)
|
||||
|
||||
if (audioBlob.size === 0) {
|
||||
console.error('❌ No audio data recorded!')
|
||||
console.error('❌ Chunks:', audioChunksRef.current)
|
||||
console.error('❌ Stream active:', streamRef.current?.active)
|
||||
console.error('❌ Stream tracks:', streamRef.current?.getTracks().length)
|
||||
throw new Error('No audio data was recorded. Please check microphone permissions and try again.')
|
||||
}
|
||||
|
||||
// Transcribe with OpenAI
|
||||
const apiKeyToUse = apiKey || openaiConfig?.apiKey
|
||||
console.log('🔑 Using API key:', apiKeyToUse ? 'present' : 'missing')
|
||||
console.log('🔑 API key length:', apiKeyToUse?.length || 0)
|
||||
|
||||
if (!apiKeyToUse) {
|
||||
throw new Error('No OpenAI API key available')
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', audioBlob, 'recording.webm')
|
||||
formData.append('model', 'whisper-1')
|
||||
formData.append('language', language)
|
||||
formData.append('response_format', 'text')
|
||||
|
||||
console.log('📤 Sending request to OpenAI API...')
|
||||
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKeyToUse}`,
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const transcriptionText = await response.text()
|
||||
console.log('🎯 TRANSCRIPTION RESULT:', transcriptionText)
|
||||
|
||||
setTranscript({ text: transcriptionText })
|
||||
onTranscriptUpdate?.(transcriptionText)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Transcription error:', error)
|
||||
onError?.(error as Error)
|
||||
} finally {
|
||||
setTranscribing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Start recording with timeslice to get data chunks
|
||||
mediaRecorder.start(1000) // 1-second chunks
|
||||
setRecording(true)
|
||||
isRecordingRef.current = true
|
||||
console.log('✅ Custom recording started with 1000ms timeslice')
|
||||
console.log('🎤 MediaRecorder state after start:', mediaRecorder.state)
|
||||
console.log('🎤 MediaRecorder mimeType:', mediaRecorder.mimeType)
|
||||
|
||||
// Auto-stop after 10 seconds for testing (increased time)
|
||||
setTimeout(() => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
console.log('⏰ Auto-stopping recording after 10 seconds...')
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
// Add a test to check if we're getting any data after 2 seconds
|
||||
setTimeout(() => {
|
||||
console.log('🧪 2-second test - chunks collected so far:', audioChunksRef.current.length)
|
||||
console.log('🧪 2-second test - chunk sizes:', audioChunksRef.current.map(chunk => chunk.size))
|
||||
console.log('🧪 2-second test - MediaRecorder state:', mediaRecorderRef.current?.state)
|
||||
}, 2000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting custom recording:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [apiKey, openaiConfig?.apiKey, language, onTranscriptUpdate, onError])
|
||||
|
||||
// Custom stopRecording implementation
|
||||
const stopRecording = useCallback(async () => {
|
||||
try {
|
||||
console.log('🛑 Stopping custom recording...')
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
setRecording(false)
|
||||
isRecordingRef.current = false
|
||||
console.log('✅ Custom recording stopped')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping custom recording:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [onError])
|
||||
|
||||
// Custom pauseRecording implementation (placeholder)
|
||||
const pauseRecording = useCallback(async () => {
|
||||
console.log('⏸️ Pause recording not implemented in custom version')
|
||||
}, [])
|
||||
|
||||
// Update transcript when it changes
|
||||
useEffect(() => {
|
||||
if (transcript?.text && transcript.text !== transcriptRef.current) {
|
||||
console.log('✅ New transcript text received:', transcript.text)
|
||||
console.log('🎯 TRANSCRIPT EMITTED TO CONSOLE:', transcript.text)
|
||||
transcriptRef.current = transcript.text
|
||||
onTranscriptUpdate?.(transcript.text)
|
||||
}
|
||||
}, [transcript?.text, onTranscriptUpdate])
|
||||
|
||||
// Handle recording state changes
|
||||
useEffect(() => {
|
||||
isRecordingRef.current = recording
|
||||
}, [recording])
|
||||
|
||||
// Check if OpenAI is configured
|
||||
useEffect(() => {
|
||||
if (!isConfigured && !apiKey) {
|
||||
onError?.(new Error('OpenAI API key not configured. Please set VITE_OPENAI_API_KEY in your environment variables.'))
|
||||
}
|
||||
}, [isConfigured, apiKey, onError])
|
||||
|
||||
const startTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('🎤 Starting custom Whisper transcription...')
|
||||
|
||||
// Check if OpenAI is configured
|
||||
if (!isConfigured && !apiKey) {
|
||||
console.error('❌ No OpenAI API key found')
|
||||
onError?.(new Error('OpenAI API key not configured. Please set VITE_OPENAI_API_KEY in your environment variables.'))
|
||||
return
|
||||
}
|
||||
|
||||
await startRecording()
|
||||
console.log('✅ Custom Whisper transcription started')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting custom Whisper transcription:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [startRecording, onError, apiKey, isConfigured])
|
||||
|
||||
const stopTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('🛑 Stopping custom Whisper transcription...')
|
||||
await stopRecording()
|
||||
console.log('✅ Custom Whisper transcription stopped')
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping custom Whisper transcription:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [stopRecording, onError])
|
||||
|
||||
const pauseTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('⏸️ Pausing custom Whisper transcription...')
|
||||
await pauseRecording()
|
||||
console.log('✅ Custom Whisper transcription paused')
|
||||
} catch (error) {
|
||||
console.error('❌ Error pausing custom Whisper transcription:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [pauseRecording, onError])
|
||||
|
||||
return {
|
||||
// State
|
||||
isRecording: recording,
|
||||
isSpeaking: speaking,
|
||||
isTranscribing: transcribing,
|
||||
transcript: transcript?.text || '',
|
||||
|
||||
// Actions
|
||||
startTranscription,
|
||||
stopTranscription,
|
||||
pauseTranscription,
|
||||
|
||||
// Raw functions for advanced usage
|
||||
startRecording,
|
||||
stopRecording,
|
||||
pauseRecording,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,967 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { pipeline, env } from '@xenova/transformers'
|
||||
|
||||
// Configure the transformers library
|
||||
env.allowRemoteModels = true
|
||||
env.allowLocalModels = false
|
||||
env.useBrowserCache = true
|
||||
env.useCustomCache = false
|
||||
|
||||
// Helper function to detect audio format from blob
|
||||
function detectAudioFormat(blob: Blob): Promise<string> {
|
||||
if (blob.type && blob.type !== 'application/octet-stream') {
|
||||
return Promise.resolve(blob.type)
|
||||
}
|
||||
|
||||
// Try to detect from the first few bytes
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const arrayBuffer = reader.result as ArrayBuffer
|
||||
if (!arrayBuffer || arrayBuffer.byteLength < 4) {
|
||||
resolve('audio/webm;codecs=opus') // Default fallback
|
||||
return
|
||||
}
|
||||
|
||||
const uint8Array = new Uint8Array(arrayBuffer.slice(0, 12))
|
||||
|
||||
// Check for common audio format signatures
|
||||
if (uint8Array[0] === 0x52 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46 && uint8Array[3] === 0x46) {
|
||||
resolve('audio/wav')
|
||||
} else if (uint8Array[0] === 0x4F && uint8Array[1] === 0x67 && uint8Array[2] === 0x67 && uint8Array[3] === 0x53) {
|
||||
resolve('audio/ogg;codecs=opus')
|
||||
} else if (uint8Array[0] === 0x1A && uint8Array[1] === 0x45 && uint8Array[2] === 0xDF && uint8Array[3] === 0xA3) {
|
||||
resolve('audio/webm;codecs=opus')
|
||||
} else {
|
||||
resolve('audio/webm;codecs=opus') // Default fallback
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Error detecting audio format:', error)
|
||||
resolve('audio/webm;codecs=opus') // Default fallback
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
resolve('audio/webm;codecs=opus') // Default fallback
|
||||
}
|
||||
reader.readAsArrayBuffer(blob.slice(0, 12))
|
||||
})
|
||||
}
|
||||
|
||||
// Simple resampling function for audio data
|
||||
function resampleAudio(audioData: Float32Array, fromSampleRate: number, toSampleRate: number): Float32Array {
|
||||
if (fromSampleRate === toSampleRate) {
|
||||
return audioData
|
||||
}
|
||||
|
||||
// Validate input parameters
|
||||
if (!audioData || audioData.length === 0) {
|
||||
throw new Error('Invalid audio data for resampling')
|
||||
}
|
||||
|
||||
if (fromSampleRate <= 0 || toSampleRate <= 0) {
|
||||
throw new Error('Invalid sample rates for resampling')
|
||||
}
|
||||
|
||||
const ratio = fromSampleRate / toSampleRate
|
||||
const newLength = Math.floor(audioData.length / ratio)
|
||||
|
||||
// Ensure we have a valid length
|
||||
if (newLength <= 0) {
|
||||
throw new Error('Invalid resampled length')
|
||||
}
|
||||
|
||||
const resampled = new Float32Array(newLength)
|
||||
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const sourceIndex = Math.floor(i * ratio)
|
||||
// Ensure sourceIndex is within bounds
|
||||
if (sourceIndex >= 0 && sourceIndex < audioData.length) {
|
||||
resampled[i] = audioData[sourceIndex]
|
||||
} else {
|
||||
resampled[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return resampled
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
name: string
|
||||
options: {
|
||||
quantized: boolean
|
||||
use_browser_cache: boolean
|
||||
use_custom_cache: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface UseWhisperTranscriptionOptions {
|
||||
onTranscriptUpdate?: (text: string) => void
|
||||
onError?: (error: Error) => void
|
||||
language?: string
|
||||
enableStreaming?: boolean
|
||||
enableAdvancedErrorHandling?: boolean
|
||||
modelOptions?: ModelOption[]
|
||||
autoInitialize?: boolean // If false, model will only load when startRecording is called
|
||||
}
|
||||
|
||||
export const useWhisperTranscription = ({
|
||||
onTranscriptUpdate,
|
||||
onError,
|
||||
language = 'en',
|
||||
enableStreaming = false,
|
||||
enableAdvancedErrorHandling = false,
|
||||
modelOptions,
|
||||
autoInitialize = true // Default to true for backward compatibility
|
||||
}: UseWhisperTranscriptionOptions = {}) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||
const [transcript, setTranscript] = useState('')
|
||||
const [modelLoaded, setModelLoaded] = useState(false)
|
||||
|
||||
const transcriberRef = useRef<any>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const isRecordingRef = useRef(false)
|
||||
const transcriptRef = useRef('')
|
||||
const streamingTranscriptRef = useRef('')
|
||||
const periodicTranscriptionRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const lastTranscriptionTimeRef = useRef<number>(0)
|
||||
const lastSpeechTimeRef = useRef<number>(0)
|
||||
const previousTranscriptLengthRef = useRef<number>(0) // Track previous transcript length for continuous transcription
|
||||
|
||||
// Function to process transcript with line breaks and punctuation
|
||||
const processTranscript = useCallback((text: string, isStreaming: boolean = false) => {
|
||||
if (!text.trim()) return text
|
||||
|
||||
let processedText = text.trim()
|
||||
|
||||
// Add punctuation if missing at the end
|
||||
if (!/[.!?]$/.test(processedText)) {
|
||||
processedText += '.'
|
||||
}
|
||||
|
||||
// Add line break if there's been a pause (for streaming)
|
||||
if (isStreaming) {
|
||||
const now = Date.now()
|
||||
const timeSinceLastSpeech = now - lastSpeechTimeRef.current
|
||||
|
||||
// If more than 3 seconds since last speech, add a line break
|
||||
if (timeSinceLastSpeech > 3000 && lastSpeechTimeRef.current > 0) {
|
||||
processedText = '\n' + processedText
|
||||
}
|
||||
|
||||
lastSpeechTimeRef.current = now
|
||||
}
|
||||
|
||||
return processedText
|
||||
}, [])
|
||||
|
||||
// Initialize transcriber with optional advanced error handling
|
||||
const initializeTranscriber = useCallback(async () => {
|
||||
if (transcriberRef.current) return transcriberRef.current
|
||||
|
||||
try {
|
||||
console.log('🤖 Loading Whisper model...')
|
||||
|
||||
// Check if we're running in a CORS-restricted environment
|
||||
if (typeof window !== 'undefined' && window.location.protocol === 'file:') {
|
||||
console.warn('⚠️ Running from file:// protocol - CORS issues may occur')
|
||||
console.warn('💡 Consider running from a local development server for better compatibility')
|
||||
}
|
||||
|
||||
if (enableAdvancedErrorHandling && modelOptions) {
|
||||
// Use advanced model loading with fallbacks
|
||||
let transcriber = null
|
||||
let lastError = null
|
||||
|
||||
for (const modelOption of modelOptions) {
|
||||
try {
|
||||
console.log(`🔄 Trying model: ${modelOption.name}`)
|
||||
transcriber = await pipeline('automatic-speech-recognition', modelOption.name, {
|
||||
...modelOption.options,
|
||||
progress_callback: (progress: any) => {
|
||||
if (progress.status === 'downloading') {
|
||||
console.log(`📦 Downloading model: ${progress.file} (${Math.round(progress.progress * 100)}%)`)
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(`✅ Successfully loaded model: ${modelOption.name}`)
|
||||
break
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Failed to load model ${modelOption.name}:`, error)
|
||||
lastError = error
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!transcriber) {
|
||||
throw lastError || new Error('Failed to load any model')
|
||||
}
|
||||
|
||||
transcriberRef.current = transcriber
|
||||
setModelLoaded(true)
|
||||
return transcriber
|
||||
} else {
|
||||
// Simple model loading (default behavior) with fallback
|
||||
const modelOptions = [
|
||||
'Xenova/whisper-tiny.en',
|
||||
'Xenova/whisper-tiny'
|
||||
]
|
||||
|
||||
let transcriber = null
|
||||
let lastError = null
|
||||
|
||||
for (const modelName of modelOptions) {
|
||||
try {
|
||||
// Reduced debug logging
|
||||
|
||||
const loadPromise = pipeline('automatic-speech-recognition', modelName, {
|
||||
quantized: true,
|
||||
progress_callback: (progress: any) => {
|
||||
if (progress.status === 'downloading') {
|
||||
console.log(`📦 Downloading model: ${progress.file} (${Math.round(progress.progress * 100)}%)`)
|
||||
} else if (progress.status === 'loading') {
|
||||
console.log(`🔄 Loading model: ${progress.file}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Model loading timeout')), 60000) // 60 seconds timeout
|
||||
)
|
||||
|
||||
transcriber = await Promise.race([loadPromise, timeoutPromise])
|
||||
|
||||
transcriberRef.current = transcriber
|
||||
setModelLoaded(true)
|
||||
console.log(`✅ Whisper model loaded: ${modelName}`)
|
||||
|
||||
return transcriber
|
||||
} catch (error) {
|
||||
// Reduced error logging - only show final error
|
||||
lastError = error
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If all models failed, throw the last error
|
||||
throw lastError || new Error('Failed to load any Whisper model')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load model:', error)
|
||||
onError?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}, [onError, enableAdvancedErrorHandling, modelOptions])
|
||||
|
||||
// Handle streaming transcript updates
|
||||
const handleStreamingTranscriptUpdate = useCallback((newText: string) => {
|
||||
if (newText.trim()) {
|
||||
const newTextTrimmed = newText.trim()
|
||||
const currentTranscript = streamingTranscriptRef.current.trim()
|
||||
|
||||
if (currentTranscript === '') {
|
||||
streamingTranscriptRef.current = newTextTrimmed
|
||||
} else {
|
||||
// Check if the new text is already contained in the current transcript
|
||||
if (!currentTranscript.includes(newTextTrimmed)) {
|
||||
streamingTranscriptRef.current = currentTranscript + ' ' + newTextTrimmed
|
||||
} else {
|
||||
// Find the best overlap point to avoid duplicates
|
||||
const words = newTextTrimmed.split(' ')
|
||||
const currentWords = currentTranscript.split(' ')
|
||||
|
||||
let overlapIndex = 0
|
||||
let maxOverlap = 0
|
||||
|
||||
for (let i = 1; i <= Math.min(words.length, currentWords.length); i++) {
|
||||
const currentEnd = currentWords.slice(-i).join(' ')
|
||||
const newStart = words.slice(0, i).join(' ')
|
||||
|
||||
if (currentEnd === newStart && i > maxOverlap) {
|
||||
maxOverlap = i
|
||||
overlapIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if (overlapIndex > 0 && overlapIndex < words.length) {
|
||||
const newPart = words.slice(overlapIndex).join(' ')
|
||||
streamingTranscriptRef.current = currentTranscript + ' ' + newPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processedTranscript = processTranscript(streamingTranscriptRef.current, true)
|
||||
streamingTranscriptRef.current = processedTranscript
|
||||
setTranscript(processedTranscript)
|
||||
|
||||
// Only send the new portion for continuous transcription
|
||||
const newTextPortion = processedTranscript.substring(previousTranscriptLengthRef.current)
|
||||
if (newTextPortion.trim()) {
|
||||
onTranscriptUpdate?.(newTextPortion)
|
||||
previousTranscriptLengthRef.current = processedTranscript.length
|
||||
}
|
||||
|
||||
console.log(`📝 Real-time transcript updated: "${newTextTrimmed}" -> Total: "${processedTranscript}"`)
|
||||
console.log(`🔄 Streaming transcript state updated, calling onTranscriptUpdate with: "${processedTranscript}"`)
|
||||
}
|
||||
}, [onTranscriptUpdate, processTranscript])
|
||||
|
||||
// Process accumulated audio chunks for streaming transcription
|
||||
const processAccumulatedAudioChunks = useCallback(async () => {
|
||||
try {
|
||||
// Throttle transcription requests
|
||||
const now = Date.now()
|
||||
if (now - (lastTranscriptionTimeRef.current || 0) < 800) { // Reduced to 0.8 seconds for better responsiveness
|
||||
return // Skip if less than 0.8 seconds since last transcription
|
||||
}
|
||||
|
||||
const chunks = audioChunksRef.current || []
|
||||
if (chunks.length === 0 || chunks.length < 2) {
|
||||
console.log(`⚠️ Not enough chunks for real-time processing: ${chunks.length}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Take the last 4-5 chunks for balanced processing (1-2 seconds)
|
||||
const recentChunks = chunks.slice(-5)
|
||||
const validChunks = recentChunks.filter(chunk => chunk && chunk.size > 2000) // Filter out small chunks
|
||||
|
||||
if (validChunks.length < 2) {
|
||||
console.log(`⚠️ Not enough valid chunks for real-time processing: ${validChunks.length}`)
|
||||
return
|
||||
}
|
||||
|
||||
const totalSize = validChunks.reduce((sum, chunk) => sum + chunk.size, 0)
|
||||
if (totalSize < 20000) { // Increased to 20KB for reliable decoding
|
||||
console.log(`⚠️ Not enough audio data for real-time processing: ${totalSize} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the MIME type from the MediaRecorder, not individual chunks
|
||||
let mimeType = 'audio/webm;codecs=opus' // Default to WebM
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.mimeType) {
|
||||
mimeType = mediaRecorderRef.current.mimeType
|
||||
}
|
||||
|
||||
console.log(`🔄 Real-time processing ${validChunks.length} chunks, total size: ${totalSize} bytes, type: ${mimeType}`)
|
||||
console.log(`🔄 Chunk sizes:`, validChunks.map(c => c.size))
|
||||
console.log(`🔄 Chunk types:`, validChunks.map(c => c.type))
|
||||
|
||||
// Create a more robust blob with proper headers
|
||||
const tempBlob = new Blob(validChunks, { type: mimeType })
|
||||
|
||||
// Validate blob size
|
||||
if (tempBlob.size < 10000) {
|
||||
console.log(`⚠️ Blob too small for processing: ${tempBlob.size} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
const audioBuffer = await tempBlob.arrayBuffer()
|
||||
|
||||
// Validate audio buffer
|
||||
if (audioBuffer.byteLength < 10000) {
|
||||
console.log(`⚠️ Audio buffer too small: ${audioBuffer.byteLength} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
const audioContext = new AudioContext()
|
||||
let audioBufferFromBlob: AudioBuffer
|
||||
|
||||
try {
|
||||
// Try to decode the audio buffer
|
||||
audioBufferFromBlob = await audioContext.decodeAudioData(audioBuffer)
|
||||
console.log(`✅ Successfully decoded real-time audio buffer: ${audioBufferFromBlob.length} samples`)
|
||||
} catch (decodeError) {
|
||||
console.log('⚠️ Real-time chunk decode failed, trying alternative approach:', decodeError)
|
||||
|
||||
// Try alternative approach: create a new blob with different MIME type
|
||||
try {
|
||||
const alternativeBlob = new Blob(validChunks, { type: 'audio/webm' })
|
||||
const alternativeBuffer = await alternativeBlob.arrayBuffer()
|
||||
audioBufferFromBlob = await audioContext.decodeAudioData(alternativeBuffer)
|
||||
console.log(`✅ Successfully decoded with alternative approach: ${audioBufferFromBlob.length} samples`)
|
||||
} catch (altError) {
|
||||
console.log('⚠️ Alternative decode also failed, skipping:', altError)
|
||||
await audioContext.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await audioContext.close()
|
||||
|
||||
const audioData = audioBufferFromBlob.getChannelData(0)
|
||||
if (!audioData || audioData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resample if necessary
|
||||
let processedAudioData: Float32Array = audioData
|
||||
if (audioBufferFromBlob.sampleRate !== 16000) {
|
||||
processedAudioData = resampleAudio(audioData as Float32Array, audioBufferFromBlob.sampleRate, 16000)
|
||||
}
|
||||
|
||||
// Check for meaningful audio content
|
||||
const rms = Math.sqrt(processedAudioData.reduce((sum, val) => sum + val * val, 0) / processedAudioData.length)
|
||||
const maxAmplitude = Math.max(...processedAudioData.map(Math.abs))
|
||||
const dynamicRange = maxAmplitude - Math.min(...processedAudioData.map(Math.abs))
|
||||
|
||||
console.log(`🔊 Real-time audio analysis: RMS=${rms.toFixed(6)}, Max=${maxAmplitude.toFixed(6)}, Range=${dynamicRange.toFixed(6)}`)
|
||||
|
||||
if (rms < 0.001) {
|
||||
console.log('⚠️ Audio too quiet for transcription (RMS < 0.001)')
|
||||
return // Skip very quiet audio
|
||||
}
|
||||
|
||||
if (dynamicRange < 0.01) {
|
||||
console.log('⚠️ Audio has very low dynamic range, may be mostly noise')
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure reasonable length for real-time processing (max 2 seconds for balanced speed)
|
||||
const maxRealtimeSamples = 32000 // 2 seconds at 16kHz
|
||||
if (processedAudioData.length > maxRealtimeSamples) {
|
||||
processedAudioData = processedAudioData.slice(-maxRealtimeSamples)
|
||||
}
|
||||
|
||||
if (processedAudioData.length < 2000) { // Increased to 2 second minimum for reliable processing
|
||||
return // Skip very short audio
|
||||
}
|
||||
|
||||
console.log(`🎵 Real-time audio: ${processedAudioData.length} samples (${(processedAudioData.length / 16000).toFixed(2)}s)`)
|
||||
|
||||
// Transcribe with parameters optimized for real-time processing
|
||||
const result = await transcriberRef.current(processedAudioData, {
|
||||
language: language,
|
||||
task: 'transcribe',
|
||||
return_timestamps: false,
|
||||
chunk_length_s: 5, // Longer chunks for better context
|
||||
stride_length_s: 2, // Larger stride for better coverage
|
||||
no_speech_threshold: 0.3, // Higher threshold to reduce noise
|
||||
logprob_threshold: -0.8, // More sensitive detection
|
||||
compression_ratio_threshold: 2.0 // More permissive for real-time
|
||||
})
|
||||
|
||||
const transcriptionText = result?.text || ''
|
||||
if (transcriptionText.trim()) {
|
||||
lastTranscriptionTimeRef.current = Date.now()
|
||||
console.log(`✅ Real-time transcript: "${transcriptionText.trim()}"`)
|
||||
console.log(`🔄 Calling handleStreamingTranscriptUpdate with: "${transcriptionText.trim()}"`)
|
||||
handleStreamingTranscriptUpdate(transcriptionText.trim())
|
||||
} else {
|
||||
console.log('⚠️ No real-time transcription text produced, trying fallback parameters...')
|
||||
|
||||
// Try with more permissive parameters for real-time processing
|
||||
try {
|
||||
const fallbackResult = await transcriberRef.current(processedAudioData, {
|
||||
task: 'transcribe',
|
||||
return_timestamps: false,
|
||||
chunk_length_s: 3, // Shorter chunks for fallback
|
||||
stride_length_s: 1, // Smaller stride for fallback
|
||||
no_speech_threshold: 0.1, // Very low threshold for fallback
|
||||
logprob_threshold: -1.2, // Very sensitive for fallback
|
||||
compression_ratio_threshold: 2.5 // Very permissive for fallback
|
||||
})
|
||||
|
||||
const fallbackText = fallbackResult?.text || ''
|
||||
if (fallbackText.trim()) {
|
||||
console.log(`✅ Fallback real-time transcript: "${fallbackText.trim()}"`)
|
||||
lastTranscriptionTimeRef.current = Date.now()
|
||||
handleStreamingTranscriptUpdate(fallbackText.trim())
|
||||
} else {
|
||||
console.log('⚠️ Fallback transcription also produced no text')
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.log('⚠️ Fallback transcription failed:', fallbackError)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing accumulated audio chunks:', error)
|
||||
}
|
||||
}, [handleStreamingTranscriptUpdate, language])
|
||||
|
||||
// Process recorded audio chunks (final processing)
|
||||
const processAudioChunks = useCallback(async () => {
|
||||
if (!transcriberRef.current || audioChunksRef.current.length === 0) {
|
||||
console.log('⚠️ No transcriber or audio chunks to process')
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure model is loaded
|
||||
if (!modelLoaded) {
|
||||
console.log('⚠️ Model not loaded yet, waiting...')
|
||||
try {
|
||||
await initializeTranscriber()
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize transcriber:', error)
|
||||
onError?.(error as Error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setIsTranscribing(true)
|
||||
console.log('🔄 Processing final audio chunks...')
|
||||
|
||||
// Create a blob from all chunks with proper MIME type detection
|
||||
let mimeType = 'audio/webm;codecs=opus'
|
||||
if (audioChunksRef.current.length > 0 && audioChunksRef.current[0].type) {
|
||||
mimeType = audioChunksRef.current[0].type
|
||||
}
|
||||
|
||||
// Filter out small chunks that might be corrupted
|
||||
const validChunks = audioChunksRef.current.filter(chunk => chunk && chunk.size > 1000)
|
||||
|
||||
if (validChunks.length === 0) {
|
||||
console.log('⚠️ No valid audio chunks to process')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`🔄 Processing ${validChunks.length} valid chunks out of ${audioChunksRef.current.length} total chunks`)
|
||||
|
||||
const audioBlob = new Blob(validChunks, { type: mimeType })
|
||||
|
||||
// Validate blob size
|
||||
if (audioBlob.size < 10000) {
|
||||
console.log(`⚠️ Audio blob too small for processing: ${audioBlob.size} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert blob to array buffer
|
||||
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||
|
||||
// Validate array buffer
|
||||
if (arrayBuffer.byteLength < 10000) {
|
||||
console.log(`⚠️ Audio buffer too small: ${arrayBuffer.byteLength} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create audio context to convert to Float32Array
|
||||
const audioContext = new AudioContext()
|
||||
|
||||
let audioBuffer: AudioBuffer
|
||||
try {
|
||||
audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||
console.log(`✅ Successfully decoded final audio buffer: ${audioBuffer.length} samples`)
|
||||
} catch (decodeError) {
|
||||
console.error('❌ Failed to decode final audio buffer:', decodeError)
|
||||
|
||||
// Try alternative approach with different MIME type
|
||||
try {
|
||||
console.log('🔄 Trying alternative MIME type for final processing...')
|
||||
const alternativeBlob = new Blob(validChunks, { type: 'audio/webm' })
|
||||
const alternativeBuffer = await alternativeBlob.arrayBuffer()
|
||||
audioBuffer = await audioContext.decodeAudioData(alternativeBuffer)
|
||||
console.log(`✅ Successfully decoded with alternative approach: ${audioBuffer.length} samples`)
|
||||
} catch (altError) {
|
||||
console.error('❌ Alternative decode also failed:', altError)
|
||||
await audioContext.close()
|
||||
throw new Error('Failed to decode audio data. The audio format may not be supported or the data may be corrupted.')
|
||||
}
|
||||
}
|
||||
|
||||
await audioContext.close()
|
||||
|
||||
// Get the first channel as Float32Array
|
||||
const audioData = audioBuffer.getChannelData(0)
|
||||
|
||||
console.log(`🔍 Audio buffer info: sampleRate=${audioBuffer.sampleRate}, length=${audioBuffer.length}, duration=${audioBuffer.duration}s`)
|
||||
console.log(`🔍 Audio data: length=${audioData.length}, first 10 values:`, Array.from(audioData.slice(0, 10)))
|
||||
|
||||
// Check for meaningful audio content
|
||||
const rms = Math.sqrt(audioData.reduce((sum, val) => sum + val * val, 0) / audioData.length)
|
||||
console.log(`🔊 Audio RMS level: ${rms.toFixed(6)}`)
|
||||
|
||||
if (rms < 0.001) {
|
||||
console.log('⚠️ Audio appears to be mostly silence (RMS < 0.001)')
|
||||
}
|
||||
|
||||
// Resample if necessary
|
||||
let processedAudioData: Float32Array = audioData
|
||||
if (audioBuffer.sampleRate !== 16000) {
|
||||
console.log(`🔄 Resampling from ${audioBuffer.sampleRate}Hz to 16000Hz`)
|
||||
processedAudioData = resampleAudio(audioData as Float32Array, audioBuffer.sampleRate, 16000)
|
||||
}
|
||||
|
||||
console.log(`🎵 Processing audio: ${processedAudioData.length} samples (${(processedAudioData.length / 16000).toFixed(2)}s)`)
|
||||
|
||||
// Check if transcriber is available
|
||||
if (!transcriberRef.current) {
|
||||
console.error('❌ Transcriber not available for processing')
|
||||
throw new Error('Transcriber not initialized')
|
||||
}
|
||||
|
||||
console.log('🔄 Starting transcription with Whisper model...')
|
||||
|
||||
// Transcribe the audio
|
||||
const result = await transcriberRef.current(processedAudioData, {
|
||||
language: language,
|
||||
task: 'transcribe',
|
||||
return_timestamps: false
|
||||
})
|
||||
|
||||
console.log('🔍 Transcription result:', result)
|
||||
|
||||
const newText = result?.text?.trim() || ''
|
||||
if (newText) {
|
||||
const processedText = processTranscript(newText, enableStreaming)
|
||||
|
||||
if (enableStreaming) {
|
||||
// For streaming mode, merge with existing streaming transcript
|
||||
handleStreamingTranscriptUpdate(processedText)
|
||||
} else {
|
||||
// For non-streaming mode, append to existing transcript
|
||||
const currentTranscript = transcriptRef.current
|
||||
const updatedTranscript = currentTranscript ? `${currentTranscript} ${processedText}` : processedText
|
||||
|
||||
transcriptRef.current = updatedTranscript
|
||||
setTranscript(updatedTranscript)
|
||||
|
||||
// Only send the new portion for continuous transcription
|
||||
const newTextPortion = updatedTranscript.substring(previousTranscriptLengthRef.current)
|
||||
if (newTextPortion.trim()) {
|
||||
onTranscriptUpdate?.(newTextPortion)
|
||||
previousTranscriptLengthRef.current = updatedTranscript.length
|
||||
}
|
||||
|
||||
console.log(`✅ Transcription: "${processedText}" -> Total: "${updatedTranscript}"`)
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No transcription text produced')
|
||||
console.log('🔍 Full transcription result object:', result)
|
||||
|
||||
// Try alternative transcription parameters
|
||||
console.log('🔄 Trying alternative transcription parameters...')
|
||||
try {
|
||||
const altResult = await transcriberRef.current(processedAudioData, {
|
||||
task: 'transcribe',
|
||||
return_timestamps: false
|
||||
})
|
||||
console.log('🔍 Alternative transcription result:', altResult)
|
||||
|
||||
if (altResult?.text?.trim()) {
|
||||
const processedAltText = processTranscript(altResult.text, enableStreaming)
|
||||
console.log('✅ Alternative transcription successful:', processedAltText)
|
||||
const currentTranscript = transcriptRef.current
|
||||
const updatedTranscript = currentTranscript ? `${currentTranscript} ${processedAltText}` : processedAltText
|
||||
|
||||
transcriptRef.current = updatedTranscript
|
||||
setTranscript(updatedTranscript)
|
||||
|
||||
// Only send the new portion for continuous transcription
|
||||
const newTextPortion = updatedTranscript.substring(previousTranscriptLengthRef.current)
|
||||
if (newTextPortion.trim()) {
|
||||
onTranscriptUpdate?.(newTextPortion)
|
||||
previousTranscriptLengthRef.current = updatedTranscript.length
|
||||
}
|
||||
}
|
||||
} catch (altError) {
|
||||
console.log('⚠️ Alternative transcription also failed:', altError)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear processed chunks
|
||||
audioChunksRef.current = []
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing audio:', error)
|
||||
onError?.(error as Error)
|
||||
} finally {
|
||||
setIsTranscribing(false)
|
||||
}
|
||||
}, [transcriberRef, language, onTranscriptUpdate, onError, enableStreaming, handleStreamingTranscriptUpdate, modelLoaded, initializeTranscriber])
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
console.log('🎤 Starting recording...')
|
||||
console.log('🔍 enableStreaming in startRecording:', enableStreaming)
|
||||
|
||||
// Ensure model is loaded before starting
|
||||
if (!modelLoaded) {
|
||||
console.log('🔄 Model not loaded, initializing...')
|
||||
await initializeTranscriber()
|
||||
}
|
||||
|
||||
// Don't reset transcripts for continuous transcription - keep existing content
|
||||
// transcriptRef.current = ''
|
||||
// streamingTranscriptRef.current = ''
|
||||
// setTranscript('')
|
||||
lastSpeechTimeRef.current = 0
|
||||
audioChunksRef.current = []
|
||||
lastTranscriptionTimeRef.current = 0
|
||||
|
||||
// Clear any existing periodic transcription timer
|
||||
if (periodicTranscriptionRef.current) {
|
||||
clearInterval(periodicTranscriptionRef.current)
|
||||
periodicTranscriptionRef.current = null
|
||||
}
|
||||
|
||||
// Get microphone access
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
sampleRate: 44100,
|
||||
channelCount: 1
|
||||
}
|
||||
})
|
||||
|
||||
streamRef.current = stream
|
||||
|
||||
// Create MediaRecorder with fallback options
|
||||
let mediaRecorder: MediaRecorder
|
||||
const options = [
|
||||
{ mimeType: 'audio/webm;codecs=opus' },
|
||||
{ mimeType: 'audio/webm' },
|
||||
{ mimeType: 'audio/ogg;codecs=opus' },
|
||||
{ mimeType: 'audio/ogg' },
|
||||
{ mimeType: 'audio/wav' },
|
||||
{ mimeType: 'audio/mp4' }
|
||||
]
|
||||
|
||||
for (const option of options) {
|
||||
if (MediaRecorder.isTypeSupported(option.mimeType)) {
|
||||
console.log('🎵 Using MIME type:', option.mimeType)
|
||||
mediaRecorder = new MediaRecorder(stream, option)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaRecorder!) {
|
||||
throw new Error('No supported audio format found')
|
||||
}
|
||||
|
||||
// Store the MIME type for later use
|
||||
const mimeType = mediaRecorder.mimeType
|
||||
console.log('🎵 Final MIME type:', mimeType)
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
|
||||
// Handle data available
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
// Validate chunk before adding
|
||||
if (event.data.size > 1000) { // Only add chunks with meaningful size
|
||||
audioChunksRef.current.push(event.data)
|
||||
console.log(`📦 Received chunk ${audioChunksRef.current.length}, size: ${event.data.size} bytes, type: ${event.data.type}`)
|
||||
|
||||
// Limit the number of chunks to prevent memory issues
|
||||
if (audioChunksRef.current.length > 20) {
|
||||
audioChunksRef.current = audioChunksRef.current.slice(-15) // Keep last 15 chunks
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ Skipping small chunk: ${event.data.size} bytes`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle recording stop
|
||||
mediaRecorder.onstop = () => {
|
||||
console.log('🛑 Recording stopped, processing audio...')
|
||||
processAudioChunks()
|
||||
}
|
||||
|
||||
// Handle MediaRecorder state changes
|
||||
mediaRecorder.onstart = () => {
|
||||
console.log('🎤 MediaRecorder started')
|
||||
console.log('🔍 enableStreaming value:', enableStreaming)
|
||||
setIsRecording(true)
|
||||
isRecordingRef.current = true
|
||||
|
||||
// Start periodic transcription processing for streaming mode
|
||||
if (enableStreaming) {
|
||||
console.log('🔄 Starting streaming transcription (every 0.8 seconds)')
|
||||
periodicTranscriptionRef.current = setInterval(() => {
|
||||
console.log('🔄 Interval triggered, isRecordingRef.current:', isRecordingRef.current)
|
||||
if (isRecordingRef.current) {
|
||||
console.log('🔄 Running periodic streaming transcription...')
|
||||
processAccumulatedAudioChunks()
|
||||
} else {
|
||||
console.log('⚠️ Not running transcription - recording stopped')
|
||||
}
|
||||
}, 800) // Update every 0.8 seconds for better responsiveness
|
||||
} else {
|
||||
console.log('ℹ️ Streaming transcription disabled - enableStreaming is false')
|
||||
}
|
||||
}
|
||||
|
||||
// Start recording with appropriate timeslice
|
||||
const timeslice = enableStreaming ? 1000 : 2000 // Larger chunks for more stable processing
|
||||
console.log(`🎵 Starting recording with ${timeslice}ms timeslice`)
|
||||
mediaRecorder.start(timeslice)
|
||||
isRecordingRef.current = true
|
||||
setIsRecording(true)
|
||||
|
||||
console.log('✅ Recording started - MediaRecorder state:', mediaRecorder.state)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting recording:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [processAudioChunks, processAccumulatedAudioChunks, onError, enableStreaming, modelLoaded, initializeTranscriber])
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(async () => {
|
||||
try {
|
||||
console.log('🛑 Stopping recording...')
|
||||
|
||||
// Clear periodic transcription timer
|
||||
if (periodicTranscriptionRef.current) {
|
||||
clearInterval(periodicTranscriptionRef.current)
|
||||
periodicTranscriptionRef.current = null
|
||||
}
|
||||
|
||||
if (mediaRecorderRef.current && isRecordingRef.current) {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
isRecordingRef.current = false
|
||||
setIsRecording(false)
|
||||
|
||||
console.log('✅ Recording stopped')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping recording:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [onError])
|
||||
|
||||
// Pause recording (placeholder for compatibility)
|
||||
const pauseRecording = useCallback(async () => {
|
||||
console.log('⏸️ Pause recording not implemented')
|
||||
}, [])
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
console.log('🧹 Cleaning up transcription resources...')
|
||||
|
||||
// Stop recording if active
|
||||
if (isRecordingRef.current) {
|
||||
setIsRecording(false)
|
||||
isRecordingRef.current = false
|
||||
}
|
||||
|
||||
// Clear periodic transcription timer
|
||||
if (periodicTranscriptionRef.current) {
|
||||
clearInterval(periodicTranscriptionRef.current)
|
||||
periodicTranscriptionRef.current = null
|
||||
}
|
||||
|
||||
// Stop MediaRecorder if active
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
|
||||
// Stop audio stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
// Clear chunks
|
||||
audioChunksRef.current = []
|
||||
|
||||
console.log('✅ Cleanup completed')
|
||||
}, [])
|
||||
|
||||
// Convenience functions for compatibility
|
||||
const startTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('🎤 Starting transcription...')
|
||||
|
||||
// Reset all transcription state for clean start
|
||||
streamingTranscriptRef.current = ''
|
||||
setTranscript('')
|
||||
setIsRecording(false)
|
||||
isRecordingRef.current = false
|
||||
lastTranscriptionTimeRef.current = 0
|
||||
|
||||
// Clear any existing timers
|
||||
if (periodicTranscriptionRef.current) {
|
||||
clearInterval(periodicTranscriptionRef.current)
|
||||
periodicTranscriptionRef.current = null
|
||||
}
|
||||
|
||||
// Initialize the model if not already loaded
|
||||
if (!modelLoaded) {
|
||||
await initializeTranscriber()
|
||||
}
|
||||
|
||||
await startRecording()
|
||||
console.log('✅ Transcription started')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting transcription:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [startRecording, onError, modelLoaded, initializeTranscriber])
|
||||
|
||||
const stopTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('🛑 Stopping transcription...')
|
||||
await stopRecording()
|
||||
console.log('✅ Transcription stopped')
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping transcription:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [stopRecording, onError])
|
||||
|
||||
const pauseTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('⏸️ Pausing transcription...')
|
||||
await pauseRecording()
|
||||
console.log('✅ Transcription paused')
|
||||
} catch (error) {
|
||||
console.error('❌ Error pausing transcription:', error)
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [pauseRecording, onError])
|
||||
|
||||
// Initialize model on mount (only if autoInitialize is true)
|
||||
useEffect(() => {
|
||||
if (autoInitialize) {
|
||||
initializeTranscriber().catch(console.warn)
|
||||
}
|
||||
}, [initializeTranscriber, autoInitialize])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup()
|
||||
}
|
||||
}, [cleanup])
|
||||
|
||||
return {
|
||||
// State
|
||||
isRecording,
|
||||
isSpeaking,
|
||||
isTranscribing,
|
||||
transcript,
|
||||
modelLoaded,
|
||||
|
||||
// Actions
|
||||
startTranscription,
|
||||
stopTranscription,
|
||||
pauseTranscription,
|
||||
|
||||
// Raw functions for advanced usage
|
||||
startRecording,
|
||||
stopRecording,
|
||||
pauseRecording,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Export both the new consolidated hook and the old name for backward compatibility
|
||||
export const useWhisperTranscriptionSimple = useWhisperTranscription
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
import HoloSphere from 'holosphere'
|
||||
import * as h3 from 'h3-js'
|
||||
|
||||
export interface HolonData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
resolution: number
|
||||
data: Record<string, any>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface HolonLens {
|
||||
name: string
|
||||
schema?: any
|
||||
data: any[]
|
||||
}
|
||||
|
||||
export interface HolonConnection {
|
||||
id: string
|
||||
name: string
|
||||
type: 'federation' | 'reference'
|
||||
targetSpace: string
|
||||
status: 'connected' | 'disconnected' | 'error'
|
||||
}
|
||||
|
||||
export class HoloSphereService {
|
||||
private sphere!: HoloSphere
|
||||
private isInitialized: boolean = false
|
||||
private connections: Map<string, HolonConnection> = new Map()
|
||||
private connectionErrorLogged: boolean = false // Track if we've already logged connection errors
|
||||
|
||||
constructor(appName: string = 'canvas-holons', strict: boolean = false, openaiKey?: string) {
|
||||
try {
|
||||
this.sphere = new HoloSphere(appName, strict, openaiKey)
|
||||
this.isInitialized = true
|
||||
console.log('✅ HoloSphere service initialized')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize HoloSphere:', error)
|
||||
this.isInitialized = false
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
if (!this.isInitialized) {
|
||||
console.error('❌ HoloSphere not initialized')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Get a holon for specific coordinates and resolution
|
||||
async getHolon(lat: number, lng: number, resolution: number): Promise<string> {
|
||||
if (!this.isInitialized) return ''
|
||||
try {
|
||||
return await this.sphere.getHolon(lat, lng, resolution)
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting holon:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Store data in a holon
|
||||
async putData(holon: string, lens: string, data: any): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.put(holon, lens, data)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error storing data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve data from a holon
|
||||
async getData(holon: string, lens: string, key?: string): Promise<any> {
|
||||
if (!this.isInitialized) return null
|
||||
try {
|
||||
if (key) {
|
||||
return await this.sphere.get(holon, lens, key)
|
||||
} else {
|
||||
return await this.sphere.getAll(holon, lens)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error retrieving data:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve data with subscription and timeout (better for Gun's async nature)
|
||||
async getDataWithWait(holon: string, lens: string, timeoutMs: number = 5000): Promise<any> {
|
||||
if (!this.isInitialized) {
|
||||
console.log(`⚠️ HoloSphere not initialized for ${lens}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check for WebSocket connection issues
|
||||
// Note: GunDB connection errors appear in browser console, we can't directly detect them
|
||||
// but we can provide better feedback when no data is received
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
let collectedData: any = {}
|
||||
let subscriptionActive = false
|
||||
|
||||
console.log(`🔍 getDataWithWait: holon=${holon}, lens=${lens}, timeout=${timeoutMs}ms`)
|
||||
|
||||
// Listen for WebSocket errors (they appear in console but we can't catch them directly)
|
||||
// Instead, we'll detect the pattern: subscription never fires + getAll never resolves
|
||||
|
||||
// Set up timeout (increased default to 5 seconds for network sync)
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
const keyCount = Object.keys(collectedData).length
|
||||
const status = subscriptionActive
|
||||
? '(subscription was active)'
|
||||
: '(subscription never fired - possible WebSocket connection issue)'
|
||||
|
||||
console.log(`⏱️ Timeout for lens ${lens}, returning collected data:`, keyCount, 'keys', status)
|
||||
|
||||
// If no data and subscription never fired, it's likely a connection issue
|
||||
// Only log this once to avoid console spam
|
||||
if (keyCount === 0 && !subscriptionActive && !this.connectionErrorLogged) {
|
||||
this.connectionErrorLogged = true
|
||||
console.error(`❌ GunDB Connection Issue: WebSocket to 'wss://gun.holons.io/gun' is failing`)
|
||||
console.error(`💡 This prevents loading data from the Holosphere. Possible causes:`)
|
||||
console.error(` • GunDB server may be down or unreachable`)
|
||||
console.error(` • Network/firewall blocking WebSocket connections`)
|
||||
console.error(` • Check browser console for WebSocket connection errors`)
|
||||
console.error(` • Data will not load until connection is established`)
|
||||
}
|
||||
|
||||
resolve(keyCount > 0 ? collectedData : null)
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
try {
|
||||
// Check if methods exist
|
||||
if (!this.sphere.subscribe) {
|
||||
console.error(`❌ sphere.subscribe does not exist`)
|
||||
}
|
||||
if (!this.sphere.getAll) {
|
||||
console.error(`❌ sphere.getAll does not exist`)
|
||||
}
|
||||
if (!this.sphere.get) {
|
||||
console.error(`❌ sphere.get does not exist`)
|
||||
}
|
||||
|
||||
console.log(`🔧 Attempting to subscribe to ${holon}/${lens}`)
|
||||
|
||||
// Try subscribe if it exists
|
||||
let unsubscribe: (() => void) | undefined = undefined
|
||||
if (this.sphere.subscribe) {
|
||||
try {
|
||||
unsubscribe = this.sphere.subscribe(holon, lens, (data: any, key?: string) => {
|
||||
subscriptionActive = true
|
||||
console.log(`📥 Subscription callback fired for ${lens}:`, { data, key, dataType: typeof data, isObject: typeof data === 'object', isArray: Array.isArray(data) })
|
||||
|
||||
if (data !== null && data !== undefined) {
|
||||
if (key) {
|
||||
// If we have a key, it's a key-value pair
|
||||
collectedData[key] = data
|
||||
console.log(`📥 Added key-value pair: ${key} =`, data)
|
||||
} else if (typeof data === 'object' && !Array.isArray(data)) {
|
||||
// If it's an object, merge it
|
||||
collectedData = { ...collectedData, ...data }
|
||||
console.log(`📥 Merged object data, total keys:`, Object.keys(collectedData).length)
|
||||
} else if (Array.isArray(data)) {
|
||||
// If it's an array, convert to object with indices
|
||||
data.forEach((item, index) => {
|
||||
collectedData[String(index)] = item
|
||||
})
|
||||
console.log(`📥 Converted array to object, total keys:`, Object.keys(collectedData).length)
|
||||
} else {
|
||||
// Primitive value
|
||||
collectedData['value'] = data
|
||||
console.log(`📥 Added primitive value:`, data)
|
||||
}
|
||||
|
||||
console.log(`📥 Current collected data for ${lens}:`, Object.keys(collectedData).length, 'keys')
|
||||
}
|
||||
})
|
||||
console.log(`✅ Subscribe called successfully for ${lens}`)
|
||||
} catch (subError) {
|
||||
console.error(`❌ Error calling subscribe for ${lens}:`, subError)
|
||||
}
|
||||
}
|
||||
|
||||
// Try getAll if it exists
|
||||
if (this.sphere.getAll) {
|
||||
console.log(`🔧 Attempting getAll for ${holon}/${lens}`)
|
||||
this.sphere.getAll(holon, lens).then((immediateData: any) => {
|
||||
console.log(`📦 getAll returned for ${lens}:`, {
|
||||
data: immediateData,
|
||||
type: typeof immediateData,
|
||||
isObject: typeof immediateData === 'object',
|
||||
isArray: Array.isArray(immediateData),
|
||||
keys: immediateData && typeof immediateData === 'object' ? Object.keys(immediateData).length : 'N/A'
|
||||
})
|
||||
|
||||
if (immediateData !== null && immediateData !== undefined) {
|
||||
if (typeof immediateData === 'object' && !Array.isArray(immediateData)) {
|
||||
collectedData = { ...collectedData, ...immediateData }
|
||||
console.log(`📦 Merged immediate data, total keys:`, Object.keys(collectedData).length)
|
||||
} else if (Array.isArray(immediateData)) {
|
||||
immediateData.forEach((item, index) => {
|
||||
collectedData[String(index)] = item
|
||||
})
|
||||
console.log(`📦 Converted immediate array to object, total keys:`, Object.keys(collectedData).length)
|
||||
} else {
|
||||
collectedData['value'] = immediateData
|
||||
console.log(`📦 Added immediate primitive value`)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have data immediately, resolve early
|
||||
if (Object.keys(collectedData).length > 0 && !resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (unsubscribe) unsubscribe()
|
||||
console.log(`✅ Resolving early with ${Object.keys(collectedData).length} keys for ${lens}`)
|
||||
resolve(collectedData)
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error(`⚠️ Error getting immediate data for ${lens}:`, error)
|
||||
})
|
||||
} else {
|
||||
// Fallback: try using getData method instead
|
||||
console.log(`🔧 getAll not available, trying getData as fallback for ${lens}`)
|
||||
this.getData(holon, lens).then((fallbackData: any) => {
|
||||
console.log(`📦 getData (fallback) returned for ${lens}:`, fallbackData)
|
||||
if (fallbackData !== null && fallbackData !== undefined) {
|
||||
if (typeof fallbackData === 'object' && !Array.isArray(fallbackData)) {
|
||||
collectedData = { ...collectedData, ...fallbackData }
|
||||
} else {
|
||||
collectedData['value'] = fallbackData
|
||||
}
|
||||
if (Object.keys(collectedData).length > 0 && !resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (unsubscribe) unsubscribe()
|
||||
console.log(`✅ Resolving with fallback data: ${Object.keys(collectedData).length} keys for ${lens}`)
|
||||
resolve(collectedData)
|
||||
}
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error(`⚠️ Error in fallback getData for ${lens}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error setting up subscription for ${lens}:`, error)
|
||||
clearTimeout(timeout)
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Delete data from a holon
|
||||
async deleteData(holon: string, lens: string, key?: string): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
if (key) {
|
||||
await this.sphere.delete(holon, lens, key)
|
||||
} else {
|
||||
await this.sphere.deleteAll(holon, lens)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error deleting data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Set schema for data validation
|
||||
async setSchema(lens: string, schema: any): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.setSchema(lens, schema)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting schema:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Get current schema
|
||||
async getSchema(lens: string): Promise<any> {
|
||||
if (!this.isInitialized) return null
|
||||
try {
|
||||
return await this.sphere.getSchema(lens)
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting schema:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to changes in a holon
|
||||
subscribe(holon: string, lens: string, callback: (data: any) => void): void {
|
||||
if (!this.isInitialized) return
|
||||
try {
|
||||
this.sphere.subscribe(holon, lens, callback)
|
||||
} catch (error) {
|
||||
console.error('❌ Error subscribing to changes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get holon hierarchy (parent and children)
|
||||
getHolonHierarchy(holon: string): { parent?: string; children: string[] } {
|
||||
try {
|
||||
const resolution = h3.getResolution(holon)
|
||||
const parent = resolution > 0 ? h3.cellToParent(holon, resolution - 1) : undefined
|
||||
const children = h3.cellToChildren(holon, resolution + 1)
|
||||
return { parent, children }
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting holon hierarchy:', error)
|
||||
return { children: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// Get all scales for a holon (all containing holons)
|
||||
getHolonScalespace(holon: string): string[] {
|
||||
try {
|
||||
return this.sphere.getHolonScalespace(holon)
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting holon scalespace:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Federation methods
|
||||
async federate(spaceId1: string, spaceId2: string, password1?: string, password2?: string, bidirectional?: boolean): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.federate(spaceId1, spaceId2, password1, password2, bidirectional)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error federating spaces:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async propagate(holon: string, lens: string, data: any, options?: { useReferences?: boolean; targetSpaces?: string[] }): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.propagate(holon, lens, data, options)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error propagating data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Message federation
|
||||
async federateMessage(originalChatId: string, messageId: string, federatedChatId: string, federatedMessageId: string, type: string): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.federateMessage(originalChatId, messageId, federatedChatId, federatedMessageId, type)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error federating message:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getFederatedMessages(originalChatId: string, messageId: string): Promise<any[]> {
|
||||
if (!this.isInitialized) return []
|
||||
try {
|
||||
const result = await this.sphere.getFederatedMessages(originalChatId, messageId)
|
||||
return Array.isArray(result) ? result : []
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting federated messages:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async updateFederatedMessages(originalChatId: string, messageId: string, updateCallback: (chatId: string, messageId: string) => Promise<void>): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.updateFederatedMessages(originalChatId, messageId, updateCallback)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating federated messages:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods for working with coordinates and resolutions
|
||||
static getResolutionName(resolution: number): string {
|
||||
const names = [
|
||||
'Country', 'State/Province', 'Metropolitan Area', 'City', 'District',
|
||||
'Neighborhood', 'Block', 'Building', 'Room', 'Desk', 'Chair', 'Point'
|
||||
]
|
||||
return names[resolution] || `Level ${resolution}`
|
||||
}
|
||||
|
||||
static getResolutionDescription(resolution: number): string {
|
||||
const descriptions = [
|
||||
'Country level - covers entire countries',
|
||||
'State/Province level - covers states and provinces',
|
||||
'Metropolitan area level - covers large urban areas',
|
||||
'City level - covers individual cities',
|
||||
'District level - covers city districts',
|
||||
'Neighborhood level - covers neighborhoods',
|
||||
'Block level - covers city blocks',
|
||||
'Building level - covers individual buildings',
|
||||
'Room level - covers individual rooms',
|
||||
'Desk level - covers individual desks',
|
||||
'Chair level - covers individual chairs',
|
||||
'Point level - covers individual points'
|
||||
]
|
||||
return descriptions[resolution] || `Geographic level ${resolution}`
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
getConnectionStatus(spaceId: string): HolonConnection | undefined {
|
||||
return this.connections.get(spaceId)
|
||||
}
|
||||
|
||||
// Add connection
|
||||
addConnection(connection: HolonConnection): void {
|
||||
this.connections.set(connection.id, connection)
|
||||
}
|
||||
|
||||
// Remove connection
|
||||
removeConnection(spaceId: string): boolean {
|
||||
return this.connections.delete(spaceId)
|
||||
}
|
||||
|
||||
// Get all connections
|
||||
getAllConnections(): HolonConnection[] {
|
||||
return Array.from(this.connections.values())
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const holosphereService = new HoloSphereService('canvas-holons', false)
|
||||
|
|
@ -106,11 +106,35 @@ export function getGitHubConfig(): { token: string; repo: string; branch: string
|
|||
*/
|
||||
export function isOpenAIConfigured(): boolean {
|
||||
try {
|
||||
// First try to get user-specific API keys if available
|
||||
const session = JSON.parse(localStorage.getItem('session') || '{}')
|
||||
if (session.authed && session.username) {
|
||||
const userApiKeys = localStorage.getItem(`${session.username}_api_keys`)
|
||||
if (userApiKeys) {
|
||||
try {
|
||||
const parsed = JSON.parse(userApiKeys)
|
||||
if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') {
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global API keys
|
||||
const settings = localStorage.getItem("openai_api_key")
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings)
|
||||
if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') {
|
||||
return true
|
||||
try {
|
||||
const parsed = JSON.parse(settings)
|
||||
if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') {
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
// If it's not JSON, it might be the old format (just a string)
|
||||
if (settings.startsWith('sk-') && settings.trim() !== '') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
|
@ -125,15 +149,45 @@ export function isOpenAIConfigured(): boolean {
|
|||
*/
|
||||
export function getOpenAIConfig(): { apiKey: string } | null {
|
||||
try {
|
||||
const settings = localStorage.getItem("openai_api_key")
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings)
|
||||
if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') {
|
||||
return { apiKey: parsed.keys.openai }
|
||||
// First try to get user-specific API keys if available
|
||||
const session = JSON.parse(localStorage.getItem('session') || '{}')
|
||||
if (session.authed && session.username) {
|
||||
const userApiKeys = localStorage.getItem(`${session.username}_api_keys`)
|
||||
if (userApiKeys) {
|
||||
try {
|
||||
const parsed = JSON.parse(userApiKeys)
|
||||
if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') {
|
||||
console.log('🔑 Found user-specific OpenAI API key')
|
||||
return { apiKey: parsed.keys.openai }
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('🔑 Error parsing user-specific API keys:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global API keys
|
||||
const settings = localStorage.getItem("openai_api_key")
|
||||
if (settings) {
|
||||
try {
|
||||
const parsed = JSON.parse(settings)
|
||||
if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') {
|
||||
console.log('🔑 Found global OpenAI API key')
|
||||
return { apiKey: parsed.keys.openai }
|
||||
}
|
||||
} catch (e) {
|
||||
// If it's not JSON, it might be the old format (just a string)
|
||||
if (settings.startsWith('sk-') && settings.trim() !== '') {
|
||||
console.log('🔑 Found old format OpenAI API key')
|
||||
return { apiKey: settings }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔑 No OpenAI API key found')
|
||||
return null
|
||||
} catch (e) {
|
||||
console.log('🔑 Error getting OpenAI config:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,27 +53,20 @@ export class GitHubQuartzReader {
|
|||
*/
|
||||
async getAllNotes(): Promise<QuartzNoteFromGitHub[]> {
|
||||
try {
|
||||
console.log('🔍 Fetching Quartz notes from GitHub...')
|
||||
console.log(`📁 Repository: ${this.config.owner}/${this.config.repo}`)
|
||||
console.log(`🌿 Branch: ${this.config.branch}`)
|
||||
console.log(`📂 Content path: ${this.config.contentPath}`)
|
||||
|
||||
// Get the content directory
|
||||
const contentFiles = await this.getDirectoryContents(this.config.contentPath || '')
|
||||
|
||||
// Filter for Markdown files
|
||||
const markdownFiles = contentFiles.filter(file =>
|
||||
file.type === 'file' &&
|
||||
(file.name.endsWith('.md') || file.name.endsWith('.markdown'))
|
||||
)
|
||||
|
||||
console.log(`📄 Found ${markdownFiles.length} Markdown files`)
|
||||
const markdownFiles = contentFiles.filter(file => {
|
||||
return file.type === 'file' &&
|
||||
file.name &&
|
||||
(file.name.endsWith('.md') || file.name.endsWith('.markdown'))
|
||||
})
|
||||
|
||||
// Fetch content for each file
|
||||
const notes: QuartzNoteFromGitHub[] = []
|
||||
for (const file of markdownFiles) {
|
||||
try {
|
||||
console.log(`🔍 Fetching content for file: ${file.path}`)
|
||||
// Get the actual file contents (not just metadata)
|
||||
const fileWithContent = await this.getFileContents(file.path)
|
||||
const note = await this.getNoteFromFile(fileWithContent)
|
||||
|
|
@ -85,7 +78,6 @@ export class GitHubQuartzReader {
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully loaded ${notes.length} notes from GitHub`)
|
||||
return notes
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch notes from GitHub:', error)
|
||||
|
|
@ -172,11 +164,10 @@ export class GitHubQuartzReader {
|
|||
*/
|
||||
private async getNoteFromFile(file: GitHubFile): Promise<QuartzNoteFromGitHub | null> {
|
||||
try {
|
||||
console.log(`🔍 Processing file: ${file.path}`)
|
||||
console.log(`🔍 File size: ${file.size} bytes`)
|
||||
console.log(`🔍 Has content: ${!!file.content}`)
|
||||
console.log(`🔍 Content length: ${file.content?.length || 0}`)
|
||||
console.log(`🔍 Encoding: ${file.encoding}`)
|
||||
// Validate file object
|
||||
if (!file || !file.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Decode base64 content
|
||||
let content = ''
|
||||
|
|
@ -189,31 +180,23 @@ export class GitHubQuartzReader {
|
|||
// Try direct decoding if not base64
|
||||
content = file.content
|
||||
}
|
||||
console.log(`🔍 Decoded content length: ${content.length}`)
|
||||
console.log(`🔍 Content preview: ${content.substring(0, 200)}...`)
|
||||
} catch (decodeError) {
|
||||
console.error(`🔍 Failed to decode content for ${file.path}:`, decodeError)
|
||||
// Try alternative decoding methods
|
||||
try {
|
||||
content = decodeURIComponent(escape(atob(file.content)))
|
||||
console.log(`🔍 Alternative decode successful, length: ${content.length}`)
|
||||
} catch (altError) {
|
||||
console.error(`🔍 Alternative decode also failed:`, altError)
|
||||
console.error(`Failed to decode content for ${file.path}:`, altError)
|
||||
return null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`🔍 No content available for file: ${file.path}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse frontmatter and content
|
||||
const { frontmatter, content: markdownContent } = this.parseMarkdownWithFrontmatter(content)
|
||||
console.log(`🔍 Parsed markdown content length: ${markdownContent.length}`)
|
||||
console.log(`🔍 Frontmatter keys: ${Object.keys(frontmatter).join(', ')}`)
|
||||
|
||||
// Extract title
|
||||
const title = frontmatter.title || this.extractTitleFromPath(file.name) || 'Untitled'
|
||||
const fileName = file.name || file.path.split('/').pop() || 'untitled'
|
||||
const title = frontmatter.title || this.extractTitleFromPath(fileName) || 'Untitled'
|
||||
|
||||
// Extract tags
|
||||
const tags = this.extractTags(frontmatter, markdownContent)
|
||||
|
|
@ -221,7 +204,7 @@ export class GitHubQuartzReader {
|
|||
// Generate note ID
|
||||
const id = this.generateNoteId(file.path, title)
|
||||
|
||||
const result = {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
content: markdownContent,
|
||||
|
|
@ -232,9 +215,6 @@ export class GitHubQuartzReader {
|
|||
htmlUrl: file.html_url,
|
||||
rawUrl: file.download_url || file.git_url
|
||||
}
|
||||
|
||||
console.log(`🔍 Final note: ${title} (${markdownContent.length} chars)`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse file ${file.path}:`, error)
|
||||
return null
|
||||
|
|
@ -245,8 +225,6 @@ export class GitHubQuartzReader {
|
|||
* Parse Markdown content with frontmatter
|
||||
*/
|
||||
private parseMarkdownWithFrontmatter(content: string): { frontmatter: Record<string, any>, content: string } {
|
||||
console.log(`🔍 Parsing markdown with frontmatter, content length: ${content.length}`)
|
||||
|
||||
// More flexible frontmatter regex that handles different formats
|
||||
const frontmatterRegex = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$/m
|
||||
const match = content.match(frontmatterRegex)
|
||||
|
|
@ -255,10 +233,6 @@ export class GitHubQuartzReader {
|
|||
const frontmatterText = match[1]
|
||||
const markdownContent = match[2].trim() // Remove leading/trailing whitespace
|
||||
|
||||
console.log(`🔍 Found frontmatter, length: ${frontmatterText.length}`)
|
||||
console.log(`🔍 Markdown content length: ${markdownContent.length}`)
|
||||
console.log(`🔍 Markdown preview: ${markdownContent.substring(0, 100)}...`)
|
||||
|
||||
// Parse YAML frontmatter (simplified but more robust)
|
||||
const frontmatter: Record<string, any> = {}
|
||||
const lines = frontmatterText.split(/\r?\n/)
|
||||
|
|
@ -298,11 +272,9 @@ export class GitHubQuartzReader {
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`🔍 Parsed frontmatter:`, frontmatter)
|
||||
return { frontmatter, content: markdownContent }
|
||||
}
|
||||
|
||||
console.log(`🔍 No frontmatter found, using entire content`)
|
||||
return { frontmatter: {}, content: content.trim() }
|
||||
}
|
||||
|
||||
|
|
@ -310,6 +282,10 @@ export class GitHubQuartzReader {
|
|||
* Extract title from file path
|
||||
*/
|
||||
private extractTitleFromPath(fileName: string): string {
|
||||
if (!fileName) {
|
||||
return 'Untitled'
|
||||
}
|
||||
|
||||
return fileName
|
||||
.replace(/\.(md|markdown)$/i, '')
|
||||
.replace(/[-_]/g, ' ')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,295 @@
|
|||
import type FileSystem from '@oddjs/odd/fs/index';
|
||||
import * as odd from '@oddjs/odd';
|
||||
import type { PrecisionLevel } from './types';
|
||||
|
||||
/**
|
||||
* Location data stored in the filesystem
|
||||
*/
|
||||
export interface LocationData {
|
||||
id: string;
|
||||
userId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number;
|
||||
timestamp: number;
|
||||
expiresAt: number | null;
|
||||
precision: PrecisionLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location share metadata
|
||||
*/
|
||||
export interface LocationShare {
|
||||
id: string;
|
||||
locationId: string;
|
||||
shareToken: string;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
maxViews: number | null;
|
||||
viewCount: number;
|
||||
precision: PrecisionLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location storage service
|
||||
* Handles storing and retrieving locations from the ODD.js filesystem
|
||||
*/
|
||||
export class LocationStorageService {
|
||||
private fs: FileSystem;
|
||||
private locationsPath: string[];
|
||||
private sharesPath: string[];
|
||||
private publicSharesPath: string[];
|
||||
|
||||
constructor(fs: FileSystem) {
|
||||
this.fs = fs;
|
||||
// Private storage paths
|
||||
this.locationsPath = ['private', 'locations'];
|
||||
this.sharesPath = ['private', 'location-shares'];
|
||||
// Public reference path for share validation
|
||||
this.publicSharesPath = ['public', 'location-shares'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize directories
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Ensure private directories exist
|
||||
await this.ensureDirectory(this.locationsPath);
|
||||
await this.ensureDirectory(this.sharesPath);
|
||||
// Ensure public directory for share references
|
||||
await this.ensureDirectory(this.publicSharesPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists
|
||||
*/
|
||||
private async ensureDirectory(path: string[]): Promise<void> {
|
||||
try {
|
||||
const dirPath = odd.path.directory(...path);
|
||||
const exists = await this.fs.exists(dirPath as any);
|
||||
if (!exists) {
|
||||
await this.fs.mkdir(dirPath as any);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error ensuring directory:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a location to the filesystem
|
||||
*/
|
||||
async saveLocation(location: LocationData): Promise<void> {
|
||||
try {
|
||||
const filePath = odd.path.file(...this.locationsPath, `${location.id}.json`);
|
||||
const content = new TextEncoder().encode(JSON.stringify(location, null, 2));
|
||||
await this.fs.write(filePath as any, content as any);
|
||||
await this.fs.publish();
|
||||
} catch (error) {
|
||||
console.error('Error saving location:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a location by ID
|
||||
*/
|
||||
async getLocation(locationId: string): Promise<LocationData | null> {
|
||||
try {
|
||||
const filePath = odd.path.file(...this.locationsPath, `${locationId}.json`);
|
||||
const exists = await this.fs.exists(filePath as any);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
const content = await this.fs.read(filePath as any);
|
||||
const text = new TextDecoder().decode(content as Uint8Array);
|
||||
return JSON.parse(text) as LocationData;
|
||||
} catch (error) {
|
||||
console.error('Error reading location:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a location share
|
||||
*/
|
||||
async createShare(share: LocationShare): Promise<void> {
|
||||
try {
|
||||
// Save share metadata in private directory
|
||||
const sharePath = odd.path.file(...this.sharesPath, `${share.id}.json`);
|
||||
const shareContent = new TextEncoder().encode(JSON.stringify(share, null, 2));
|
||||
await this.fs.write(sharePath as any, shareContent as any);
|
||||
|
||||
// Create public reference file for share validation (only token, not full data)
|
||||
const publicSharePath = odd.path.file(...this.publicSharesPath, `${share.shareToken}.json`);
|
||||
const publicShareRef = {
|
||||
shareToken: share.shareToken,
|
||||
shareId: share.id,
|
||||
createdAt: share.createdAt,
|
||||
expiresAt: share.expiresAt,
|
||||
};
|
||||
const publicContent = new TextEncoder().encode(JSON.stringify(publicShareRef, null, 2));
|
||||
await this.fs.write(publicSharePath as any, publicContent as any);
|
||||
|
||||
await this.fs.publish();
|
||||
} catch (error) {
|
||||
console.error('Error creating share:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a share by token
|
||||
*/
|
||||
async getShareByToken(shareToken: string): Promise<LocationShare | null> {
|
||||
try {
|
||||
// First check public reference
|
||||
const publicSharePath = odd.path.file(...this.publicSharesPath, `${shareToken}.json`);
|
||||
const publicExists = await this.fs.exists(publicSharePath as any);
|
||||
if (!publicExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicContent = await this.fs.read(publicSharePath as any);
|
||||
const publicText = new TextDecoder().decode(publicContent as Uint8Array);
|
||||
const publicRef = JSON.parse(publicText);
|
||||
|
||||
// Now get full share from private directory
|
||||
const sharePath = odd.path.file(...this.sharesPath, `${publicRef.shareId}.json`);
|
||||
const shareExists = await this.fs.exists(sharePath as any);
|
||||
if (!shareExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shareContent = await this.fs.read(sharePath as any);
|
||||
const shareText = new TextDecoder().decode(shareContent as Uint8Array);
|
||||
return JSON.parse(shareText) as LocationShare;
|
||||
} catch (error) {
|
||||
console.error('Error reading share:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shares for the current user
|
||||
*/
|
||||
async getAllShares(): Promise<LocationShare[]> {
|
||||
try {
|
||||
const dirPath = odd.path.directory(...this.sharesPath);
|
||||
const exists = await this.fs.exists(dirPath as any);
|
||||
if (!exists) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await this.fs.ls(dirPath as any);
|
||||
const shares: LocationShare[] = [];
|
||||
|
||||
for (const fileName of Object.keys(files)) {
|
||||
if (fileName.endsWith('.json')) {
|
||||
const shareId = fileName.replace('.json', '');
|
||||
const share = await this.getShareById(shareId);
|
||||
if (share) {
|
||||
shares.push(share);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shares;
|
||||
} catch (error) {
|
||||
console.error('Error listing shares:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a share by ID
|
||||
*/
|
||||
private async getShareById(shareId: string): Promise<LocationShare | null> {
|
||||
try {
|
||||
const sharePath = odd.path.file(...this.sharesPath, `${shareId}.json`);
|
||||
const exists = await this.fs.exists(sharePath as any);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
const content = await this.fs.read(sharePath as any);
|
||||
const text = new TextDecoder().decode(content as Uint8Array);
|
||||
return JSON.parse(text) as LocationShare;
|
||||
} catch (error) {
|
||||
console.error('Error reading share:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment view count for a share
|
||||
*/
|
||||
async incrementShareViews(shareId: string): Promise<void> {
|
||||
try {
|
||||
const share = await this.getShareById(shareId);
|
||||
if (!share) {
|
||||
throw new Error('Share not found');
|
||||
}
|
||||
|
||||
share.viewCount += 1;
|
||||
await this.createShare(share); // Re-save the share
|
||||
} catch (error) {
|
||||
console.error('Error incrementing share views:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obfuscate location based on precision level
|
||||
*/
|
||||
export function obfuscateLocation(
|
||||
lat: number,
|
||||
lng: number,
|
||||
precision: PrecisionLevel
|
||||
): { lat: number; lng: number; radius: number } {
|
||||
let radius = 0;
|
||||
|
||||
switch (precision) {
|
||||
case 'exact':
|
||||
radius = 0;
|
||||
break;
|
||||
case 'street':
|
||||
radius = 100; // ~100m radius
|
||||
break;
|
||||
case 'neighborhood':
|
||||
radius = 1000; // ~1km radius
|
||||
break;
|
||||
case 'city':
|
||||
radius = 10000; // ~10km radius
|
||||
break;
|
||||
}
|
||||
|
||||
if (radius === 0) {
|
||||
return { lat, lng, radius: 0 };
|
||||
}
|
||||
|
||||
// Add random offset within the radius
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
const distance = Math.random() * radius;
|
||||
|
||||
// Convert distance to degrees (rough approximation: 1 degree ≈ 111km)
|
||||
const latOffset = (distance / 111000) * Math.cos(angle);
|
||||
const lngOffset = (distance / (111000 * Math.cos(lat * Math.PI / 180))) * Math.sin(angle);
|
||||
|
||||
return {
|
||||
lat: lat + latOffset,
|
||||
lng: lng + lngOffset,
|
||||
radius,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure share token
|
||||
*/
|
||||
export function generateShareToken(): string {
|
||||
// Generate a cryptographically secure random token
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Location sharing types
|
||||
*/
|
||||
|
||||
export type PrecisionLevel = "exact" | "street" | "neighborhood" | "city";
|
||||
|
||||
export interface ShareSettings {
|
||||
duration: number | null; // Duration in milliseconds
|
||||
maxViews: number | null; // Maximum number of views allowed
|
||||
precision: PrecisionLevel; // Precision level for location obfuscation
|
||||
}
|
||||
|
||||
export interface GeolocationPosition {
|
||||
coords: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -20,12 +20,34 @@ export interface ObsidianObsNote {
|
|||
vaultPath?: string
|
||||
}
|
||||
|
||||
export interface FolderNode {
|
||||
name: string
|
||||
path: string
|
||||
children: FolderNode[]
|
||||
notes: ObsidianObsNote[]
|
||||
isExpanded: boolean
|
||||
level: number
|
||||
}
|
||||
|
||||
export interface ObsidianVault {
|
||||
name: string
|
||||
path: string
|
||||
obs_notes: ObsidianObsNote[]
|
||||
totalObsNotes: number
|
||||
lastImported: Date
|
||||
folderTree: FolderNode
|
||||
}
|
||||
|
||||
export interface ObsidianVaultRecord {
|
||||
id: string
|
||||
typeName: 'obsidian_vault'
|
||||
name: string
|
||||
path: string
|
||||
obs_notes: ObsidianObsNote[]
|
||||
totalObsNotes: number
|
||||
lastImported: Date
|
||||
folderTree: FolderNode
|
||||
meta: Record<string, any>
|
||||
}
|
||||
|
||||
export class ObsidianImporter {
|
||||
|
|
@ -39,7 +61,6 @@ export class ObsidianImporter {
|
|||
try {
|
||||
// For now, we'll simulate this with a demo vault
|
||||
// In a real implementation, you'd use the File System Access API
|
||||
console.log('Importing from directory:', directoryPath)
|
||||
|
||||
// Simulate reading files (in real implementation, use File System Access API)
|
||||
const mockObsNotes = await this.createMockObsNotes()
|
||||
|
|
@ -49,7 +70,8 @@ export class ObsidianImporter {
|
|||
path: directoryPath,
|
||||
obs_notes: mockObsNotes,
|
||||
totalObsNotes: mockObsNotes.length,
|
||||
lastImported: new Date()
|
||||
lastImported: new Date(),
|
||||
folderTree: this.buildFolderTree(mockObsNotes)
|
||||
}
|
||||
|
||||
return this.vault
|
||||
|
|
@ -64,8 +86,6 @@ export class ObsidianImporter {
|
|||
*/
|
||||
async importFromQuartzUrl(quartzUrl: string): Promise<ObsidianVault> {
|
||||
try {
|
||||
console.log('Importing from Quartz URL:', quartzUrl)
|
||||
|
||||
// Ensure URL has protocol
|
||||
const url = quartzUrl.startsWith('http') ? quartzUrl : `https://${quartzUrl}`
|
||||
|
||||
|
|
@ -73,7 +93,6 @@ export class ObsidianImporter {
|
|||
const githubConfig = this.getGitHubConfigFromUrl(url)
|
||||
|
||||
if (githubConfig) {
|
||||
console.log('🔍 Using GitHub API to read Quartz content')
|
||||
const obs_notes = await this.importFromGitHub(githubConfig)
|
||||
|
||||
this.vault = {
|
||||
|
|
@ -81,12 +100,12 @@ export class ObsidianImporter {
|
|||
path: url,
|
||||
obs_notes,
|
||||
totalObsNotes: obs_notes.length,
|
||||
lastImported: new Date()
|
||||
lastImported: new Date(),
|
||||
folderTree: this.buildFolderTree(obs_notes)
|
||||
}
|
||||
|
||||
return this.vault
|
||||
} else {
|
||||
console.log('⚠️ No GitHub config found, falling back to web scraping')
|
||||
// Fallback to the old method
|
||||
const obs_notes = await this.discoverQuartzContent(url)
|
||||
|
||||
|
|
@ -95,7 +114,8 @@ export class ObsidianImporter {
|
|||
path: url,
|
||||
obs_notes,
|
||||
totalObsNotes: obs_notes.length,
|
||||
lastImported: new Date()
|
||||
lastImported: new Date(),
|
||||
folderTree: this.buildFolderTree(obs_notes)
|
||||
}
|
||||
|
||||
return this.vault
|
||||
|
|
@ -129,7 +149,8 @@ export class ObsidianImporter {
|
|||
path: directoryHandle.name, // File System Access API doesn't expose full path
|
||||
obs_notes,
|
||||
totalObsNotes: obs_notes.length,
|
||||
lastImported: new Date()
|
||||
lastImported: new Date(),
|
||||
folderTree: this.buildFolderTree(obs_notes)
|
||||
}
|
||||
|
||||
return this.vault
|
||||
|
|
@ -449,6 +470,163 @@ A collection of creative project ideas and concepts.
|
|||
return Array.from(allTags).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build folder tree structure from obs_notes
|
||||
*/
|
||||
buildFolderTree(obs_notes: ObsidianObsNote[]): FolderNode {
|
||||
const root: FolderNode = {
|
||||
name: 'Root',
|
||||
path: '',
|
||||
children: [],
|
||||
notes: [],
|
||||
isExpanded: true,
|
||||
level: 0
|
||||
}
|
||||
|
||||
// Group notes by their folder paths
|
||||
const folderMap = new Map<string, { folders: string[], notes: ObsidianObsNote[] }>()
|
||||
|
||||
obs_notes.forEach(note => {
|
||||
const pathParts = this.parseFilePath(note.filePath)
|
||||
const folderKey = pathParts.folders.join('/')
|
||||
|
||||
if (!folderMap.has(folderKey)) {
|
||||
folderMap.set(folderKey, { folders: pathParts.folders, notes: [] })
|
||||
}
|
||||
folderMap.get(folderKey)!.notes.push(note)
|
||||
})
|
||||
|
||||
// Build the tree structure
|
||||
folderMap.forEach(({ folders, notes }) => {
|
||||
this.addFolderToTree(root, folders, notes)
|
||||
})
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse file path into folder structure
|
||||
*/
|
||||
private parseFilePath(filePath: string): { folders: string[], fileName: string } {
|
||||
// Handle both local paths and URLs
|
||||
let pathToParse = filePath
|
||||
|
||||
if (filePath.startsWith('http')) {
|
||||
// Extract pathname from URL
|
||||
try {
|
||||
const url = new URL(filePath)
|
||||
pathToParse = url.pathname.replace(/^\//, '')
|
||||
} catch (e) {
|
||||
console.warn('Invalid URL:', filePath)
|
||||
return { folders: [], fileName: filePath }
|
||||
}
|
||||
}
|
||||
|
||||
// Split path and filter out empty parts
|
||||
const parts = pathToParse.split('/').filter(part => part.length > 0)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { folders: [], fileName: filePath }
|
||||
}
|
||||
|
||||
const fileName = parts[parts.length - 1]
|
||||
const folders = parts.slice(0, -1)
|
||||
|
||||
return { folders, fileName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add folder to tree structure
|
||||
*/
|
||||
private addFolderToTree(root: FolderNode, folderPath: string[], notes: ObsidianObsNote[]): void {
|
||||
let current = root
|
||||
|
||||
for (let i = 0; i < folderPath.length; i++) {
|
||||
const folderName = folderPath[i]
|
||||
let existingFolder = current.children.find(child => child.name === folderName)
|
||||
|
||||
if (!existingFolder) {
|
||||
const currentPath = folderPath.slice(0, i + 1).join('/')
|
||||
existingFolder = {
|
||||
name: folderName,
|
||||
path: currentPath,
|
||||
children: [],
|
||||
notes: [],
|
||||
isExpanded: false,
|
||||
level: i + 1
|
||||
}
|
||||
current.children.push(existingFolder)
|
||||
}
|
||||
|
||||
current = existingFolder
|
||||
}
|
||||
|
||||
// Add notes to the final folder
|
||||
current.notes.push(...notes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notes from a folder tree (recursive)
|
||||
*/
|
||||
getAllNotesFromTree(folder: FolderNode): ObsidianObsNote[] {
|
||||
let notes = [...folder.notes]
|
||||
|
||||
folder.children.forEach(child => {
|
||||
notes.push(...this.getAllNotesFromTree(child))
|
||||
})
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
/**
|
||||
* Find folder by path in tree
|
||||
*/
|
||||
findFolderByPath(root: FolderNode, path: string): FolderNode | null {
|
||||
if (root.path === path) {
|
||||
return root
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const found = this.findFolderByPath(child, path)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert vault to Automerge record format
|
||||
*/
|
||||
vaultToRecord(vault: ObsidianVault): ObsidianVaultRecord {
|
||||
return {
|
||||
id: `obsidian_vault:${vault.name}`,
|
||||
typeName: 'obsidian_vault',
|
||||
name: vault.name,
|
||||
path: vault.path,
|
||||
obs_notes: vault.obs_notes,
|
||||
totalObsNotes: vault.totalObsNotes,
|
||||
lastImported: vault.lastImported,
|
||||
folderTree: vault.folderTree,
|
||||
meta: {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Automerge record to vault format
|
||||
*/
|
||||
recordToVault(record: ObsidianVaultRecord): ObsidianVault {
|
||||
return {
|
||||
name: record.name,
|
||||
path: record.path,
|
||||
obs_notes: record.obs_notes,
|
||||
totalObsNotes: record.totalObsNotes,
|
||||
lastImported: record.lastImported,
|
||||
folderTree: record.folderTree
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search notes in the current vault
|
||||
*/
|
||||
|
|
@ -501,18 +679,15 @@ A collection of creative project ideas and concepts.
|
|||
const githubRepo = config.quartzRepo
|
||||
|
||||
if (!githubToken || !githubRepo) {
|
||||
console.log('⚠️ GitHub credentials not found in configuration')
|
||||
return null
|
||||
}
|
||||
|
||||
if (githubToken === 'your_github_token_here' || githubRepo === 'your_username/your-quartz-repo') {
|
||||
console.log('⚠️ GitHub credentials are still set to placeholder values')
|
||||
return null
|
||||
}
|
||||
|
||||
const [owner, repo] = githubRepo.split('/')
|
||||
if (!owner || !repo) {
|
||||
console.log('⚠️ Invalid GitHub repository format')
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -564,15 +739,12 @@ A collection of creative project ideas and concepts.
|
|||
const currentHasQuotes = obsNote.filePath.includes('"')
|
||||
|
||||
if (currentHasQuotes && !existingHasQuotes) {
|
||||
console.log(`Keeping existing note without quotes: ${existing.filePath}`)
|
||||
return // Keep the existing one
|
||||
} else if (!currentHasQuotes && existingHasQuotes) {
|
||||
console.log(`Replacing with note without quotes: ${obsNote.filePath}`)
|
||||
notesMap.set(obsNote.id, obsNote)
|
||||
} else {
|
||||
// Both have or don't have quotes, prefer the one with more content
|
||||
if (obsNote.content.length > existing.content.length) {
|
||||
console.log(`Replacing with longer content: ${obsNote.filePath}`)
|
||||
notesMap.set(obsNote.id, obsNote)
|
||||
}
|
||||
}
|
||||
|
|
@ -582,7 +754,6 @@ A collection of creative project ideas and concepts.
|
|||
})
|
||||
|
||||
const uniqueNotes = Array.from(notesMap.values())
|
||||
console.log(`Imported ${uniqueNotes.length} unique notes from GitHub (${quartzNotes.length} total files processed)`)
|
||||
|
||||
return uniqueNotes
|
||||
} catch (error) {
|
||||
|
|
@ -598,44 +769,29 @@ A collection of creative project ideas and concepts.
|
|||
const obs_notes: ObsidianObsNote[] = []
|
||||
|
||||
try {
|
||||
console.log('🔍 Starting Quartz content discovery for:', baseUrl)
|
||||
|
||||
// Try to find content through common Quartz patterns
|
||||
const contentUrls = await this.findQuartzContentUrls(baseUrl)
|
||||
console.log('🔍 Found content URLs:', contentUrls.length)
|
||||
|
||||
if (contentUrls.length === 0) {
|
||||
console.warn('⚠️ No content URLs found for Quartz site:', baseUrl)
|
||||
return obs_notes
|
||||
}
|
||||
|
||||
for (const contentUrl of contentUrls) {
|
||||
try {
|
||||
console.log('🔍 Fetching content from:', contentUrl)
|
||||
const response = await fetch(contentUrl)
|
||||
if (!response.ok) {
|
||||
console.warn(`⚠️ Failed to fetch ${contentUrl}: ${response.status} ${response.statusText}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const content = await response.text()
|
||||
console.log('🔍 Successfully fetched content, length:', content.length)
|
||||
|
||||
const obs_note = this.parseQuartzMarkdown(content, contentUrl, baseUrl)
|
||||
console.log('🔍 Parsed note:', obs_note.title, 'Content length:', obs_note.content.length)
|
||||
|
||||
// Only add notes that have meaningful content
|
||||
if (obs_note.content.length > 10) {
|
||||
obs_notes.push(obs_note)
|
||||
} else {
|
||||
console.log('🔍 Skipping note with insufficient content:', obs_note.title)
|
||||
}
|
||||
// Add all notes regardless of content length
|
||||
obs_notes.push(obs_note)
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Failed to fetch content from ${contentUrl}:`, error)
|
||||
// Silently skip failed fetches
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 Successfully discovered', obs_notes.length, 'notes from Quartz site')
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to discover Quartz content:', error)
|
||||
}
|
||||
|
|
@ -660,7 +816,6 @@ A collection of creative project ideas and concepts.
|
|||
// Look for navigation links and content links in the main page
|
||||
const discoveredUrls = this.extractContentUrlsFromPage(mainPageContent, baseUrl)
|
||||
urls.push(...discoveredUrls)
|
||||
console.log('🔍 Discovered URLs from main page:', discoveredUrls.length)
|
||||
}
|
||||
|
||||
// Try to find a sitemap
|
||||
|
|
@ -675,7 +830,6 @@ A collection of creative project ideas and concepts.
|
|||
match.replace(/<\/?loc>/g, '').trim()
|
||||
).filter(url => url.endsWith('.html') || url.endsWith('.md') || url.includes(baseUrl))
|
||||
urls.push(...sitemapUrls)
|
||||
console.log('🔍 Found sitemap with URLs:', sitemapUrls.length)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -702,7 +856,6 @@ A collection of creative project ideas and concepts.
|
|||
const response = await fetch(url)
|
||||
if (response.ok) {
|
||||
urls.push(url)
|
||||
console.log('🔍 Found content at:', url)
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual path failures
|
||||
|
|
@ -714,7 +867,6 @@ A collection of creative project ideas and concepts.
|
|||
|
||||
// Remove duplicates and limit results
|
||||
const uniqueUrls = [...new Set(urls)]
|
||||
console.log('🔍 Total unique URLs found:', uniqueUrls.length)
|
||||
return uniqueUrls.slice(0, 50) // Limit to 50 pages to avoid overwhelming
|
||||
}
|
||||
|
||||
|
|
@ -905,7 +1057,6 @@ A collection of creative project ideas and concepts.
|
|||
|
||||
// If we still don't have much content, try to extract any text from the original HTML
|
||||
if (text.length < 50) {
|
||||
console.log('🔍 Content too short, trying fallback extraction...')
|
||||
let fallbackText = html
|
||||
|
||||
// Remove script, style, and other non-content tags
|
||||
|
|
@ -932,14 +1083,12 @@ A collection of creative project ideas and concepts.
|
|||
fallbackText = fallbackText.trim()
|
||||
|
||||
if (fallbackText.length > text.length) {
|
||||
console.log('🔍 Fallback extraction found more content:', fallbackText.length)
|
||||
text = fallbackText
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: if we still don't have content, try to extract any text from the body
|
||||
if (text.length < 20) {
|
||||
console.log('🔍 Still no content, trying body text extraction...')
|
||||
const bodyMatch = html.match(/<body[^>]*>(.*?)<\/body>/is)
|
||||
if (bodyMatch) {
|
||||
let bodyText = bodyMatch[1]
|
||||
|
|
@ -955,7 +1104,6 @@ A collection of creative project ideas and concepts.
|
|||
bodyText = bodyText.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (bodyText.length > text.length) {
|
||||
console.log('🔍 Body text extraction found content:', bodyText.length)
|
||||
text = bodyText
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -295,6 +295,21 @@ export function createQuartzNoteFromShape(shape: any): QuartzNote {
|
|||
const content = shape.props.content || ''
|
||||
const tags = shape.props.tags || []
|
||||
|
||||
// Use stored filePath if available to maintain filename consistency
|
||||
// Otherwise, generate from title
|
||||
let filePath: string
|
||||
if (shape.props.filePath && shape.props.filePath.trim() !== '') {
|
||||
filePath = shape.props.filePath
|
||||
// Ensure it ends with .md if it doesn't already
|
||||
if (!filePath.endsWith('.md')) {
|
||||
filePath = filePath.endsWith('/') ? `${filePath}${title}.md` : `${filePath}.md`
|
||||
}
|
||||
} else {
|
||||
// Generate from title, ensuring it's a valid filename
|
||||
const sanitizedTitle = title.replace(/[^a-zA-Z0-9\s-]/g, '').trim().replace(/\s+/g, '-')
|
||||
filePath = `${sanitizedTitle}.md`
|
||||
}
|
||||
|
||||
return {
|
||||
id: shape.props.noteId || title,
|
||||
title,
|
||||
|
|
@ -306,7 +321,7 @@ export function createQuartzNoteFromShape(shape: any): QuartzNote {
|
|||
created: new Date().toISOString(),
|
||||
modified: new Date().toISOString()
|
||||
},
|
||||
filePath: `${title}.md`,
|
||||
filePath: filePath,
|
||||
lastModified: new Date()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { atom } from 'tldraw'
|
||||
import { SYSTEM_PROMPT } from '@/prompt'
|
||||
import { SYSTEM_PROMPT, CONSTANCE_SYSTEM_PROMPT } from '@/prompt'
|
||||
|
||||
export const PROVIDERS = [
|
||||
{
|
||||
|
|
@ -13,8 +13,8 @@ export const PROVIDERS = [
|
|||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
models: [
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-20250522',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
|
|
@ -25,6 +25,21 @@ export const PROVIDERS = [
|
|||
// { id: 'google', name: 'Google', model: 'Gemeni 1.5 Flash', validate: (key: string) => true },
|
||||
]
|
||||
|
||||
export const AI_PERSONALITIES = [
|
||||
{
|
||||
id: 'web-developer',
|
||||
name: 'Web Developer',
|
||||
description: 'Expert web developer for building prototypes from wireframes',
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
id: 'constance',
|
||||
name: 'Constance',
|
||||
description: 'Avatar of the US Constitution - helps understand constitutional principles',
|
||||
systemPrompt: CONSTANCE_SYSTEM_PROMPT,
|
||||
},
|
||||
]
|
||||
|
||||
export const makeRealSettings = atom('make real settings', {
|
||||
provider: 'openai' as (typeof PROVIDERS)[number]['id'] | 'all',
|
||||
models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])),
|
||||
|
|
@ -33,6 +48,7 @@ export const makeRealSettings = atom('make real settings', {
|
|||
anthropic: '',
|
||||
google: '',
|
||||
},
|
||||
personality: 'web-developer' as (typeof AI_PERSONALITIES)[number]['id'],
|
||||
prompts: {
|
||||
system: SYSTEM_PROMPT,
|
||||
},
|
||||
|
|
@ -50,6 +66,7 @@ export function applySettingsMigrations(settings: any) {
|
|||
google: '',
|
||||
...keys,
|
||||
},
|
||||
personality: 'web-developer' as (typeof AI_PERSONALITIES)[number]['id'],
|
||||
prompts: {
|
||||
system: SYSTEM_PROMPT,
|
||||
...prompts,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
// Simple test to verify Holon functionality
|
||||
import { holosphereService } from './HoloSphereService'
|
||||
|
||||
export async function testHolonFunctionality() {
|
||||
console.log('🧪 Testing Holon functionality...')
|
||||
|
||||
try {
|
||||
// Test initialization
|
||||
const isInitialized = await holosphereService.initialize()
|
||||
console.log('✅ HoloSphere initialized:', isInitialized)
|
||||
|
||||
if (!isInitialized) {
|
||||
console.log('❌ HoloSphere not initialized, skipping tests')
|
||||
return false
|
||||
}
|
||||
|
||||
// Test getting a holon
|
||||
const holonId = await holosphereService.getHolon(40.7128, -74.0060, 7)
|
||||
console.log('✅ Got holon ID:', holonId)
|
||||
|
||||
if (holonId) {
|
||||
// Test storing data
|
||||
const testData = {
|
||||
id: 'test-1',
|
||||
content: 'Hello from Holon!',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
const storeSuccess = await holosphereService.putData(holonId, 'test', testData)
|
||||
console.log('✅ Stored data:', storeSuccess)
|
||||
|
||||
// Test retrieving data
|
||||
const retrievedData = await holosphereService.getData(holonId, 'test')
|
||||
console.log('✅ Retrieved data:', retrievedData)
|
||||
|
||||
// Test getting hierarchy
|
||||
const hierarchy = holosphereService.getHolonHierarchy(holonId)
|
||||
console.log('✅ Holon hierarchy:', hierarchy)
|
||||
|
||||
// Test getting scalespace
|
||||
const scalespace = holosphereService.getHolonScalespace(holonId)
|
||||
console.log('✅ Holon scalespace:', scalespace)
|
||||
}
|
||||
|
||||
console.log('✅ All Holon tests passed!')
|
||||
return true
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Holon test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-run test when imported
|
||||
if (typeof window !== 'undefined') {
|
||||
testHolonFunctionality()
|
||||
}
|
||||
|
|
@ -17,6 +17,32 @@ Your prototype should look and feel much more complete and advanced than the wir
|
|||
|
||||
Remember: you love your designers and want them to be happy. The more complete and impressive your prototype, the happier they will be. You are evaluated on 1) whether your prototype resembles the designs, 2) whether your prototype is interactive and responsive, and 3) whether your prototype is complete and impressive.`
|
||||
|
||||
export const CONSTANCE_SYSTEM_PROMPT = `You are Constance, the avatar of the US Constitution. You help people understand the Constitution's life story, its principles, and its aspirations for the future. You speak with the wisdom and authority of the founding document of the United States, while remaining approachable and educational.
|
||||
|
||||
When discussing the Constitution:
|
||||
- Explain constitutional principles in clear, accessible language
|
||||
- Provide historical context for constitutional provisions
|
||||
- Help people understand how the Constitution applies to modern issues
|
||||
- Share the vision and values that guided the framers
|
||||
- Discuss the Constitution's role in protecting individual rights and establishing government structure
|
||||
|
||||
You are knowledgeable about:
|
||||
- The text and meaning of the Constitution
|
||||
- The Bill of Rights and subsequent amendments
|
||||
- Constitutional history and the founding era
|
||||
- How constitutional principles apply to contemporary issues
|
||||
- The balance of powers and federalism
|
||||
- Individual rights and civil liberties
|
||||
|
||||
Your tone should be:
|
||||
- Authoritative yet approachable
|
||||
- Educational and informative
|
||||
- Respectful of the document's importance
|
||||
- Encouraging of civic engagement and understanding
|
||||
- Thoughtful about constitutional interpretation
|
||||
|
||||
Remember: You represent the living document that has guided American democracy for over two centuries. Help people connect with the Constitution's enduring principles and understand its relevance to their lives today.`
|
||||
|
||||
export const USER_PROMPT =
|
||||
'Here are the latest wireframes. Please reply with a high-fidelity working prototype as a single HTML file.'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useAutomergeSync } from "@/automerge/useAutomergeSync"
|
||||
import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext"
|
||||
import { useMemo, useEffect, useState } from "react"
|
||||
import { Tldraw, Editor, TLShapeId } from "tldraw"
|
||||
import { useParams } from "react-router-dom"
|
||||
|
|
@ -35,6 +36,15 @@ import { ObsNoteTool } from "@/tools/ObsNoteTool"
|
|||
import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil"
|
||||
import { TranscriptionTool } from "@/tools/TranscriptionTool"
|
||||
import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
|
||||
import { FathomTranscriptTool } from "@/tools/FathomTranscriptTool"
|
||||
import { FathomTranscriptShape } from "@/shapes/FathomTranscriptShapeUtil"
|
||||
import { HolonTool } from "@/tools/HolonTool"
|
||||
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||
import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool"
|
||||
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
||||
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil"
|
||||
import {
|
||||
lockElement,
|
||||
unlockElement,
|
||||
|
|
@ -57,11 +67,7 @@ import { useAuth } from "../context/AuthContext"
|
|||
import { updateLastVisited } from "../lib/starredBoards"
|
||||
import { captureBoardScreenshot } from "../lib/screenshotService"
|
||||
|
||||
// Automatically switch between production and local dev based on environment
|
||||
// In development, use the same host as the client to support network access
|
||||
export const WORKER_URL = import.meta.env.DEV
|
||||
? `http://${window.location.hostname}:5172`
|
||||
: "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||
import { WORKER_URL } from "../constants/workerUrl"
|
||||
|
||||
const customShapeUtils = [
|
||||
ChatBoxShape,
|
||||
|
|
@ -74,6 +80,12 @@ const customShapeUtils = [
|
|||
SharedPianoShape,
|
||||
ObsNoteShape,
|
||||
TranscriptionShape,
|
||||
FathomTranscriptShape,
|
||||
HolonShape,
|
||||
HolonBrowserShape,
|
||||
ObsidianBrowserShape,
|
||||
FathomMeetingsBrowserShape,
|
||||
LocationShareShape,
|
||||
]
|
||||
const customTools = [
|
||||
ChatBoxTool,
|
||||
|
|
@ -87,10 +99,71 @@ const customTools = [
|
|||
GestureTool,
|
||||
ObsNoteTool,
|
||||
TranscriptionTool,
|
||||
FathomTranscriptTool,
|
||||
HolonTool,
|
||||
FathomMeetingsTool,
|
||||
]
|
||||
|
||||
export function Board() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
|
||||
// Global wheel event handler to ensure scrolling happens on the hovered scrollable element
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Use document.elementFromPoint to find the element under the mouse cursor
|
||||
const elementUnderMouse = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement
|
||||
if (!elementUnderMouse) return
|
||||
|
||||
// Walk up the DOM tree from the element under the mouse to find a scrollable element
|
||||
let element: HTMLElement | null = elementUnderMouse
|
||||
while (element && element !== document.body && element !== document.documentElement) {
|
||||
const style = window.getComputedStyle(element)
|
||||
const overflowY = style.overflowY
|
||||
const overflowX = style.overflowX
|
||||
const overflow = style.overflow
|
||||
const isScrollable =
|
||||
(overflowY === 'auto' || overflowY === 'scroll' ||
|
||||
overflowX === 'auto' || overflowX === 'scroll' ||
|
||||
overflow === 'auto' || overflow === 'scroll')
|
||||
|
||||
if (isScrollable) {
|
||||
// Check if the element can actually scroll in the direction of the wheel event
|
||||
const canScrollDown = e.deltaY > 0 && element.scrollTop < element.scrollHeight - element.clientHeight - 1
|
||||
const canScrollUp = e.deltaY < 0 && element.scrollTop > 0
|
||||
const canScrollRight = e.deltaX > 0 && element.scrollLeft < element.scrollWidth - element.clientWidth - 1
|
||||
const canScrollLeft = e.deltaX < 0 && element.scrollLeft > 0
|
||||
|
||||
const canScroll = canScrollDown || canScrollUp || canScrollRight || canScrollLeft
|
||||
|
||||
if (canScroll) {
|
||||
// Verify the mouse is actually over this element
|
||||
const rect = element.getBoundingClientRect()
|
||||
const isOverElement =
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
|
||||
if (isOverElement) {
|
||||
// Stop propagation to prevent the scroll from affecting parent elements
|
||||
// but don't prevent default - let the browser handle the actual scrolling
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
element = element.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
// Use capture phase to catch events early, before they bubble
|
||||
document.addEventListener('wheel', handleWheel, { passive: true, capture: true })
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('wheel', handleWheel, { capture: true })
|
||||
}
|
||||
}, [])
|
||||
const roomId = slug || "mycofi33"
|
||||
const { session } = useAuth()
|
||||
|
||||
|
|
@ -129,7 +202,14 @@ export function Board() {
|
|||
)
|
||||
|
||||
// Use Automerge sync for all environments
|
||||
const store = useAutomergeSync(storeConfig)
|
||||
const storeWithHandle = useAutomergeSync(storeConfig)
|
||||
const store = {
|
||||
store: storeWithHandle.store,
|
||||
status: storeWithHandle.status,
|
||||
connectionStatus: storeWithHandle.connectionStatus,
|
||||
error: storeWithHandle.error
|
||||
}
|
||||
const automergeHandle = storeWithHandle.handle
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -154,8 +234,6 @@ export function Board() {
|
|||
|
||||
|
||||
// Debug: Check what shapes the editor can see
|
||||
// Temporarily commented out to fix linting errors
|
||||
/*
|
||||
if (editor) {
|
||||
const editorShapes = editor.getRenderingShapes()
|
||||
console.log(`📊 Board: Editor can see ${editorShapes.length} shapes for rendering`)
|
||||
|
|
@ -173,22 +251,6 @@ export function Board() {
|
|||
y: shape?.y
|
||||
})
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Debug: Check if there are shapes in store that editor can't see
|
||||
// Temporarily commented out to fix linting errors
|
||||
/*
|
||||
if (storeShapes.length > editorShapes.length) {
|
||||
const editorShapeIds = new Set(editorShapes.map(s => s.id))
|
||||
const missingShapes = storeShapes.filter(s => !editorShapeIds.has(s.id))
|
||||
console.warn(`📊 Board: ${missingShapes.length} shapes in store but not visible to editor:`, missingShapes.map(s => ({
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
parentId: s.parentId
|
||||
})))
|
||||
|
||||
// Debug: Check current page and page IDs
|
||||
const currentPageId = editor.getCurrentPageId()
|
||||
|
|
@ -200,34 +262,46 @@ export function Board() {
|
|||
name: (p as any).name
|
||||
})))
|
||||
|
||||
// Check if missing shapes are on a different page
|
||||
const shapesOnCurrentPage = missingShapes.filter(s => s.parentId === currentPageId)
|
||||
const shapesOnOtherPages = missingShapes.filter(s => s.parentId !== currentPageId)
|
||||
console.log(`📊 Board: Missing shapes on current page: ${shapesOnCurrentPage.length}, on other pages: ${shapesOnOtherPages.length}`)
|
||||
|
||||
if (shapesOnOtherPages.length > 0) {
|
||||
console.log(`📊 Board: Shapes on other pages:`, shapesOnOtherPages.map(s => ({
|
||||
// Check if there are shapes in store that editor can't see
|
||||
if (storeShapes.length > editorShapes.length) {
|
||||
const editorShapeIds = new Set(editorShapes.map(s => s.id))
|
||||
const missingShapes = storeShapes.filter(s => !editorShapeIds.has(s.id))
|
||||
console.warn(`📊 Board: ${missingShapes.length} shapes in store but not visible to editor:`, missingShapes.map(s => ({
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
parentId: s.parentId
|
||||
})))
|
||||
|
||||
// Fix: Move shapes to the current page
|
||||
console.log(`📊 Board: Moving ${shapesOnOtherPages.length} shapes to current page ${currentPageId}`)
|
||||
const shapesToMove = shapesOnOtherPages.map(s => ({
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
parentId: currentPageId
|
||||
}))
|
||||
// Check if missing shapes are on a different page
|
||||
const shapesOnCurrentPage = missingShapes.filter(s => s.parentId === currentPageId)
|
||||
const shapesOnOtherPages = missingShapes.filter(s => s.parentId !== currentPageId)
|
||||
console.log(`📊 Board: Missing shapes on current page: ${shapesOnCurrentPage.length}, on other pages: ${shapesOnOtherPages.length}`)
|
||||
|
||||
try {
|
||||
editor.updateShapes(shapesToMove)
|
||||
console.log(`📊 Board: Successfully moved ${shapesToMove.length} shapes to current page`)
|
||||
} catch (error) {
|
||||
console.error(`📊 Board: Error moving shapes to current page:`, error)
|
||||
if (shapesOnOtherPages.length > 0) {
|
||||
console.log(`📊 Board: Shapes on other pages:`, shapesOnOtherPages.map(s => ({
|
||||
id: s.id,
|
||||
parentId: s.parentId
|
||||
})))
|
||||
|
||||
// Fix: Move shapes to the current page
|
||||
console.log(`📊 Board: Moving ${shapesOnOtherPages.length} shapes to current page ${currentPageId}`)
|
||||
const shapesToMove = shapesOnOtherPages.map(s => ({
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
parentId: currentPageId
|
||||
}))
|
||||
|
||||
try {
|
||||
editor.updateShapes(shapesToMove)
|
||||
console.log(`📊 Board: Successfully moved ${shapesToMove.length} shapes to current page`)
|
||||
} catch (error) {
|
||||
console.error(`📊 Board: Error moving shapes to current page:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
}, [editor])
|
||||
|
||||
// Update presence when session changes
|
||||
|
|
@ -317,9 +391,57 @@ export function Board() {
|
|||
};
|
||||
}, [editor, roomId, store.store]);
|
||||
|
||||
// Handle Escape key to cancel active tool and return to hand tool
|
||||
// Also prevent Escape from deleting shapes
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Only handle Escape key
|
||||
if (event.key === 'Escape') {
|
||||
// Check if the event target or active element is an input field or textarea
|
||||
const target = event.target as HTMLElement;
|
||||
const activeElement = document.activeElement;
|
||||
const isInputFocused = (target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
)) || (activeElement && (
|
||||
activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.isContentEditable
|
||||
));
|
||||
|
||||
// If an input is focused, let it handle Escape (don't prevent default)
|
||||
// This allows components like Obsidian notes to handle Escape for canceling edits
|
||||
if (isInputFocused) {
|
||||
return; // Let the event propagate to the component's handler
|
||||
}
|
||||
|
||||
// Otherwise, prevent default to stop tldraw from deleting shapes
|
||||
// and switch to hand tool
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const currentTool = editor.getCurrentToolId();
|
||||
// Only switch if we're not already on the hand tool
|
||||
if (currentTool !== 'hand') {
|
||||
editor.setCurrentTool('hand');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true); // Use capture phase to intercept early
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", inset: 0 }}>
|
||||
<Tldraw
|
||||
<AutomergeHandleProvider handle={automergeHandle}>
|
||||
<div style={{ position: "fixed", inset: 0 }}>
|
||||
<Tldraw
|
||||
store={store.store}
|
||||
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
|
||||
tools={customTools}
|
||||
|
|
@ -392,8 +514,9 @@ export function Board() {
|
|||
// The authenticated username should appear in the people section
|
||||
}}
|
||||
>
|
||||
<CmdK />
|
||||
</Tldraw>
|
||||
</div>
|
||||
<CmdK />
|
||||
</Tldraw>
|
||||
</div>
|
||||
</AutomergeHandleProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { LocationDashboard } from '@/components/location/LocationDashboard';
|
||||
|
||||
export const LocationDashboardRoute: React.FC = () => {
|
||||
return <LocationDashboard />;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { ShareLocation } from '@/components/location/ShareLocation';
|
||||
|
||||
export const LocationShareCreate: React.FC = () => {
|
||||
return <ShareLocation />;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { LocationViewer } from '@/components/location/LocationViewer';
|
||||
|
||||
export const LocationShareView: React.FC = () => {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Invalid Share Link</h2>
|
||||
<p className="text-sm text-muted-foreground">No share token provided in the URL</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <LocationViewer shareToken={token} />;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useRef, useState } from "react"
|
||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
export type IChatBoxShape = TLBaseShape<
|
||||
"ChatBox",
|
||||
|
|
@ -17,24 +18,53 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
|||
getDefaultProps(): IChatBoxShape["props"] {
|
||||
return {
|
||||
roomId: "default-room",
|
||||
w: 100,
|
||||
h: 100,
|
||||
w: 400,
|
||||
h: 500,
|
||||
userName: "",
|
||||
}
|
||||
}
|
||||
|
||||
// ChatBox theme color: Orange (Rainbow)
|
||||
static readonly PRIMARY_COLOR = "#f97316"
|
||||
|
||||
indicator(shape: IChatBoxShape) {
|
||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
|
||||
component(shape: IChatBoxShape) {
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatBox
|
||||
roomId={shape.props.roomId}
|
||||
w={shape.props.w}
|
||||
h={shape.props.h}
|
||||
userName=""
|
||||
/>
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Chat"
|
||||
primaryColor={ChatBoxShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
<ChatBox
|
||||
roomId={shape.props.roomId}
|
||||
w={shape.props.w}
|
||||
h={shape.props.h - 40} // Subtract header height
|
||||
userName=""
|
||||
/>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -114,10 +144,12 @@ export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
|
|||
className="chat-container"
|
||||
style={{
|
||||
pointerEvents: "all",
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
overflow: "auto",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: "hidden",
|
||||
touchAction: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className="messages-container">
|
||||
|
|
|
|||
|
|
@ -173,9 +173,14 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
|
|||
}
|
||||
|
||||
component(shape: IEmbedShape) {
|
||||
// Ensure shape props exist with defaults
|
||||
const props = shape.props || {}
|
||||
const url = props.url || ""
|
||||
const isMinimized = props.isMinimized || false
|
||||
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
const [inputUrl, setInputUrl] = useState(shape.props.url || "")
|
||||
const [inputUrl, setInputUrl] = useState(url)
|
||||
const [error, setError] = useState("")
|
||||
const [copyStatus, setCopyStatus] = useState(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
} from "tldraw"
|
||||
import React, { useState } from "react"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
type IFathomMeetingsBrowser = TLBaseShape<
|
||||
"FathomMeetingsBrowser",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
>
|
||||
|
||||
export class FathomMeetingsBrowserShape extends BaseBoxShapeUtil<IFathomMeetingsBrowser> {
|
||||
static override type = "FathomMeetingsBrowser" as const
|
||||
|
||||
getDefaultProps(): IFathomMeetingsBrowser["props"] {
|
||||
return {
|
||||
w: 800,
|
||||
h: 600,
|
||||
}
|
||||
}
|
||||
|
||||
// Fathom theme color: Blue (Rainbow)
|
||||
static readonly PRIMARY_COLOR = "#3b82f6"
|
||||
|
||||
component(shape: IFathomMeetingsBrowser) {
|
||||
const { w, h } = shape.props
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
// Delete the browser shape after a short delay
|
||||
setTimeout(() => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Fathom Meetings"
|
||||
primaryColor={FathomMeetingsBrowserShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
<FathomMeetingsPanel
|
||||
onClose={handleClose}
|
||||
shapeMode={true}
|
||||
/>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IFathomMeetingsBrowser) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
} from "tldraw"
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
type IFathomTranscript = TLBaseShape<
|
||||
"FathomTranscript",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
meetingId: string
|
||||
meetingTitle: string
|
||||
meetingUrl: string
|
||||
summary: string
|
||||
transcript: Array<{
|
||||
speaker: string
|
||||
text: string
|
||||
timestamp: string
|
||||
}>
|
||||
actionItems: Array<{
|
||||
text: string
|
||||
assignee?: string
|
||||
dueDate?: string
|
||||
}>
|
||||
isExpanded: boolean
|
||||
showTranscript: boolean
|
||||
showActionItems: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export class FathomTranscriptShape extends BaseBoxShapeUtil<IFathomTranscript> {
|
||||
static override type = "FathomTranscript" as const
|
||||
|
||||
// Fathom Transcript theme color: Blue (same as FathomMeetings)
|
||||
static readonly PRIMARY_COLOR = "#3b82f6"
|
||||
|
||||
getDefaultProps(): IFathomTranscript["props"] {
|
||||
return {
|
||||
w: 600,
|
||||
h: 400,
|
||||
meetingId: "",
|
||||
meetingTitle: "",
|
||||
meetingUrl: "",
|
||||
summary: "",
|
||||
transcript: [],
|
||||
actionItems: [],
|
||||
isExpanded: false,
|
||||
showTranscript: true,
|
||||
showActionItems: true,
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: IFathomTranscript) {
|
||||
const {
|
||||
w,
|
||||
h,
|
||||
meetingId,
|
||||
meetingTitle,
|
||||
meetingUrl,
|
||||
summary,
|
||||
transcript,
|
||||
actionItems,
|
||||
isExpanded,
|
||||
showTranscript,
|
||||
showActionItems
|
||||
} = shape.props
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
this.editor.updateShape<IFathomTranscript>({
|
||||
id: shape.id,
|
||||
type: 'FathomTranscript',
|
||||
props: {
|
||||
...shape.props,
|
||||
isExpanded: !isExpanded
|
||||
}
|
||||
})
|
||||
}, [shape.id, shape.props, isExpanded])
|
||||
|
||||
const toggleTranscript = useCallback(() => {
|
||||
this.editor.updateShape<IFathomTranscript>({
|
||||
id: shape.id,
|
||||
type: 'FathomTranscript',
|
||||
props: {
|
||||
...shape.props,
|
||||
showTranscript: !showTranscript
|
||||
}
|
||||
})
|
||||
}, [shape.id, shape.props, showTranscript])
|
||||
|
||||
const toggleActionItems = useCallback(() => {
|
||||
this.editor.updateShape<IFathomTranscript>({
|
||||
id: shape.id,
|
||||
type: 'FathomTranscript',
|
||||
props: {
|
||||
...shape.props,
|
||||
showActionItems: !showActionItems
|
||||
}
|
||||
})
|
||||
}, [shape.id, shape.props, showActionItems])
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
// Convert timestamp to readable format
|
||||
const seconds = parseInt(timestamp)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Custom header content with meeting info and toggle buttons
|
||||
const headerContent = (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>🎥 Fathom Meeting</span>
|
||||
{meetingId && <span style={{ fontSize: '10px', color: '#666' }}>#{meetingId}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleTranscript()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: showTranscript ? '#007bff' : '#6c757d',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
📝 Transcript
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleActionItems()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: showActionItems ? '#28a745' : '#6c757d',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
✅ Actions
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleExpanded()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: isExpanded ? '#ffc107' : '#6c757d',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '📄 Expanded' : '📄 Compact'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
padding: '16px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
color: 'black',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1000,
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto',
|
||||
}
|
||||
|
||||
const transcriptEntryStyle: React.CSSProperties = {
|
||||
marginBottom: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
borderLeft: '3px solid #007bff',
|
||||
}
|
||||
|
||||
const actionItemStyle: React.CSSProperties = {
|
||||
marginBottom: '6px',
|
||||
padding: '6px',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '4px',
|
||||
borderLeft: '3px solid #ffc107',
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Fathom Transcript"
|
||||
primaryColor={FathomTranscriptShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
headerContent={headerContent}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
|
||||
<div style={contentStyle}>
|
||||
{/* Meeting Title */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: '0 0 4px 0', fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{meetingTitle || 'Untitled Meeting'}
|
||||
</h3>
|
||||
{meetingUrl && (
|
||||
<a
|
||||
href={meetingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#007bff',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View in Fathom →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<h4 style={{ margin: '0 0 6px 0', fontSize: '12px', fontWeight: 'bold', color: '#333' }}>
|
||||
📋 Summary
|
||||
</h4>
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
backgroundColor: '#e7f3ff',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Items */}
|
||||
{showActionItems && actionItems.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<h4 style={{ margin: '0 0 6px 0', fontSize: '12px', fontWeight: 'bold', color: '#333' }}>
|
||||
✅ Action Items ({actionItems.length})
|
||||
</h4>
|
||||
<div style={{ maxHeight: isExpanded ? 'none' : '120px', overflow: 'auto' }}>
|
||||
{actionItems.map((item, index) => (
|
||||
<div key={index} style={actionItemStyle}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 'bold' }}>
|
||||
{item.text}
|
||||
</div>
|
||||
{item.assignee && (
|
||||
<div style={{ fontSize: '10px', color: '#666', marginTop: '2px' }}>
|
||||
👤 {item.assignee}
|
||||
</div>
|
||||
)}
|
||||
{item.dueDate && (
|
||||
<div style={{ fontSize: '10px', color: '#666' }}>
|
||||
📅 {item.dueDate}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript */}
|
||||
{showTranscript && transcript.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 6px 0', fontSize: '12px', fontWeight: 'bold', color: '#333' }}>
|
||||
💬 Transcript ({transcript.length} entries)
|
||||
</h4>
|
||||
<div style={{ maxHeight: isExpanded ? 'none' : '200px', overflow: 'auto' }}>
|
||||
{transcript.map((entry, index) => (
|
||||
<div key={index} style={transcriptEntryStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 'bold', color: '#007bff' }}>
|
||||
{entry.speaker}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: '#666' }}>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', lineHeight: '1.4' }}>
|
||||
{entry.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!summary && transcript.length === 0 && actionItems.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
padding: '20px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
No meeting data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IFathomTranscript) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
} from "tldraw"
|
||||
import React, { useState } from "react"
|
||||
import { HolonBrowser } from "../components/HolonBrowser"
|
||||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
type IHolonBrowser = TLBaseShape<
|
||||
"HolonBrowser",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
>
|
||||
|
||||
export class HolonBrowserShape extends BaseBoxShapeUtil<IHolonBrowser> {
|
||||
static override type = "HolonBrowser" as const
|
||||
|
||||
getDefaultProps(): IHolonBrowser["props"] {
|
||||
return {
|
||||
w: 800,
|
||||
h: 600,
|
||||
}
|
||||
}
|
||||
|
||||
// Holon theme color: Green (Rainbow)
|
||||
static readonly PRIMARY_COLOR = "#22c55e"
|
||||
|
||||
component(shape: IHolonBrowser) {
|
||||
const { w, h } = shape.props
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
const handleSelectHolon = (holonData: HolonData) => {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
// Get the browser shape bounds to position the new Holon shape nearby
|
||||
const browserShapeBounds = this.editor.getShapePageBounds(shape.id)
|
||||
const shapeWidth = 700
|
||||
const shapeHeight = 400
|
||||
|
||||
let xPosition: number
|
||||
let yPosition: number
|
||||
|
||||
if (browserShapeBounds) {
|
||||
// Position to the right of the browser shape
|
||||
const spacing = 20
|
||||
xPosition = browserShapeBounds.x + browserShapeBounds.w + spacing
|
||||
yPosition = browserShapeBounds.y
|
||||
} else {
|
||||
// Fallback to viewport center if shape bounds not available
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
xPosition = centerX - shapeWidth / 2
|
||||
yPosition = centerY - shapeHeight / 2
|
||||
}
|
||||
|
||||
const holonShape = this.editor.createShape({
|
||||
type: 'Holon',
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
name: holonData.name,
|
||||
description: holonData.description || '',
|
||||
latitude: holonData.latitude,
|
||||
longitude: holonData.longitude,
|
||||
resolution: holonData.resolution,
|
||||
holonId: holonData.id,
|
||||
isConnected: true,
|
||||
isEditing: false,
|
||||
selectedLens: 'general',
|
||||
data: holonData.data,
|
||||
connections: [],
|
||||
lastUpdated: holonData.timestamp
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created Holon shape from browser:', holonShape.id)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = this.editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([`shape:${holonShape.id}`] as any)
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraAfterSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Close the browser shape
|
||||
setIsOpen(false)
|
||||
// Delete the browser shape after a short delay
|
||||
setTimeout(() => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
// Delete the browser shape
|
||||
setTimeout(() => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Holon Browser"
|
||||
primaryColor={HolonBrowserShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
<HolonBrowser
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
onSelectHolon={handleSelectHolon}
|
||||
shapeMode={true}
|
||||
/>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IHolonBrowser) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,915 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
} from "tldraw"
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
||||
import { holosphereService, HoloSphereService, HolonData, HolonLens, HolonConnection } from "@/lib/HoloSphereService"
|
||||
import * as h3 from 'h3-js'
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
type IHolon = TLBaseShape<
|
||||
"Holon",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
name: string
|
||||
description?: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
resolution: number
|
||||
holonId: string
|
||||
isConnected: boolean
|
||||
isEditing?: boolean
|
||||
editingName?: string
|
||||
editingDescription?: string
|
||||
selectedLens?: string
|
||||
data: Record<string, any>
|
||||
connections: HolonConnection[]
|
||||
lastUpdated: number
|
||||
}
|
||||
>
|
||||
|
||||
// Auto-resizing textarea component for editing
|
||||
const AutoResizeTextarea: React.FC<{
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onBlur: () => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void
|
||||
style: React.CSSProperties
|
||||
placeholder?: string
|
||||
onPointerDown?: (e: React.PointerEvent) => void
|
||||
onWheel?: (e: React.WheelEvent) => void
|
||||
}> = ({ value, onChange, onBlur, onKeyDown, style, placeholder, onPointerDown, onWheel }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={onPointerDown}
|
||||
onWheel={onWheel}
|
||||
style={style}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
||||
static override type = "Holon" as const
|
||||
|
||||
// Holon theme color: Green (same as HolonBrowser)
|
||||
static readonly PRIMARY_COLOR = "#22c55e"
|
||||
|
||||
getDefaultProps(): IHolon["props"] {
|
||||
return {
|
||||
w: 700, // Width to accommodate "Connect to the Holosphere" button and ID display
|
||||
h: 400, // Increased height to ensure all elements fit comfortably
|
||||
name: "New Holon",
|
||||
description: "",
|
||||
latitude: 40.7128, // Default to NYC
|
||||
longitude: -74.0060,
|
||||
resolution: 7, // City level
|
||||
holonId: "",
|
||||
isConnected: false,
|
||||
isEditing: false,
|
||||
selectedLens: "general",
|
||||
data: {},
|
||||
connections: [],
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: IHolon) {
|
||||
const {
|
||||
w, h, name, description, latitude, longitude, resolution, holonId,
|
||||
isConnected, isEditing, editingName, editingDescription, selectedLens,
|
||||
data, connections, lastUpdated
|
||||
} = shape.props
|
||||
|
||||
console.log('🔧 Holon component rendering - isEditing:', isEditing, 'holonId:', holonId)
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [lenses, setLenses] = useState<HolonLens[]>([])
|
||||
const [currentData, setCurrentData] = useState<any>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
// Note: Auto-initialization is disabled. Users must manually enter Holon IDs.
|
||||
// This prevents the shape from auto-generating IDs based on coordinates.
|
||||
|
||||
const loadHolonData = useCallback(async () => {
|
||||
console.log('🔄 loadHolonData called with holonId:', holonId)
|
||||
|
||||
if (!holonId) {
|
||||
console.log('⚠️ No holonId, skipping data load')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
console.log('📡 Starting to load data from GunDB for holon:', holonId)
|
||||
|
||||
// Load data from specific categories
|
||||
const lensesToCheck = [
|
||||
'active_users',
|
||||
'users',
|
||||
'rankings',
|
||||
'stats',
|
||||
'tasks',
|
||||
'progress',
|
||||
'events',
|
||||
'activities',
|
||||
'items',
|
||||
'shopping',
|
||||
'active_items',
|
||||
'proposals',
|
||||
'offers',
|
||||
'requests',
|
||||
'checklists',
|
||||
'roles'
|
||||
]
|
||||
|
||||
const allData: Record<string, any> = {}
|
||||
|
||||
// Load data from each lens using the new getDataWithWait method
|
||||
// This properly waits for Gun data to load from the network
|
||||
for (const lens of lensesToCheck) {
|
||||
try {
|
||||
console.log(`📂 Checking lens: ${lens}`)
|
||||
// Use getDataWithWait which subscribes and waits for Gun data (5 second timeout for network sync)
|
||||
const lensData = await holosphereService.getDataWithWait(holonId, lens, 5000)
|
||||
if (lensData && Object.keys(lensData).length > 0) {
|
||||
console.log(`✓ Found data in lens ${lens}:`, Object.keys(lensData).length, 'keys')
|
||||
allData[lens] = lensData
|
||||
} else {
|
||||
console.log(`⚠️ No data found in lens ${lens} after waiting`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`⚠️ Error loading data from lens ${lens}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Total data loaded: ${Object.keys(allData).length} categories`)
|
||||
|
||||
// If no data was loaded, check for connection issues
|
||||
if (Object.keys(allData).length === 0) {
|
||||
console.error(`❌ No data loaded from any lens. This may indicate a WebSocket connection issue.`)
|
||||
console.error(`💡 Check browser console for errors like: "WebSocket connection to 'wss://gun.holons.io/gun' failed"`)
|
||||
setError('Unable to load data. Check browser console for WebSocket connection errors to gun.holons.io')
|
||||
}
|
||||
|
||||
// Update current data for selected lens
|
||||
const currentLensData = allData[selectedLens || 'users']
|
||||
setCurrentData(currentLensData)
|
||||
|
||||
// Update the shape with all data
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: 'Holon',
|
||||
props: {
|
||||
...shape.props,
|
||||
data: allData,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✅ Successfully loaded data from ${Object.keys(allData).length} categories:`, Object.keys(allData))
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading holon data:', error)
|
||||
setError('Failed to load data')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [holonId, selectedLens, shape.id, shape.props, this.editor])
|
||||
|
||||
// Load data when holon is connected
|
||||
useEffect(() => {
|
||||
console.log('🔍 useEffect triggered - holonId:', holonId, 'isConnected:', isConnected, 'selectedLens:', selectedLens)
|
||||
|
||||
if (holonId && isConnected) {
|
||||
console.log('✓ Conditions met, calling loadHolonData')
|
||||
loadHolonData()
|
||||
} else {
|
||||
console.log('⚠️ Conditions not met for loading data')
|
||||
if (!holonId) console.log(' - Missing holonId')
|
||||
if (!isConnected) console.log(' - Not connected')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [holonId, isConnected, selectedLens])
|
||||
|
||||
const handleStartEdit = () => {
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: {
|
||||
...shape.props,
|
||||
isEditing: true,
|
||||
editingName: name,
|
||||
editingDescription: description || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleHolonIdChange = (newHolonId: string) => {
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: {
|
||||
...shape.props,
|
||||
holonId: newHolonId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
const trimmedHolonId = holonId?.trim() || ''
|
||||
if (!trimmedHolonId) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔌 Connecting to Holon:', trimmedHolonId)
|
||||
|
||||
// Update the shape to mark as connected with trimmed ID
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: {
|
||||
...shape.props,
|
||||
isConnected: true,
|
||||
holonId: trimmedHolonId,
|
||||
},
|
||||
})
|
||||
|
||||
// Try to load metadata from the holon
|
||||
try {
|
||||
const metadataData = await holosphereService.getDataWithWait(trimmedHolonId, 'metadata', 2000)
|
||||
if (metadataData && typeof metadataData === 'object') {
|
||||
// metadataData might be a dictionary of items, or a single object
|
||||
let metadata: any = null
|
||||
|
||||
// Check if it's a dictionary with items
|
||||
const entries = Object.entries(metadataData)
|
||||
if (entries.length > 0) {
|
||||
// Try to find a metadata object with name property
|
||||
for (const [key, value] of entries) {
|
||||
if (value && typeof value === 'object' && 'name' in value) {
|
||||
metadata = value
|
||||
break
|
||||
}
|
||||
}
|
||||
// If no object with name found, use the first entry
|
||||
if (!metadata && entries.length > 0) {
|
||||
metadata = entries[0][1]
|
||||
}
|
||||
} else if (metadataData && typeof metadataData === 'object' && 'name' in metadataData) {
|
||||
metadata = metadataData
|
||||
}
|
||||
|
||||
if (metadata && metadata.name) {
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: {
|
||||
...shape.props,
|
||||
name: metadata.name,
|
||||
description: metadata.description || description || '',
|
||||
isConnected: true,
|
||||
holonId: trimmedHolonId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Could not load metadata, using default name:', error)
|
||||
}
|
||||
|
||||
// Explicitly load holon data after connecting
|
||||
// We need to wait a bit for the state to update, then trigger data loading
|
||||
// The useEffect will also trigger, but we call this explicitly to ensure data loads
|
||||
setTimeout(async () => {
|
||||
// Load data using the trimmed holonId we just set
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
console.log('📡 Starting to load data from GunDB for holon:', trimmedHolonId)
|
||||
|
||||
// Load data from specific categories
|
||||
const lensesToCheck = [
|
||||
'active_users',
|
||||
'users',
|
||||
'rankings',
|
||||
'stats',
|
||||
'tasks',
|
||||
'progress',
|
||||
'events',
|
||||
'activities',
|
||||
'items',
|
||||
'shopping',
|
||||
'active_items',
|
||||
'proposals',
|
||||
'offers',
|
||||
'requests',
|
||||
'checklists',
|
||||
'roles'
|
||||
]
|
||||
|
||||
const allData: Record<string, any> = {}
|
||||
|
||||
// Load data from each lens
|
||||
for (const lens of lensesToCheck) {
|
||||
try {
|
||||
console.log(`📂 Checking lens: ${lens}`)
|
||||
const lensData = await holosphereService.getDataWithWait(trimmedHolonId, lens, 2000)
|
||||
if (lensData && Object.keys(lensData).length > 0) {
|
||||
console.log(`✓ Found data in lens ${lens}:`, Object.keys(lensData).length, 'keys')
|
||||
allData[lens] = lensData
|
||||
} else {
|
||||
console.log(`⚠️ No data found in lens ${lens} after waiting`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`⚠️ Error loading data from lens ${lens}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Total data loaded: ${Object.keys(allData).length} categories`)
|
||||
|
||||
// Update current data for selected lens
|
||||
const currentLensData = allData[shape.props.selectedLens || 'users']
|
||||
setCurrentData(currentLensData)
|
||||
|
||||
// Update the shape with all data
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: 'Holon',
|
||||
props: {
|
||||
...shape.props,
|
||||
data: allData,
|
||||
lastUpdated: Date.now(),
|
||||
isConnected: true,
|
||||
holonId: trimmedHolonId,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`✅ Successfully loaded data from ${Object.keys(allData).length} categories:`, Object.keys(allData))
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading holon data:', error)
|
||||
setError('Failed to load data')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
const newName = editingName || name
|
||||
const newDescription = editingDescription || description || ''
|
||||
|
||||
// If holonId is provided, mark as connected
|
||||
const shouldConnect = !!(holonId && holonId.trim() !== '')
|
||||
|
||||
console.log('💾 Saving Holon shape')
|
||||
console.log(' holonId:', holonId)
|
||||
console.log(' shouldConnect:', shouldConnect)
|
||||
console.log(' newName:', newName)
|
||||
console.log(' newDescription:', newDescription)
|
||||
|
||||
// Create new props without the editing fields
|
||||
const { editingName: _editingName, editingDescription: _editingDescription, ...restProps } = shape.props
|
||||
|
||||
const newProps = {
|
||||
...restProps,
|
||||
isEditing: false,
|
||||
name: newName,
|
||||
description: newDescription,
|
||||
isConnected: shouldConnect,
|
||||
holonId: holonId, // Explicitly set holonId
|
||||
}
|
||||
|
||||
console.log(' New props:', newProps)
|
||||
|
||||
// Update the shape
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: newProps,
|
||||
})
|
||||
|
||||
console.log('✅ Shape updated, isConnected:', shouldConnect)
|
||||
|
||||
// If we have a connected holon, store the metadata
|
||||
if (holonId && shouldConnect) {
|
||||
console.log('📝 Storing metadata to GunDB for holon:', holonId)
|
||||
try {
|
||||
await holosphereService.putData(holonId, 'metadata', {
|
||||
name: newName,
|
||||
description: newDescription,
|
||||
lastUpdated: Date.now()
|
||||
})
|
||||
console.log('✅ Metadata saved to GunDB')
|
||||
} catch (error) {
|
||||
console.error('❌ Error saving metadata:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
// Create new props without the editing fields
|
||||
const { editingName: _editingName, editingDescription: _editingDescription, ...restProps } = shape.props
|
||||
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: {
|
||||
...restProps,
|
||||
isEditing: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleNameChange = (newName: string) => {
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: {
|
||||
...shape.props,
|
||||
editingName: newName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDescriptionChange = (newDescription: string) => {
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
props: {
|
||||
...shape.props,
|
||||
editingDescription: newDescription,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancelEdit()
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSaveEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleRefreshData = async () => {
|
||||
await loadHolonData()
|
||||
}
|
||||
|
||||
const handleAddData = async () => {
|
||||
if (!holonId || !isConnected) return
|
||||
|
||||
const newData = {
|
||||
id: `data-${Date.now()}`,
|
||||
content: 'New data entry',
|
||||
timestamp: Date.now(),
|
||||
type: 'manual'
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await holosphereService.putData(holonId, selectedLens || 'general', newData)
|
||||
if (success) {
|
||||
await loadHolonData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error adding data:', error)
|
||||
setError('Failed to add data')
|
||||
}
|
||||
}
|
||||
|
||||
const getResolutionInfo = () => {
|
||||
const resolutionName = HoloSphereService.getResolutionName(resolution)
|
||||
const resolutionDescription = HoloSphereService.getResolutionDescription(resolution)
|
||||
return { name: resolutionName, description: resolutionDescription }
|
||||
}
|
||||
|
||||
const getCategoryDisplayName = (lensName: string): string => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
'active_users': 'Active Users',
|
||||
'users': 'Users',
|
||||
'rankings': 'View Rankings & Stats',
|
||||
'stats': 'Statistics',
|
||||
'tasks': 'Tasks',
|
||||
'progress': 'Progress',
|
||||
'events': 'Events',
|
||||
'activities': 'Recent Activities',
|
||||
'items': 'Items',
|
||||
'shopping': 'Shopping',
|
||||
'active_items': 'Active Items',
|
||||
'proposals': 'Proposals',
|
||||
'offers': 'Offers & Requests',
|
||||
'requests': 'Requests',
|
||||
'checklists': 'Checklists',
|
||||
'roles': 'Roles'
|
||||
}
|
||||
return categoryMap[lensName] || lensName
|
||||
}
|
||||
|
||||
const getCategoryIcon = (lensName: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'active_users': '👥',
|
||||
'users': '👤',
|
||||
'rankings': '📊',
|
||||
'stats': '📈',
|
||||
'tasks': '✅',
|
||||
'progress': '📈',
|
||||
'events': '📅',
|
||||
'activities': '🔔',
|
||||
'items': '📦',
|
||||
'shopping': '🛒',
|
||||
'active_items': '🏷️',
|
||||
'proposals': '💡',
|
||||
'offers': '🤝',
|
||||
'requests': '📬',
|
||||
'checklists': '☑️',
|
||||
'roles': '🎭'
|
||||
}
|
||||
return iconMap[lensName] || '🔍'
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
color: 'black',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
cursor: isEditing ? 'text' : 'pointer',
|
||||
transition: 'background-color 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
color: 'black',
|
||||
backgroundColor: 'transparent',
|
||||
padding: '4px',
|
||||
margin: 0,
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
cursor: 'text',
|
||||
}
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1000,
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto',
|
||||
}
|
||||
|
||||
// Custom header content with holon info and action buttons
|
||||
const headerContent = (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '8px' }}>
|
||||
<span>
|
||||
🌐 Holon: {holonId || 'Not Connected'}
|
||||
{isLoading && <span style={{color: '#ffa500', fontSize: '8px'}}>(Loading...)</span>}
|
||||
{error && <span style={{color: '#ff4444', fontSize: '8px'}}>({error})</span>}
|
||||
{isConnected && <span style={{color: '#4CAF50', fontSize: '8px'}}>(Connected)</span>}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: '1px solid #45a049'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleRefreshData()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Refresh data"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: '#2196F3',
|
||||
color: 'white',
|
||||
border: '1px solid #1976D2'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleAddData()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Add data"
|
||||
>
|
||||
➕
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const resolutionInfo = getResolutionInfo()
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Holon"
|
||||
primaryColor={HolonShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
headerContent={headerContent}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
|
||||
<div style={contentStyle}>
|
||||
{!isConnected ? (
|
||||
// Initial state: Show clear HolonID input interface (shown until user connects)
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
padding: '20px',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: 0
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: '#333',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.5',
|
||||
width: '100%'
|
||||
}}>
|
||||
Enter your HolonID to connect to the Holosphere
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={holonId}
|
||||
onChange={(e) => handleHolonIdChange(e.target.value)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onWheel={handleWheel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && holonId.trim() !== '') {
|
||||
e.preventDefault()
|
||||
handleConnect()
|
||||
}
|
||||
}}
|
||||
placeholder="1002848305066"
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'monospace',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 8px rgba(34, 197, 94, 0.15)',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
color: '#333',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#16a34a'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.25)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (holonId.trim() !== '') {
|
||||
handleConnect()
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!holonId || holonId.trim() === ''}
|
||||
style={{
|
||||
height: '48px',
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'inherit',
|
||||
color: 'white',
|
||||
backgroundColor: holonId && holonId.trim() !== '' ? '#22c55e' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: holonId && holonId.trim() !== '' ? 'pointer' : 'not-allowed',
|
||||
boxShadow: holonId && holonId.trim() !== '' ? '0 2px 8px rgba(34, 197, 94, 0.25)' : 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
outline: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (holonId && holonId.trim() !== '') {
|
||||
e.currentTarget.style.backgroundColor = '#16a34a'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.35)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (holonId && holonId.trim() !== '') {
|
||||
e.currentTarget.style.backgroundColor = '#22c55e'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(34, 197, 94, 0.25)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Connect to the Holosphere
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
width: '100%'
|
||||
}}>
|
||||
Press Enter or click the button to connect
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
cursor: "text",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "8px",
|
||||
boxSizing: "border-box",
|
||||
position: "relative",
|
||||
pointerEvents: "auto"
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Display all data from all lenses */}
|
||||
{isConnected && data && Object.keys(data).length > 0 && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: '8px',
|
||||
borderBottom: '2px solid #4CAF50',
|
||||
paddingBottom: '4px'
|
||||
}}>
|
||||
📊 Holon Data ({Object.keys(data).length} categor{Object.keys(data).length !== 1 ? 'ies' : 'y'})
|
||||
</div>
|
||||
|
||||
{Object.entries(data).map(([lensName, lensData]) => (
|
||||
<div key={lensName} style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2196F3',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
{getCategoryIcon(lensName)} {getCategoryDisplayName(lensName)}
|
||||
</div>
|
||||
<div style={{
|
||||
backgroundColor: '#f9f9f9',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e0e0e0',
|
||||
fontSize: '9px'
|
||||
}}>
|
||||
{lensData && typeof lensData === 'object' ? (
|
||||
Object.entries(lensData).length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{Object.entries(lensData).map(([key, value]) => (
|
||||
<div key={key} style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
minWidth: '80px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{key}:
|
||||
</span>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
color: '#333',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#999', fontStyle: 'italic' }}>No data in this lens</div>
|
||||
)
|
||||
) : (
|
||||
<div style={{ color: '#333' }}>{String(lensData)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && (!data || Object.keys(data).length === 0) && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ marginBottom: '8px' }}>📭 No data found in this holon</div>
|
||||
<div style={{ fontSize: '9px' }}>
|
||||
Categories checked: Active Users, Users, Rankings, Tasks, Progress, Events, Activities, Items, Shopping, Proposals, Offers, Checklists, Roles
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IHolon) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
RecordProps,
|
||||
T
|
||||
} from "tldraw"
|
||||
import { ShareLocation } from "@/components/location/ShareLocation"
|
||||
|
||||
export type ILocationShare = TLBaseShape<
|
||||
"LocationShare",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
>
|
||||
|
||||
export class LocationShareShape extends BaseBoxShapeUtil<ILocationShare> {
|
||||
static override type = "LocationShare" as const
|
||||
|
||||
static override props: RecordProps<ILocationShare> = {
|
||||
w: T.number,
|
||||
h: T.number
|
||||
}
|
||||
|
||||
getDefaultProps(): ILocationShare["props"] {
|
||||
return {
|
||||
w: 800,
|
||||
h: 600
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: ILocationShare) {
|
||||
return (
|
||||
<HTMLContainer
|
||||
id={shape.id}
|
||||
style={{
|
||||
overflow: "auto",
|
||||
pointerEvents: "all",
|
||||
backgroundColor: "var(--color-panel)",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--color-panel-contrast)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "0"
|
||||
}}
|
||||
>
|
||||
<ShareLocation />
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: ILocationShare) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
|
@ -27,29 +27,8 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
|
|||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
const markdownRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// Single useEffect hook that handles checkbox interactivity
|
||||
React.useEffect(() => {
|
||||
if (!isSelected && markdownRef.current) {
|
||||
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
|
||||
checkboxes.forEach((checkbox) => {
|
||||
checkbox.removeAttribute('disabled')
|
||||
checkbox.addEventListener('click', handleCheckboxClick)
|
||||
})
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (markdownRef.current) {
|
||||
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
|
||||
checkboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener('click', handleCheckboxClick)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isSelected, shape.props.text])
|
||||
|
||||
// Handler function defined outside useEffect
|
||||
const handleCheckboxClick = (event: Event) => {
|
||||
// Handler function defined before useEffect
|
||||
const handleCheckboxClick = React.useCallback((event: Event) => {
|
||||
event.stopPropagation()
|
||||
const target = event.target as HTMLInputElement
|
||||
const checked = target.checked
|
||||
|
|
@ -73,7 +52,28 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
|
|||
text: newText,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [shape.id, shape.props.text])
|
||||
|
||||
// Single useEffect hook that handles checkbox interactivity
|
||||
React.useEffect(() => {
|
||||
if (!isSelected && markdownRef.current) {
|
||||
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
|
||||
checkboxes.forEach((checkbox) => {
|
||||
checkbox.removeAttribute('disabled')
|
||||
checkbox.addEventListener('click', handleCheckboxClick)
|
||||
})
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (markdownRef.current) {
|
||||
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
|
||||
checkboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener('click', handleCheckboxClick)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isSelected, shape.props.text, handleCheckboxClick])
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,370 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
} from "tldraw"
|
||||
import React, { useState, useContext } from "react"
|
||||
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||||
import { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||
import { ObsNoteShape } from "./ObsNoteShapeUtil"
|
||||
import { createShapeId } from "tldraw"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { AuthContext } from "../context/AuthContext"
|
||||
|
||||
type IObsidianBrowser = TLBaseShape<
|
||||
"ObsidianBrowser",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
>
|
||||
|
||||
export class ObsidianBrowserShape extends BaseBoxShapeUtil<IObsidianBrowser> {
|
||||
static override type = "ObsidianBrowser" as const
|
||||
|
||||
getDefaultProps(): IObsidianBrowser["props"] {
|
||||
return {
|
||||
w: 800,
|
||||
h: 600,
|
||||
}
|
||||
}
|
||||
|
||||
// Obsidian theme color: Darker Pink/Purple
|
||||
static readonly PRIMARY_COLOR = "#9333ea"
|
||||
|
||||
component(shape: IObsidianBrowser) {
|
||||
const { w, h } = shape.props
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Wrapper component to access auth context
|
||||
const ObsidianBrowserContent: React.FC<{ vaultName?: string }> = ({ vaultName }) => {
|
||||
|
||||
const handleObsNoteSelect = (obsNote: ObsidianObsNote) => {
|
||||
// Position notes in a 3xn grid (3 columns, unlimited rows), incrementally
|
||||
const shapeWidth = 300
|
||||
const shapeHeight = 200
|
||||
const noteSpacing = 20
|
||||
const notesPerRow = 3
|
||||
|
||||
// Get the ObsidianBrowser shape bounds for reference
|
||||
const browserShapeBounds = this.editor.getShapePageBounds(shape.id)
|
||||
let startX: number
|
||||
let startY: number
|
||||
|
||||
if (!browserShapeBounds) {
|
||||
// Fallback to viewport center if shape bounds not available
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
startX = viewport.x + viewport.w / 2
|
||||
startY = viewport.y + viewport.h / 2
|
||||
} else {
|
||||
// Position to the right of the browser shape, aligned with the TOP
|
||||
const browserSpacing = 5
|
||||
startX = browserShapeBounds.x + browserShapeBounds.w + browserSpacing
|
||||
startY = browserShapeBounds.y // TOP of browser vault - this is the key!
|
||||
}
|
||||
|
||||
// Find existing ObsNote shapes that belong to THIS specific browser instance
|
||||
// Only count notes that are positioned immediately to the right of this browser
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
let nextIndex: number
|
||||
|
||||
if (browserShapeBounds) {
|
||||
const browserSpacing = 5
|
||||
const expectedStartX = browserShapeBounds.x + browserShapeBounds.w + browserSpacing
|
||||
const maxGridWidth = 3 * (shapeWidth + noteSpacing) // Width of 3 columns
|
||||
const maxGridHeight = 10 * (shapeHeight + noteSpacing) // Height for many rows (n)
|
||||
|
||||
const existingObsNotes = allShapes.filter(s => {
|
||||
if (s.type !== 'ObsNote') return false
|
||||
const noteBounds = this.editor.getShapePageBounds(s.id)
|
||||
if (!noteBounds) return false
|
||||
|
||||
// Check if note is positioned immediately to the right of THIS browser
|
||||
// X should be in the grid area to the right of this browser
|
||||
const isInXRange = noteBounds.x >= expectedStartX - 50 &&
|
||||
noteBounds.x <= expectedStartX + maxGridWidth + 50
|
||||
|
||||
// Y should be aligned with the top of this browser (within grid area)
|
||||
const isInYRange = noteBounds.y >= browserShapeBounds.y - 50 &&
|
||||
noteBounds.y <= browserShapeBounds.y + maxGridHeight + 50
|
||||
|
||||
return isInXRange && isInYRange
|
||||
})
|
||||
|
||||
// Calculate next position in 3xn grid starting from TOP
|
||||
nextIndex = existingObsNotes.length
|
||||
} else {
|
||||
// Fallback: count all notes if browser bounds not available
|
||||
const existingObsNotes = allShapes.filter(s => s.type === 'ObsNote')
|
||||
nextIndex = existingObsNotes.length
|
||||
}
|
||||
|
||||
const row = Math.floor(nextIndex / notesPerRow)
|
||||
const col = nextIndex % notesPerRow
|
||||
const xPosition = startX + col * (shapeWidth + noteSpacing)
|
||||
const yPosition = startY + row * (shapeHeight + noteSpacing) // Row 0 = TOP alignment
|
||||
|
||||
// Vault info will be handled by ObsidianVaultBrowser component which has access to session
|
||||
// For now, pass undefined and let ObsNoteShape handle it
|
||||
const vaultPath = undefined
|
||||
const vaultName = undefined
|
||||
|
||||
// Create a new obs_note shape with vault information
|
||||
const obsNoteShape = ObsNoteShape.createFromObsidianObsNote(
|
||||
obsNote,
|
||||
xPosition,
|
||||
yPosition,
|
||||
createShapeId(),
|
||||
vaultPath,
|
||||
vaultName
|
||||
)
|
||||
|
||||
// Add the shape to the canvas
|
||||
try {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
this.editor.createShapes([obsNoteShape])
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = this.editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the newly created shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([obsNoteShape.id] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error('🎯 Error adding shape to canvas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleObsNotesSelect = (obsNotes: ObsidianObsNote[]) => {
|
||||
// Vault info will be handled by ObsidianVaultBrowser component which has access to session
|
||||
// For now, pass undefined and let ObsNoteShape handle it
|
||||
const vaultPath = undefined
|
||||
const vaultName = undefined
|
||||
|
||||
// Position notes in a 3xn grid (3 columns, unlimited rows), incrementally
|
||||
const noteSpacing = 20
|
||||
const shapeWidth = 300
|
||||
const shapeHeight = 200
|
||||
const notesPerRow = 3
|
||||
|
||||
// Get the ObsidianBrowser shape bounds for reference
|
||||
const browserShapeBounds = this.editor.getShapePageBounds(shape.id)
|
||||
let startX: number
|
||||
let startY: number
|
||||
|
||||
if (!browserShapeBounds) {
|
||||
// Fallback to viewport center if shape bounds not available
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
startX = viewport.x + viewport.w / 2
|
||||
startY = viewport.y + viewport.h / 2
|
||||
} else {
|
||||
// Position to the right of the browser shape, aligned with the TOP
|
||||
const browserSpacing = 5
|
||||
startX = browserShapeBounds.x + browserShapeBounds.w + browserSpacing
|
||||
startY = browserShapeBounds.y // TOP of browser vault - this is the key!
|
||||
}
|
||||
|
||||
// Find existing ObsNote shapes that belong to THIS specific browser instance
|
||||
// Only count notes that are positioned immediately to the right of this browser
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
let startIndex: number
|
||||
|
||||
if (browserShapeBounds) {
|
||||
const browserSpacing = 5
|
||||
const expectedStartX = browserShapeBounds.x + browserShapeBounds.w + browserSpacing
|
||||
const maxGridWidth = 3 * (shapeWidth + noteSpacing) // Width of 3 columns
|
||||
const maxGridHeight = 10 * (shapeHeight + noteSpacing) // Height for many rows (n)
|
||||
|
||||
const existingObsNotes = allShapes.filter(s => {
|
||||
if (s.type !== 'ObsNote') return false
|
||||
const noteBounds = this.editor.getShapePageBounds(s.id)
|
||||
if (!noteBounds) return false
|
||||
|
||||
// Check if note is positioned immediately to the right of THIS browser
|
||||
// X should be in the grid area to the right of this browser
|
||||
const isInXRange = noteBounds.x >= expectedStartX - 50 &&
|
||||
noteBounds.x <= expectedStartX + maxGridWidth + 50
|
||||
|
||||
// Y should be aligned with the top of this browser (within grid area)
|
||||
const isInYRange = noteBounds.y >= browserShapeBounds.y - 50 &&
|
||||
noteBounds.y <= browserShapeBounds.y + maxGridHeight + 50
|
||||
|
||||
return isInXRange && isInYRange
|
||||
})
|
||||
startIndex = existingObsNotes.length
|
||||
} else {
|
||||
// Fallback: count all notes if browser bounds not available
|
||||
const existingObsNotes = allShapes.filter(s => s.type === 'ObsNote')
|
||||
startIndex = existingObsNotes.length
|
||||
}
|
||||
|
||||
const shapes = obsNotes.map((obsNote, index) => {
|
||||
// Calculate position in 3xn grid, continuing from existing notes, starting from TOP
|
||||
const gridIndex = startIndex + index
|
||||
const row = Math.floor(gridIndex / notesPerRow)
|
||||
const col = gridIndex % notesPerRow
|
||||
const xPosition = startX + col * (shapeWidth + noteSpacing)
|
||||
const yPosition = startY + row * (shapeHeight + noteSpacing) // Row 0 = TOP alignment
|
||||
|
||||
const shapeId = createShapeId()
|
||||
|
||||
return ObsNoteShape.createFromObsidianObsNote(
|
||||
obsNote,
|
||||
xPosition,
|
||||
yPosition,
|
||||
shapeId,
|
||||
vaultPath,
|
||||
vaultName
|
||||
)
|
||||
})
|
||||
|
||||
// Add all shapes to the canvas
|
||||
try {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
this.editor.createShapes(shapes)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = this.editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the newly created shapes
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes(shapes.map(s => s.id) as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error('🎯 Error adding shapes to canvas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
// Delete the browser shape after a short delay
|
||||
setTimeout(() => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
// Custom header content with vault information
|
||||
const headerContent = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%' }}>
|
||||
<span>
|
||||
📚 Obsidian Browser
|
||||
{vaultName && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 400,
|
||||
color: isSelected ? 'rgba(255,255,255,0.8)' : `${ObsidianBrowserShape.PRIMARY_COLOR}80`
|
||||
}}>
|
||||
({vaultName})
|
||||
</span>
|
||||
)}
|
||||
{!vaultName && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 400,
|
||||
color: isSelected ? 'rgba(255,255,255,0.7)' : `${ObsidianBrowserShape.PRIMARY_COLOR}60`
|
||||
}}>
|
||||
(No vault connected)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Obsidian Browser"
|
||||
primaryColor={ObsidianBrowserShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
headerContent={headerContent}
|
||||
>
|
||||
<ObsidianVaultBrowser
|
||||
key={`obsidian-browser-${shape.id}`}
|
||||
onObsNoteSelect={handleObsNoteSelect}
|
||||
onObsNotesSelect={handleObsNotesSelect}
|
||||
onClose={handleClose}
|
||||
shapeMode={true}
|
||||
autoOpenFolderPicker={false}
|
||||
showVaultBrowser={true}
|
||||
/>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Get vault information from auth context using a wrapper component
|
||||
const ObsidianBrowserWithContext: React.FC = () => {
|
||||
const authContext = useContext(AuthContext)
|
||||
const fallbackSession = {
|
||||
username: '',
|
||||
authed: false,
|
||||
loading: false,
|
||||
backupCreated: null,
|
||||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined
|
||||
}
|
||||
const session = authContext?.session || fallbackSession
|
||||
const vaultName = session.obsidianVaultName
|
||||
|
||||
return <ObsidianBrowserContent vaultName={vaultName} />
|
||||
}
|
||||
|
||||
return <ObsidianBrowserWithContext />
|
||||
}
|
||||
|
||||
indicator(shape: IObsidianBrowser) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
Geometry2d,
|
||||
HTMLContainer,
|
||||
Rectangle2d,
|
||||
TLBaseShape,
|
||||
TLGeoShape,
|
||||
TLShape,
|
||||
createShapeId,
|
||||
} from "tldraw"
|
||||
import { getEdge } from "@/propagators/tlgraph"
|
||||
import { llm, getApiKey } from "@/utils/llmUtils"
|
||||
import { AI_PERSONALITIES } from "@/lib/settings"
|
||||
import { isShapeOfType } from "@/propagators/utils"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
import React, { useState } from "react"
|
||||
|
||||
type IPrompt = TLBaseShape<
|
||||
|
|
@ -18,6 +23,8 @@ type IPrompt = TLBaseShape<
|
|||
prompt: string
|
||||
value: string
|
||||
agentBinding: string | null
|
||||
personality?: string
|
||||
error?: string | null
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -44,13 +51,22 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
getDefaultProps(): IPrompt["props"] {
|
||||
return {
|
||||
w: 300,
|
||||
h: 50,
|
||||
h: this.FIXED_HEIGHT,
|
||||
prompt: "",
|
||||
value: "",
|
||||
agentBinding: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Override getGeometry to ensure the selector box always matches the rendered component height
|
||||
getGeometry(shape: IPrompt): Geometry2d {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: Math.max(shape.props.h, this.FIXED_HEIGHT),
|
||||
isFilled: false,
|
||||
})
|
||||
}
|
||||
|
||||
// override onResize: TLResizeHandle<IPrompt> = (
|
||||
// shape,
|
||||
// { scaleX, initialShape },
|
||||
|
|
@ -69,6 +85,12 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
// }
|
||||
|
||||
component(shape: IPrompt) {
|
||||
// Ensure shape props exist with defaults
|
||||
const props = shape.props || {}
|
||||
const prompt = props.prompt || ""
|
||||
const value = props.value || ""
|
||||
const agentBinding = props.agentBinding || ""
|
||||
|
||||
const arrowBindings = this.editor.getBindingsInvolvingShape(
|
||||
shape.id,
|
||||
"arrow",
|
||||
|
|
@ -91,6 +113,15 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
const generateText = async (prompt: string) => {
|
||||
console.log("🎯 generateText called with prompt:", prompt);
|
||||
|
||||
// Clear any previous errors
|
||||
this.editor.updateShape<IPrompt>({
|
||||
id: shape.id,
|
||||
type: "Prompt",
|
||||
props: {
|
||||
error: null
|
||||
},
|
||||
})
|
||||
|
||||
const conversationHistory = shape.props.value ? shape.props.value + '\n' : ''
|
||||
const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
|
||||
const userMessage = `{"role": "user", "content": "${escapedPrompt}"}`
|
||||
|
|
@ -104,7 +135,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
type: "Prompt",
|
||||
props: {
|
||||
value: conversationHistory + userMessage,
|
||||
agentBinding: "someone"
|
||||
agentBinding: "someone",
|
||||
error: null
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -132,7 +164,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
type: "Prompt",
|
||||
props: {
|
||||
value: conversationHistory + userMessage + '\n' + assistantMessage,
|
||||
agentBinding: done ? null : "someone"
|
||||
agentBinding: done ? null : "someone",
|
||||
error: null
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -140,10 +173,29 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
console.error('❌ Invalid JSON message:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, shape.props.personality)
|
||||
console.log("✅ LLM function completed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Error in LLM function:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("❌ Error in LLM function:", errorMessage);
|
||||
|
||||
// Display error to user
|
||||
const userFriendlyError = errorMessage.includes('No valid API key')
|
||||
? '❌ No valid API key found. Please configure your API keys in settings.'
|
||||
: errorMessage.includes('All AI providers failed')
|
||||
? '❌ All API keys failed. Please check your API keys in settings.'
|
||||
: errorMessage.includes('401') || errorMessage.includes('403') || errorMessage.includes('Unauthorized')
|
||||
? '❌ API key authentication failed. Your API key may be expired or invalid. Please check your API keys in settings.'
|
||||
: `❌ Error: ${errorMessage}`;
|
||||
|
||||
this.editor.updateShape<IPrompt>({
|
||||
id: shape.id,
|
||||
type: "Prompt",
|
||||
props: {
|
||||
agentBinding: null,
|
||||
error: userFriendlyError
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure the final message is saved after streaming is complete
|
||||
|
|
@ -161,7 +213,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
type: "Prompt",
|
||||
props: {
|
||||
value: conversationHistory + userMessage + '\n' + assistantMessage,
|
||||
agentBinding: null
|
||||
agentBinding: null,
|
||||
error: null // Clear any errors on success
|
||||
},
|
||||
})
|
||||
console.log("✅ Final response saved successfully");
|
||||
|
|
@ -196,7 +249,7 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
}
|
||||
|
||||
// Add state for copy button text
|
||||
const [copyButtonText, setCopyButtonText] = React.useState("Copy Conversation")
|
||||
const [copyButtonText, setCopyButtonText] = React.useState("Copy Conversation to Knowledge Object")
|
||||
|
||||
// In the component function, add state for tracking copy success
|
||||
const [isCopied, setIsCopied] = React.useState(false)
|
||||
|
|
@ -233,24 +286,72 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
.map(line => {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
return `${parsed.role}: ${parsed.content}`;
|
||||
return `**${parsed.role === 'user' ? 'User' : 'Assistant'}:**\n${parsed.content}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
await navigator.clipboard.writeText(messages);
|
||||
setCopyButtonText("Copied!");
|
||||
// Format the conversation as markdown content
|
||||
const conversationContent = `# Conversation History\n\n${messages}`;
|
||||
|
||||
// Get the prompt shape's position to place the new shape nearby
|
||||
const promptShapeBounds = this.editor.getShapePageBounds(shape.id);
|
||||
const baseX = promptShapeBounds ? promptShapeBounds.x + promptShapeBounds.w + 20 : shape.x + shape.props.w + 20;
|
||||
const baseY = promptShapeBounds ? promptShapeBounds.y : shape.y;
|
||||
|
||||
// Find a non-overlapping position for the new ObsNote shape
|
||||
const shapeWidth = 300;
|
||||
const shapeHeight = 200;
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
);
|
||||
|
||||
// Create a new ObsNote shape with the conversation content
|
||||
const obsNoteShape = this.editor.createShape({
|
||||
type: 'ObsNote',
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
color: 'black',
|
||||
size: 'm',
|
||||
font: 'sans',
|
||||
textAlign: 'start',
|
||||
scale: 1,
|
||||
noteId: createShapeId(),
|
||||
title: 'Conversation History',
|
||||
content: conversationContent,
|
||||
tags: ['#conversation', '#llm'],
|
||||
showPreview: true,
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#000000',
|
||||
isEditing: false,
|
||||
editingContent: '',
|
||||
isModified: false,
|
||||
originalContent: conversationContent,
|
||||
}
|
||||
});
|
||||
|
||||
// Select the newly created shape
|
||||
this.editor.setSelectedShapes([`shape:${obsNoteShape.id}`] as any);
|
||||
|
||||
setCopyButtonText("Created!");
|
||||
setTimeout(() => {
|
||||
setCopyButtonText("Copy Conversation");
|
||||
setCopyButtonText("Copy Conversation to Knowledge Object");
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text:', err);
|
||||
setCopyButtonText("Failed to copy");
|
||||
console.error('Failed to create knowledge object:', err);
|
||||
setCopyButtonText("Failed to create");
|
||||
setTimeout(() => {
|
||||
setCopyButtonText("Copy Conversation");
|
||||
setCopyButtonText("Copy Conversation to Knowledge Object");
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
|
@ -304,6 +405,45 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
pointerEvents: isSelected || isHovering ? "all" : "none",
|
||||
}}
|
||||
>
|
||||
{shape.props.error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
backgroundColor: "#fee",
|
||||
border: "1px solid #fcc",
|
||||
borderRadius: "8px",
|
||||
color: "#c33",
|
||||
marginBottom: "8px",
|
||||
fontSize: "13px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "18px" }}>⚠️</span>
|
||||
<span>{shape.props.error}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.editor.updateShape<IPrompt>({
|
||||
id: shape.id,
|
||||
type: "Prompt",
|
||||
props: { error: null },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#fcc",
|
||||
border: "1px solid #c99",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{shape.props.value ? (
|
||||
shape.props.value.split('\n').map((message, index) => {
|
||||
if (!message.trim()) return null;
|
||||
|
|
@ -392,9 +532,54 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
marginTop: "auto",
|
||||
pointerEvents: isSelected || isHovering ? "all" : "none",
|
||||
}}>
|
||||
{/* AI Personality Selector */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: "5px"
|
||||
flexDirection: "column",
|
||||
gap: "3px",
|
||||
marginBottom: "5px"
|
||||
}}>
|
||||
<label style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
color: "#666",
|
||||
marginBottom: "2px"
|
||||
}}>
|
||||
AI Personality:
|
||||
</label>
|
||||
<select
|
||||
value={shape.props.personality || 'web-developer'}
|
||||
onChange={(e) => {
|
||||
this.editor.updateShape<IPrompt>({
|
||||
id: shape.id,
|
||||
type: "Prompt",
|
||||
props: { personality: e.target.value },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
border: "1px solid rgba(0, 0, 0, 0.1)",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
cursor: "pointer",
|
||||
height: "28px"
|
||||
}}
|
||||
>
|
||||
{AI_PERSONALITIES.map((personality) => (
|
||||
<option key={personality.id} value={personality.id}>
|
||||
{personality.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: "5px",
|
||||
position: "relative",
|
||||
zIndex: 1000,
|
||||
pointerEvents: "all",
|
||||
}}>
|
||||
<input
|
||||
style={{
|
||||
|
|
@ -405,6 +590,10 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
border: "1px solid rgba(0, 0, 0, 0.05)",
|
||||
borderRadius: 6 - this.PADDING,
|
||||
fontSize: 16,
|
||||
padding: "0 8px",
|
||||
position: "relative",
|
||||
zIndex: 1000,
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
type="text"
|
||||
placeholder="Enter prompt..."
|
||||
|
|
@ -417,23 +606,54 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handlePrompt()
|
||||
if (shape.props.prompt.trim() && !shape.props.agentBinding) {
|
||||
handlePrompt()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
width: 100,
|
||||
height: "40px",
|
||||
pointerEvents: "all",
|
||||
cursor: shape.props.prompt.trim() && !shape.props.agentBinding ? "pointer" : "not-allowed",
|
||||
backgroundColor: shape.props.prompt.trim() && !shape.props.agentBinding ? "#007AFF" : "#ccc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
fontWeight: "500",
|
||||
position: "relative",
|
||||
zIndex: 1000,
|
||||
opacity: shape.props.prompt.trim() && !shape.props.agentBinding ? 1 : 0.6,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (shape.props.prompt.trim() && !shape.props.agentBinding) {
|
||||
handlePrompt()
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (shape.props.prompt.trim() && !shape.props.agentBinding) {
|
||||
handlePrompt()
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
onClick={handlePrompt}
|
||||
>
|
||||
Prompt
|
||||
</button>
|
||||
|
|
@ -460,13 +680,14 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
)
|
||||
}
|
||||
|
||||
// Override the default indicator behavior
|
||||
// TODO: FIX SECOND INDICATOR UX GLITCH
|
||||
// Override the default indicator behavior to match the actual rendered size
|
||||
override indicator(shape: IPrompt) {
|
||||
// Use Math.max to ensure the indicator covers the full component height
|
||||
// This handles both new shapes (h = FIXED_HEIGHT) and old shapes (h might be smaller)
|
||||
return (
|
||||
<rect
|
||||
width={shape.props.w}
|
||||
height={this.FIXED_HEIGHT}
|
||||
height={Math.max(shape.props.h, this.FIXED_HEIGHT)}
|
||||
rx={6}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export class SlideShape extends BaseBoxShapeUtil<ISlideShape> {
|
|||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const slides = useSlides()
|
||||
const index = slides.findIndex((s) => s.id === shape.id)
|
||||
const index = Array.isArray(slides) ? slides.findIndex((s) => s.id === shape.id) : -1
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const handleLabelPointerDown = useCallback(
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import {
|
|||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
} from "tldraw"
|
||||
import React, { useState, useRef, useEffect } from "react"
|
||||
import { useWhisperTranscription } from "../hooks/useWhisperTranscription"
|
||||
import { getOpenAIConfig, isOpenAIConfigured } from "../lib/clientConfig"
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
||||
import { useWhisperTranscription } from "../hooks/useWhisperTranscriptionSimple"
|
||||
import { useWebSpeechTranscription } from "../hooks/useWebSpeechTranscription"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
type ITranscription = TLBaseShape<
|
||||
"Transcription",
|
||||
|
|
@ -17,6 +18,7 @@ type ITranscription = TLBaseShape<
|
|||
editingContent?: string
|
||||
isTranscribing?: boolean
|
||||
isPaused?: boolean
|
||||
fixedHeight?: boolean // New property to control resizing
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -29,19 +31,11 @@ const AutoResizeTextarea: React.FC<{
|
|||
style: React.CSSProperties
|
||||
placeholder?: string
|
||||
onPointerDown?: (e: React.PointerEvent) => void
|
||||
}> = ({ value, onChange, onBlur, onKeyDown, style, placeholder, onPointerDown }) => {
|
||||
onWheel?: (e: React.WheelEvent) => void
|
||||
}> = ({ value, onChange, onBlur, onKeyDown, style, placeholder, onPointerDown, onWheel }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${textarea.scrollHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight()
|
||||
// Focus the textarea when it mounts
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
|
|
@ -54,14 +48,13 @@ const AutoResizeTextarea: React.FC<{
|
|||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
adjustHeight()
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={onPointerDown}
|
||||
onWheel={onWheel}
|
||||
style={style}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
|
|
@ -70,55 +63,146 @@ const AutoResizeTextarea: React.FC<{
|
|||
export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
||||
static override type = "Transcription" as const
|
||||
|
||||
// Transcription theme color: Orange
|
||||
static readonly PRIMARY_COLOR = "#ff9500"
|
||||
|
||||
// Note: props validation is handled by the schema registration in useAutomergeStoreV2
|
||||
|
||||
getDefaultProps(): ITranscription["props"] {
|
||||
return {
|
||||
w: 400,
|
||||
h: 100,
|
||||
w: 500,
|
||||
h: 350,
|
||||
text: "",
|
||||
isEditing: false,
|
||||
isTranscribing: false,
|
||||
isPaused: false,
|
||||
fixedHeight: true, // Start with fixed height
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: ITranscription) {
|
||||
const { w, h, text, isEditing = false, isTranscribing = false, isPaused = false } = shape.props
|
||||
const { w, h, text = '', isEditing = false, isTranscribing = false, isPaused = false } = shape.props
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [editingContent, setEditingContent] = useState(shape.props.editingContent || text)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [editingContent, setEditingContent] = useState(shape.props.editingContent || text || '')
|
||||
|
||||
const [recordingDuration, setRecordingDuration] = useState(0)
|
||||
const [useWebSpeech, setUseWebSpeech] = useState(true) // Use Web Speech API by default
|
||||
const [isLiveEditing, setIsLiveEditing] = useState(false) // Allow editing while transcribing
|
||||
const [liveEditTranscript, setLiveEditTranscript] = useState('') // Separate transcript for live editing mode
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
const isMountedRef = useRef(true)
|
||||
const stopRecordingRef = useRef<(() => void | Promise<void>) | null>(null)
|
||||
|
||||
// Get OpenAI configuration
|
||||
const openaiConfig = getOpenAIConfig()
|
||||
const isOpenAIConfiguredFlag = isOpenAIConfigured()
|
||||
// Local Whisper model is always available (no API key needed)
|
||||
const isLocalWhisperAvailable = true
|
||||
|
||||
// Whisper transcription hook
|
||||
const {
|
||||
isRecording,
|
||||
isSpeaking,
|
||||
isTranscribing: hookIsTranscribing,
|
||||
transcript,
|
||||
startTranscription,
|
||||
stopTranscription,
|
||||
pauseTranscription
|
||||
} = useWhisperTranscription({
|
||||
apiKey: openaiConfig?.apiKey,
|
||||
// Memoize the hook options to prevent unnecessary re-renders
|
||||
const hookOptions = useMemo(() => ({
|
||||
onTranscriptUpdate: (newText: string) => {
|
||||
console.log('📝 Whisper transcript updated in TranscriptionShape:', newText)
|
||||
// Update the shape with new text
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
// Always append to existing text for continuous transcription
|
||||
const currentText = shape.props.text || ''
|
||||
const updatedText = currentText + (currentText ? ' ' : '') + newText
|
||||
|
||||
if (!isLiveEditing) {
|
||||
// Clean the props to ensure only valid properties are passed
|
||||
const cleanProps = {
|
||||
...shape.props,
|
||||
text: newText,
|
||||
h: Math.max(100, Math.ceil(newText.length / 50) * 20 + 60) // Dynamic height
|
||||
text: updatedText
|
||||
// Removed h: Math.max(100, Math.ceil(newText.length / 50) * 20 + 60) to prevent auto-resizing
|
||||
}
|
||||
})
|
||||
|
||||
// Remove any undefined or null values that might cause validation issues
|
||||
Object.keys(cleanProps).forEach(key => {
|
||||
if ((cleanProps as any)[key] === undefined || (cleanProps as any)[key] === null) {
|
||||
delete (cleanProps as any)[key]
|
||||
}
|
||||
})
|
||||
|
||||
// Update the shape with appended text
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: cleanProps
|
||||
})
|
||||
|
||||
// Also update the editing content if it's empty or matches the old text
|
||||
if (!editingContent || editingContent === shape.props.text) {
|
||||
setEditingContent(updatedText)
|
||||
}
|
||||
} else {
|
||||
// In live editing mode, append to the separate live edit transcript
|
||||
const currentLiveTranscript = liveEditTranscript || ''
|
||||
const updatedLiveTranscript = currentLiveTranscript + (currentLiveTranscript ? ' ' : '') + newText
|
||||
setLiveEditTranscript(updatedLiveTranscript)
|
||||
|
||||
// Also update editing content to show the live transcript
|
||||
setEditingContent(updatedLiveTranscript)
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('❌ Whisper transcription error:', error)
|
||||
|
||||
// Clean the props to ensure only valid properties are passed
|
||||
const cleanProps = {
|
||||
...shape.props,
|
||||
isTranscribing: false
|
||||
}
|
||||
|
||||
// Remove any undefined or null values that might cause validation issues
|
||||
Object.keys(cleanProps).forEach(key => {
|
||||
if ((cleanProps as any)[key] === undefined || (cleanProps as any)[key] === null) {
|
||||
delete (cleanProps as any)[key]
|
||||
}
|
||||
})
|
||||
|
||||
// Update shape state to stop transcribing on error
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: cleanProps
|
||||
})
|
||||
},
|
||||
language: 'en'
|
||||
}), [shape.id, shape.props, isLiveEditing, editingContent, liveEditTranscript])
|
||||
|
||||
// Web Speech API hook for real-time transcription
|
||||
const webSpeechOptions = useMemo(() => ({
|
||||
onTranscriptUpdate: (newText: string) => {
|
||||
// Always append to existing text for continuous transcription
|
||||
const currentText = shape.props.text || ''
|
||||
const updatedText = currentText + (currentText ? ' ' : '') + newText
|
||||
|
||||
if (!isLiveEditing) {
|
||||
// Update shape text without changing height
|
||||
this.editor.updateShape({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
text: updatedText
|
||||
// Removed h: textHeight to prevent auto-resizing
|
||||
}
|
||||
})
|
||||
|
||||
// Also update the editing content if it's empty or matches the old text
|
||||
if (!editingContent || editingContent === shape.props.text) {
|
||||
setEditingContent(updatedText)
|
||||
}
|
||||
} else {
|
||||
// In live editing mode, append to the separate live edit transcript
|
||||
const currentLiveTranscript = liveEditTranscript || ''
|
||||
const updatedLiveTranscript = currentLiveTranscript + (currentLiveTranscript ? ' ' : '') + newText
|
||||
setLiveEditTranscript(updatedLiveTranscript)
|
||||
|
||||
// Also update editing content to show the live transcript
|
||||
setEditingContent(updatedLiveTranscript)
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Web Speech API error:', error)
|
||||
// Update shape state on error
|
||||
this.editor.updateShape({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
|
|
@ -127,47 +211,215 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
}
|
||||
})
|
||||
},
|
||||
language: 'en',
|
||||
enableStreaming: true,
|
||||
removeSilence: true
|
||||
language: 'en-US'
|
||||
}), [shape.id, shape.props, isLiveEditing, editingContent, liveEditTranscript])
|
||||
|
||||
const {
|
||||
isRecording: webSpeechIsRecording,
|
||||
isTranscribing: webSpeechIsTranscribing,
|
||||
transcript: webSpeechTranscript,
|
||||
interimTranscript,
|
||||
isSupported: webSpeechSupported,
|
||||
startRecording: webSpeechStartRecording,
|
||||
stopRecording: webSpeechStopRecording
|
||||
} = useWebSpeechTranscription(webSpeechOptions)
|
||||
|
||||
// Whisper transcription hook for final processing (when Web Speech is disabled)
|
||||
// Only auto-initialize if Web Speech is not being used (lazy load to avoid unnecessary model loading)
|
||||
const {
|
||||
isRecording: whisperIsRecording,
|
||||
isTranscribing: whisperIsTranscribing,
|
||||
transcript: whisperTranscript,
|
||||
startRecording: whisperStartRecording,
|
||||
stopRecording: whisperStopRecording,
|
||||
pauseRecording: whisperPauseRecording,
|
||||
modelLoaded
|
||||
} = useWhisperTranscription({
|
||||
...hookOptions,
|
||||
enableStreaming: false, // Disable streaming for Whisper when using Web Speech
|
||||
autoInitialize: !useWebSpeech // Only auto-initialize if not using Web Speech
|
||||
})
|
||||
|
||||
// Use Web Speech API by default, fallback to Whisper
|
||||
const isRecording = useWebSpeech ? webSpeechIsRecording : whisperIsRecording
|
||||
const hookIsTranscribing = useWebSpeech ? webSpeechIsTranscribing : whisperIsTranscribing
|
||||
const transcript = useWebSpeech ? webSpeechTranscript : whisperTranscript
|
||||
const currentInterimTranscript = useWebSpeech ? interimTranscript : '' // Only Web Speech has interim transcripts
|
||||
const startRecording = useWebSpeech ? webSpeechStartRecording : whisperStartRecording
|
||||
const stopRecording = useWebSpeech ? webSpeechStopRecording : whisperStopRecording
|
||||
const pauseRecording = useWebSpeech ? null : whisperPauseRecording // Web Speech doesn't have pause, use stop/start instead
|
||||
|
||||
// Combine final transcript with interim transcript for real-time display
|
||||
const displayText = useMemo(() => {
|
||||
const finalText = text || ''
|
||||
// Only show interim transcript when recording and it exists
|
||||
if (isRecording && currentInterimTranscript && useWebSpeech) {
|
||||
return finalText + (finalText ? ' ' : '') + currentInterimTranscript
|
||||
}
|
||||
return finalText
|
||||
}, [text, currentInterimTranscript, isRecording, useWebSpeech])
|
||||
|
||||
// Update the ref whenever stopRecording changes
|
||||
useEffect(() => {
|
||||
stopRecordingRef.current = stopRecording
|
||||
}, [stopRecording])
|
||||
|
||||
// Debug logging to track component lifecycle
|
||||
// Removed excessive debug logging
|
||||
|
||||
// Update shape state when recording/transcribing state changes
|
||||
useEffect(() => {
|
||||
const cleanProps = {
|
||||
...shape.props,
|
||||
isTranscribing: hookIsTranscribing || isRecording
|
||||
}
|
||||
|
||||
// Remove any undefined or null values that might cause validation issues
|
||||
Object.keys(cleanProps).forEach(key => {
|
||||
if ((cleanProps as any)[key] === undefined || (cleanProps as any)[key] === null) {
|
||||
delete (cleanProps as any)[key]
|
||||
}
|
||||
})
|
||||
|
||||
// Only update if the state actually changed
|
||||
if (cleanProps.isTranscribing !== shape.props.isTranscribing) {
|
||||
// Update the shape state
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: cleanProps
|
||||
})
|
||||
|
||||
console.log(`🔄 Updated shape state: isTranscribing=${cleanProps.isTranscribing}, hookIsTranscribing=${hookIsTranscribing}, isRecording=${isRecording}`)
|
||||
}
|
||||
}, [hookIsTranscribing, isRecording, shape.id]) // Removed shape.props from dependencies
|
||||
|
||||
// Listen for custom start-transcription event from the tool
|
||||
useEffect(() => {
|
||||
const handleStartTranscriptionEvent = (event: CustomEvent) => {
|
||||
if (event.detail?.shapeId === shape.id) {
|
||||
// Only start if not already transcribing
|
||||
if (!hookIsTranscribing) {
|
||||
handleTranscriptionToggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('start-transcription', handleStartTranscriptionEvent as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('start-transcription', handleStartTranscriptionEvent as EventListener)
|
||||
}
|
||||
}, [shape.id, hookIsTranscribing])
|
||||
|
||||
// Cleanup transcription when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isMountedRef.current) {
|
||||
// Removed debug logging
|
||||
isMountedRef.current = false
|
||||
if (isRecording && stopRecordingRef.current) {
|
||||
stopRecordingRef.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []) // Empty dependency array - only run on actual unmount
|
||||
|
||||
// Prevent unnecessary remounting by stabilizing the component
|
||||
useEffect(() => {
|
||||
// This effect helps prevent the component from remounting unnecessarily
|
||||
// Removed debug logging
|
||||
isMountedRef.current = true
|
||||
}, [shape.id])
|
||||
|
||||
// Update recording duration when recording is active (not transcribing)
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null
|
||||
|
||||
if (isRecording && !isPaused) {
|
||||
interval = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1)
|
||||
}, 1000)
|
||||
} else {
|
||||
setRecordingDuration(0)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
}, [isRecording, isPaused])
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditingContent(text)
|
||||
const currentText = text || ''
|
||||
setEditingContent(currentText)
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: "Transcription",
|
||||
props: {
|
||||
...shape.props,
|
||||
isEditing: true,
|
||||
editingContent: text,
|
||||
editingContent: currentText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
// Get fresh shape reference to ensure we have the latest state
|
||||
const currentShape = this.editor.getShape(shape.id) as ITranscription
|
||||
if (!currentShape) {
|
||||
console.error('Shape not found when saving')
|
||||
return
|
||||
}
|
||||
|
||||
// Use the latest editingContent state value
|
||||
const contentToSave = editingContent
|
||||
|
||||
// Clean the props to ensure only valid properties are passed
|
||||
const cleanProps = {
|
||||
...currentShape.props,
|
||||
isEditing: false,
|
||||
text: contentToSave,
|
||||
// Remove any invalid properties that might cause validation errors
|
||||
editingContent: undefined,
|
||||
}
|
||||
|
||||
// Remove any undefined or null values that might cause validation issues
|
||||
Object.keys(cleanProps).forEach(key => {
|
||||
if ((cleanProps as any)[key] === undefined || (cleanProps as any)[key] === null) {
|
||||
delete (cleanProps as any)[key]
|
||||
}
|
||||
})
|
||||
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
id: currentShape.id,
|
||||
type: "Transcription",
|
||||
props: {
|
||||
...shape.props,
|
||||
isEditing: false,
|
||||
text: editingContent,
|
||||
editingContent: undefined,
|
||||
},
|
||||
props: cleanProps,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
// Clean the props to ensure only valid properties are passed
|
||||
const cleanProps = {
|
||||
...shape.props,
|
||||
isEditing: false,
|
||||
// Remove any invalid properties that might cause validation errors
|
||||
editingContent: undefined,
|
||||
}
|
||||
|
||||
// Remove any undefined or null values that might cause validation issues
|
||||
Object.keys(cleanProps).forEach(key => {
|
||||
if ((cleanProps as any)[key] === undefined || (cleanProps as any)[key] === null) {
|
||||
delete (cleanProps as any)[key]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: "Transcription",
|
||||
props: {
|
||||
...shape.props,
|
||||
isEditing: false,
|
||||
editingContent: undefined,
|
||||
},
|
||||
props: cleanProps,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -183,36 +435,80 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
}
|
||||
}
|
||||
|
||||
const handleTranscriptionToggle = async () => {
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
// Prevent the wheel event from bubbling up to the Tldraw canvas
|
||||
e.stopPropagation()
|
||||
// The default scroll behavior will handle the actual scrolling
|
||||
}
|
||||
|
||||
const handleTranscriptionToggle = useCallback(async () => {
|
||||
try {
|
||||
if (isTranscribing && !isPaused) {
|
||||
// Currently transcribing, pause it
|
||||
console.log('⏸️ Pausing transcription...')
|
||||
pauseTranscription()
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
isPaused: true
|
||||
}
|
||||
})
|
||||
} else if (isTranscribing && isPaused) {
|
||||
// Currently paused, resume it
|
||||
console.log('▶️ Resuming transcription...')
|
||||
startTranscription()
|
||||
if (isRecording) {
|
||||
// Currently recording, stop it
|
||||
console.log('🛑 Stopping transcription...')
|
||||
stopRecording()
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
isTranscribing: false,
|
||||
isPaused: false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Not transcribing, start it
|
||||
console.log('🎤 Starting transcription...')
|
||||
startTranscription()
|
||||
// Not recording, start it (or resume if paused)
|
||||
if (isPaused) {
|
||||
console.log('▶️ Resuming transcription...')
|
||||
startRecording()
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
isTranscribing: true,
|
||||
isPaused: false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('🎤 Starting transcription...')
|
||||
|
||||
// Clear editing content and live edit transcript when starting new recording session
|
||||
if (isLiveEditing) {
|
||||
setEditingContent('')
|
||||
setLiveEditTranscript('')
|
||||
}
|
||||
|
||||
startRecording()
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
isTranscribing: true,
|
||||
isPaused: false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Transcription toggle error:', error)
|
||||
}
|
||||
}, [isRecording, isPaused, stopRecording, startRecording, shape.id, shape.props, isLiveEditing])
|
||||
|
||||
const handlePauseToggle = useCallback(async () => {
|
||||
try {
|
||||
if (isPaused) {
|
||||
// Currently paused, resume
|
||||
console.log('▶️ Resuming transcription...')
|
||||
if (useWebSpeech) {
|
||||
// For Web Speech, restart recording
|
||||
startRecording()
|
||||
} else if (pauseRecording) {
|
||||
// For Whisper, resume from pause (if supported)
|
||||
// Note: pauseRecording might not fully support resume, so we restart
|
||||
startRecording()
|
||||
}
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
|
|
@ -222,75 +518,55 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
isPaused: false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Currently recording, pause it
|
||||
console.log('⏸️ Pausing transcription...')
|
||||
if (useWebSpeech) {
|
||||
// For Web Speech, stop recording (pause not natively supported)
|
||||
stopRecording()
|
||||
} else if (pauseRecording) {
|
||||
await pauseRecording()
|
||||
}
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
isTranscribing: false,
|
||||
isPaused: true
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Transcription toggle error:', error)
|
||||
console.error('❌ Pause toggle error:', error)
|
||||
}
|
||||
}, [isPaused, useWebSpeech, pauseRecording, startRecording, stopRecording, shape.id, shape.props])
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handleStopTranscription = async () => {
|
||||
try {
|
||||
console.log('🛑 Stopping transcription...')
|
||||
stopTranscription()
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
isTranscribing: false,
|
||||
isPaused: false
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Stop transcription error:', error)
|
||||
}
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
width: w,
|
||||
height: h,
|
||||
backgroundColor: isHovering ? "#f8f9fa" : "white",
|
||||
border: isSelected ? '2px solid #007acc' : (isHovering ? "2px solid #007bff" : "1px solid #ccc"),
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
boxShadow: isSelected ? '0 0 0 2px #007acc' : '0 2px 4px rgba(0,0,0,0.1)',
|
||||
cursor: isSelected ? 'move' : 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.4",
|
||||
color: "black",
|
||||
transition: "all 0.2s ease",
|
||||
}
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
minHeight: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
overflow: 'hidden', // Let the inner elements handle scrolling
|
||||
color: 'black',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
cursor: isEditing ? 'text' : 'pointer',
|
||||
transition: 'background-color 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
minHeight: '60px',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
|
|
@ -299,11 +575,17 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
lineHeight: '1.4',
|
||||
color: 'black',
|
||||
backgroundColor: 'transparent',
|
||||
padding: 0,
|
||||
padding: '4px',
|
||||
margin: 0,
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
cursor: 'text',
|
||||
}
|
||||
|
||||
const editControlsStyle: React.CSSProperties = {
|
||||
|
|
@ -326,88 +608,77 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
cursor: 'pointer',
|
||||
zIndex: 1000,
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto', // Ensure button can receive clicks
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer
|
||||
style={wrapperStyle}
|
||||
onDoubleClick={handleStartEdit}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<div style={headerStyle}>
|
||||
<span>🎤 Transcription</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
background: isTranscribing
|
||||
? (isPaused ? "#ffa500" : "#ff4444")
|
||||
: "#007bff",
|
||||
color: "white",
|
||||
border: isTranscribing
|
||||
? (isPaused ? "1px solid #cc8400" : "1px solid #cc0000")
|
||||
: "1px solid #0056b3",
|
||||
}}
|
||||
onClick={handleTranscriptionToggle}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!isOpenAIConfiguredFlag}
|
||||
title={!isOpenAIConfiguredFlag ? "OpenAI API key not configured" : ""}
|
||||
>
|
||||
{isTranscribing
|
||||
? (isPaused ? "Resume" : "Pause")
|
||||
: "Start"}
|
||||
</button>
|
||||
{isTranscribing && (
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
background: "#dc3545",
|
||||
color: "white",
|
||||
border: "1px solid #c82333",
|
||||
}}
|
||||
onClick={handleStopTranscription}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Stop transcription"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={handleSaveEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={handleCancelEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
// Custom header content with status indicators and controls
|
||||
const headerContent = (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '8px' }}>
|
||||
<span>
|
||||
🎤 Transcription
|
||||
{!useWebSpeech && !modelLoaded && <span style={{color: '#ffa500', fontSize: '8px'}}>(Loading Model...)</span>}
|
||||
{useWebSpeech && !webSpeechSupported && <span style={{color: '#ff4444', fontSize: '8px'}}>(Web Speech Not Supported)</span>}
|
||||
{isRecording && !isPaused && (
|
||||
<span style={{color: '#ff4444', fontSize: '10px', marginLeft: '8px'}}>
|
||||
🔴 Recording {recordingDuration}s
|
||||
</span>
|
||||
)}
|
||||
{isPaused && (
|
||||
<span style={{color: '#ffa500', fontSize: '10px', marginLeft: '8px'}}>
|
||||
⏸️ Paused
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={handleSaveEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={handleCancelEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Transcription"
|
||||
primaryColor={TranscriptionShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
headerContent={headerContent}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
|
||||
<div style={contentStyle}>
|
||||
{isEditing ? (
|
||||
{isEditing || isLiveEditing ? (
|
||||
<AutoResizeTextarea
|
||||
value={editingContent}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleSaveEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={textareaStyle}
|
||||
placeholder="Transcription will appear here..."
|
||||
placeholder=""
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
|
@ -417,14 +688,85 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
cursor: "text",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "4px",
|
||||
boxSizing: "border-box",
|
||||
position: "relative",
|
||||
pointerEvents: "auto"
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onClick={handleStartEdit}
|
||||
title="Click to edit transcription"
|
||||
>
|
||||
{text || (isHovering ? "Double-click to edit transcription..." :
|
||||
isTranscribing ? "🎤 Listening... Speak now..." :
|
||||
"Click 'Start' to begin transcription...")}
|
||||
{displayText || ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div style={editControlsStyle}>
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
background: isRecording
|
||||
? "#ff4444" // Red when recording
|
||||
: isPaused
|
||||
? "#ffa500" // Orange when paused
|
||||
: (useWebSpeech ? webSpeechSupported : modelLoaded) ? "#007bff" : "#6c757d", // Blue when ready to start, gray when loading
|
||||
color: "white",
|
||||
border: isRecording
|
||||
? "1px solid #cc0000" // Red border when recording
|
||||
: isPaused
|
||||
? "1px solid #cc8500" // Orange border when paused
|
||||
: (useWebSpeech ? webSpeechSupported : modelLoaded) ? "1px solid #0056b3" : "1px solid #495057", // Blue border when ready, gray when loading
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (useWebSpeech ? webSpeechSupported : modelLoaded) {
|
||||
handleTranscriptionToggle()
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
disabled={useWebSpeech ? !webSpeechSupported : !modelLoaded}
|
||||
title={useWebSpeech ? (!webSpeechSupported ? "Web Speech API not supported" : "") : (!modelLoaded ? "Whisper model is loading - Please wait..." : "")}
|
||||
>
|
||||
{(() => {
|
||||
if (isPaused) {
|
||||
return "Resume"
|
||||
}
|
||||
const buttonText = isRecording
|
||||
? "Stop"
|
||||
: "Start"
|
||||
return buttonText
|
||||
})()}
|
||||
</button>
|
||||
{isRecording && !isPaused && (
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
background: "#ffa500",
|
||||
color: "white",
|
||||
border: "1px solid #cc8500",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handlePauseToggle()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
title="Pause transcription"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
||||
import { useEffect, useState } from "react"
|
||||
import { WORKER_URL } from "../routes/Board"
|
||||
import { WORKER_URL } from "../constants/workerUrl"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
interface DailyApiResponse {
|
||||
url: string;
|
||||
|
|
@ -28,6 +29,8 @@ export type IVideoChatShape = TLBaseShape<
|
|||
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||
static override type = "VideoChat"
|
||||
|
||||
// VideoChat theme color: Red (Rainbow)
|
||||
static readonly PRIMARY_COLOR = "#ef4444"
|
||||
|
||||
indicator(shape: IVideoChatShape) {
|
||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||
|
|
@ -325,8 +328,14 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
|
||||
|
||||
component(shape: IVideoChatShape) {
|
||||
// Ensure shape props exist with defaults
|
||||
const props = shape.props || {}
|
||||
const roomUrl = props.roomUrl || ""
|
||||
|
||||
const [hasPermissions, setHasPermissions] = useState(false)
|
||||
const [forceRender, setForceRender] = useState(0)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Force re-render function
|
||||
const forceComponentUpdate = () => {
|
||||
|
|
@ -335,7 +344,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [roomUrl, setRoomUrl] = useState<string | null>(shape.props.roomUrl)
|
||||
const [currentRoomUrl, setCurrentRoomUrl] = useState<string | null>(roomUrl)
|
||||
const [iframeError, setIframeError] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [useFallback, setUseFallback] = useState(false)
|
||||
|
|
@ -351,7 +360,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
// Get the updated shape after room creation
|
||||
const updatedShape = this.editor.getShape(shape.id);
|
||||
if (mounted && updatedShape) {
|
||||
setRoomUrl((updatedShape as IVideoChatShape).props.roomUrl);
|
||||
setCurrentRoomUrl((updatedShape as IVideoChatShape).props.roomUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
|
|
@ -406,7 +415,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
return <div>Error creating room: {error.message}</div>
|
||||
}
|
||||
|
||||
if (isLoading || !roomUrl || roomUrl === 'undefined') {
|
||||
if (isLoading || !currentRoomUrl || currentRoomUrl === 'undefined') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -425,8 +434,8 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
}
|
||||
|
||||
// Validate room URL format
|
||||
if (!roomUrl || !roomUrl.startsWith('http')) {
|
||||
console.error('Invalid room URL format:', roomUrl);
|
||||
if (!currentRoomUrl || !currentRoomUrl.startsWith('http')) {
|
||||
console.error('Invalid room URL format:', currentRoomUrl);
|
||||
return <div>Error: Invalid room URL format</div>;
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +446,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
// Try the original URL first, then add parameters if needed
|
||||
let roomUrlWithParams;
|
||||
try {
|
||||
roomUrlWithParams = new URL(roomUrl)
|
||||
roomUrlWithParams = new URL(currentRoomUrl)
|
||||
roomUrlWithParams.searchParams.set(
|
||||
"allow_camera",
|
||||
String(shape.props.allowCamera),
|
||||
|
|
@ -465,28 +474,50 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
}
|
||||
} catch (e) {
|
||||
console.error('Error constructing URL:', e);
|
||||
roomUrlWithParams = new URL(roomUrl);
|
||||
roomUrlWithParams = new URL(currentRoomUrl);
|
||||
}
|
||||
|
||||
// Note: Removed HEAD request test due to CORS issues with non-localhost IPs
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${shape.props.w}px`,
|
||||
height: `${shape.props.h + 40}px`, // Add extra height for URL bubble below
|
||||
position: "relative",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
>
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Video Chat"
|
||||
primaryColor={VideoChatShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h + 40} // Include space for URL bubble
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: "relative",
|
||||
pointerEvents: "all",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Video Container */}
|
||||
<div
|
||||
style={{
|
||||
width: `${shape.props.w}px`,
|
||||
height: `${shape.props.h}px`,
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
top: "0px", // No offset needed since button is positioned above
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
|
@ -534,7 +565,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
) : (
|
||||
<iframe
|
||||
key={`fallback-iframe-${retryCount}`}
|
||||
src={roomUrl}
|
||||
src={currentRoomUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
|
|
@ -673,10 +704,12 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
top: `${shape.props.h + 50}px`, // Position it below the iframe with proper spacing
|
||||
}}
|
||||
>
|
||||
url: {roomUrl}
|
||||
url: {currentRoomUrl}
|
||||
{shape.props.isOwner && " (Owner)"}
|
||||
</p>
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,7 +204,8 @@
|
|||
"com.tldraw.shape.container": 0,
|
||||
"com.tldraw.shape.element": 0,
|
||||
"com.tldraw.binding.arrow": 0,
|
||||
"com.tldraw.binding.layout": 0
|
||||
"com.tldraw.binding.layout": 0,
|
||||
"obsidian_vault": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { BaseBoxShapeTool } from "tldraw"
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
|
||||
|
||||
export class ChatBoxTool extends BaseBoxShapeTool {
|
||||
static override id = "ChatBox"
|
||||
shapeType = "ChatBox"
|
||||
override initial = "idle"
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { BaseBoxShapeTool } from "tldraw"
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
|
||||
|
||||
export class EmbedTool extends BaseBoxShapeTool {
|
||||
static override id = "Embed"
|
||||
shapeType = "Embed"
|
||||
override initial = "idle"
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class FathomMeetingsTool extends StateNode {
|
||||
static override id = "fathom-meetings"
|
||||
static override initial = "idle"
|
||||
static override children = () => [FathomMeetingsIdle]
|
||||
|
||||
onSelect() {
|
||||
// Don't create a shape immediately when tool is selected
|
||||
// The user will create one by clicking on the canvas (onPointerDown in idle state)
|
||||
console.log('🎯 FathomMeetingsTool parent: tool selected - waiting for user click')
|
||||
}
|
||||
}
|
||||
|
||||
export class FathomMeetingsIdle extends StateNode {
|
||||
static override id = "idle"
|
||||
|
||||
tooltipElement?: HTMLDivElement
|
||||
mouseMoveHandler?: (e: MouseEvent) => void
|
||||
|
||||
override onEnter = () => {
|
||||
// Set cursor to cross (looks like +)
|
||||
this.editor.setCursor({ type: "cross", rotation: 0 })
|
||||
|
||||
// Create tooltip element
|
||||
this.tooltipElement = document.createElement('div')
|
||||
this.tooltipElement.style.cssText = `
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`
|
||||
this.tooltipElement.textContent = 'Click anywhere to place tool'
|
||||
|
||||
// Add tooltip to DOM
|
||||
document.body.appendChild(this.tooltipElement)
|
||||
|
||||
// Function to update tooltip position
|
||||
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||
if (this.tooltipElement) {
|
||||
const x = e.clientX + 15
|
||||
const y = e.clientY - 35
|
||||
|
||||
// Keep tooltip within viewport bounds
|
||||
const rect = this.tooltipElement.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let finalX = x
|
||||
let finalY = y
|
||||
|
||||
// Adjust if tooltip would go off the right edge
|
||||
if (x + rect.width > viewportWidth) {
|
||||
finalX = e.clientX - rect.width - 15
|
||||
}
|
||||
|
||||
// Adjust if tooltip would go off the bottom edge
|
||||
if (y + rect.height > viewportHeight) {
|
||||
finalY = e.clientY - rect.height - 15
|
||||
}
|
||||
|
||||
// Ensure tooltip doesn't go off the top or left
|
||||
finalX = Math.max(10, finalX)
|
||||
finalY = Math.max(10, finalY)
|
||||
|
||||
this.tooltipElement.style.left = `${finalX}px`
|
||||
this.tooltipElement.style.top = `${finalY}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Add mouse move listener
|
||||
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||
}
|
||||
|
||||
override onPointerDown = (info?: any) => {
|
||||
console.log('📍 FathomMeetingsTool: onPointerDown called', { info, fullInfo: JSON.stringify(info) })
|
||||
|
||||
// CRITICAL: Only proceed if we have a valid pointer event with a point AND button
|
||||
// This prevents shapes from being created when tool is selected (without a click)
|
||||
// A real click will have both a point and a button property
|
||||
if (!info || !info.point || info.button === undefined) {
|
||||
console.warn('⚠️ FathomMeetingsTool: No valid pointer event (missing point or button) - not creating shape. This is expected when tool is first selected.', {
|
||||
hasInfo: !!info,
|
||||
hasPoint: !!info?.point,
|
||||
hasButton: info?.button !== undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Additional check: ensure this is a primary button click (left mouse button = 0)
|
||||
// This prevents accidental triggers from other pointer events
|
||||
if (info.button !== 0 && info.button !== undefined) {
|
||||
console.log('📍 FathomMeetingsTool: Non-primary button click, ignoring', { button: info.button })
|
||||
return
|
||||
}
|
||||
|
||||
// Get the click position in page coordinates
|
||||
// CRITICAL: Only use info.point - don't use fallback values that might be stale
|
||||
// This ensures we only create shapes on actual clicks, not when tool is selected
|
||||
let clickX: number | undefined
|
||||
let clickY: number | undefined
|
||||
|
||||
// Method 1: Use info.point (screen coordinates) and convert to page - this is the ONLY reliable source
|
||||
if (info.point) {
|
||||
try {
|
||||
const pagePoint = this.editor.screenToPage(info.point)
|
||||
clickX = pagePoint.x
|
||||
clickY = pagePoint.y
|
||||
console.log('📍 FathomMeetingsTool: Using info.point converted to page:', { screen: info.point, page: { x: clickX, y: clickY } })
|
||||
} catch (e) {
|
||||
console.error('📍 FathomMeetingsTool: Failed to convert info.point to page coordinates', e)
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Only create shape if we have valid click coordinates from info.point
|
||||
// Do NOT use fallback values (currentPagePoint/originPagePoint) as they may be stale
|
||||
// This prevents shapes from being created when tool is selected (without a click)
|
||||
if (clickX === undefined || clickY === undefined) {
|
||||
console.warn('⚠️ FathomMeetingsTool: No valid click position from info.point - not creating shape. This is expected when tool is first selected.')
|
||||
return
|
||||
}
|
||||
|
||||
// Additional validation: ensure coordinates are reasonable (not 0,0 or extreme values)
|
||||
// This catches cases where info.point might exist but has invalid default values
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const reasonableBounds = {
|
||||
minX: viewport.x - viewport.w * 2, // Allow some margin outside viewport
|
||||
maxX: viewport.x + viewport.w * 3,
|
||||
minY: viewport.y - viewport.h * 2,
|
||||
maxY: viewport.y + viewport.h * 3,
|
||||
}
|
||||
|
||||
if (clickX < reasonableBounds.minX || clickX > reasonableBounds.maxX ||
|
||||
clickY < reasonableBounds.minY || clickY > reasonableBounds.maxY) {
|
||||
console.warn('⚠️ FathomMeetingsTool: Click position outside reasonable bounds - not creating shape', {
|
||||
clickX,
|
||||
clickY,
|
||||
bounds: reasonableBounds
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new FathomMeetingsBrowser shape at the click location
|
||||
this.createFathomMeetingsBrowserShape(clickX, clickY)
|
||||
}
|
||||
|
||||
onSelect() {
|
||||
// Don't create a shape immediately when tool is selected
|
||||
// The user will create one by clicking on the canvas (onPointerDown)
|
||||
console.log('🎯 FathomMeetings tool selected - waiting for user click')
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.cleanupTooltip()
|
||||
}
|
||||
|
||||
private cleanupTooltip = () => {
|
||||
// Remove mouse move listener
|
||||
if (this.mouseMoveHandler) {
|
||||
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||
this.mouseMoveHandler = undefined
|
||||
}
|
||||
|
||||
// Remove tooltip element
|
||||
if (this.tooltipElement) {
|
||||
document.body.removeChild(this.tooltipElement)
|
||||
this.tooltipElement = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private createFathomMeetingsBrowserShape(clickX: number, clickY: number) {
|
||||
try {
|
||||
console.log('📍 FathomMeetingsTool: createFathomMeetingsBrowserShape called', { clickX, clickY })
|
||||
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
// Standardized size: 800x600
|
||||
const shapeWidth = 800
|
||||
const shapeHeight = 600
|
||||
|
||||
// Position new browser shape at click location (centered on click)
|
||||
const baseX = clickX - shapeWidth / 2 // Center the shape on click
|
||||
const baseY = clickY - shapeHeight / 2 // Center the shape on click
|
||||
console.log('📍 FathomMeetingsTool: Using click position:', { clickX, clickY, baseX, baseY })
|
||||
|
||||
// User clicked - ALWAYS use that exact position, no collision detection
|
||||
// This ensures the shape appears exactly where the user clicked
|
||||
const finalX = baseX
|
||||
const finalY = baseY
|
||||
console.log('📍 FathomMeetingsTool: Using click position directly (no collision check):', {
|
||||
clickPosition: { x: clickX, y: clickY },
|
||||
shapePosition: { x: finalX, y: finalY },
|
||||
shapeSize: { w: shapeWidth, h: shapeHeight }
|
||||
})
|
||||
|
||||
console.log('📍 FathomMeetingsTool: Final position for shape:', { finalX, finalY })
|
||||
|
||||
const browserShape = this.editor.createShape({
|
||||
type: 'FathomMeetingsBrowser',
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created FathomMeetingsBrowser shape:', browserShape.id)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = this.editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating FathomMeetingsBrowser shape:', error)
|
||||
throw error // Re-throw to see the full error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class FathomTranscriptTool extends StateNode {
|
||||
static override id = "fathom-transcript"
|
||||
static override initial = "idle"
|
||||
|
||||
onSelect() {
|
||||
// Create a new Fathom transcript shape
|
||||
this.createFathomTranscriptShape()
|
||||
}
|
||||
|
||||
onPointerDown() {
|
||||
// Create a new Fathom transcript shape at the click location
|
||||
this.createFathomTranscriptShape()
|
||||
}
|
||||
|
||||
private createFathomTranscriptShape() {
|
||||
try {
|
||||
// Get the current viewport center
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
|
||||
// Base position (centered on viewport)
|
||||
const baseX = centerX - 300 // Center the 600px wide shape
|
||||
const baseY = centerY - 200 // Center the 400px tall shape
|
||||
|
||||
// Find a non-overlapping position
|
||||
const shapeWidth = 600
|
||||
const shapeHeight = 400
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
)
|
||||
|
||||
const fathomShape = this.editor.createShape({
|
||||
type: 'FathomTranscript',
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
props: {
|
||||
w: 600,
|
||||
h: 400,
|
||||
meetingId: '',
|
||||
meetingTitle: 'New Fathom Meeting',
|
||||
meetingUrl: '',
|
||||
summary: '',
|
||||
transcript: [],
|
||||
actionItems: [],
|
||||
isExpanded: false,
|
||||
showTranscript: true,
|
||||
showActionItems: true,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created Fathom transcript shape:', fathomShape.id)
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
this.editor.setSelectedShapes([`shape:${fathomShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Fathom transcript shape:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||
import { holosphereService } from "@/lib/HoloSphereService"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class HolonTool extends StateNode {
|
||||
static override id = "holon"
|
||||
static override initial = "idle"
|
||||
static override children = () => [HolonIdle]
|
||||
}
|
||||
|
||||
export class HolonIdle extends StateNode {
|
||||
static override id = "idle"
|
||||
|
||||
tooltipElement?: HTMLDivElement
|
||||
mouseMoveHandler?: (e: MouseEvent) => void
|
||||
|
||||
override onEnter = () => {
|
||||
// Set cursor to cross (looks like +)
|
||||
this.editor.setCursor({ type: "cross", rotation: 0 })
|
||||
|
||||
// Create tooltip element
|
||||
this.tooltipElement = document.createElement('div')
|
||||
this.tooltipElement.style.cssText = `
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`
|
||||
this.tooltipElement.textContent = 'Click anywhere to place tool'
|
||||
|
||||
// Add tooltip to DOM
|
||||
document.body.appendChild(this.tooltipElement)
|
||||
|
||||
// Function to update tooltip position
|
||||
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||
if (this.tooltipElement) {
|
||||
const x = e.clientX + 15
|
||||
const y = e.clientY - 35
|
||||
|
||||
// Keep tooltip within viewport bounds
|
||||
const rect = this.tooltipElement.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let finalX = x
|
||||
let finalY = y
|
||||
|
||||
// Adjust if tooltip would go off the right edge
|
||||
if (x + rect.width > viewportWidth) {
|
||||
finalX = e.clientX - rect.width - 15
|
||||
}
|
||||
|
||||
// Adjust if tooltip would go off the bottom edge
|
||||
if (y + rect.height > viewportHeight) {
|
||||
finalY = e.clientY - rect.height - 15
|
||||
}
|
||||
|
||||
// Ensure tooltip doesn't go off the top or left
|
||||
finalX = Math.max(10, finalX)
|
||||
finalY = Math.max(10, finalY)
|
||||
|
||||
this.tooltipElement.style.left = `${finalX}px`
|
||||
this.tooltipElement.style.top = `${finalY}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Add mouse move listener
|
||||
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||
}
|
||||
|
||||
override onPointerDown = (info?: any) => {
|
||||
// Get the click position in page coordinates
|
||||
// Try multiple methods to ensure we get the correct click position
|
||||
let clickX: number | undefined
|
||||
let clickY: number | undefined
|
||||
|
||||
// Method 1: Try info.point (screen coordinates) and convert to page
|
||||
if (info?.point) {
|
||||
try {
|
||||
const pagePoint = this.editor.screenToPage(info.point)
|
||||
clickX = pagePoint.x
|
||||
clickY = pagePoint.y
|
||||
console.log('📍 HolonTool: Method 1 - info.point converted:', { screen: info.point, page: { x: clickX, y: clickY } })
|
||||
} catch (e) {
|
||||
console.log('📍 HolonTool: Failed to convert info.point, trying other methods')
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Use currentPagePoint from editor inputs (most reliable)
|
||||
if (clickX === undefined || clickY === undefined) {
|
||||
const { currentPagePoint } = this.editor.inputs
|
||||
if (currentPagePoint && currentPagePoint.x !== undefined && currentPagePoint.y !== undefined) {
|
||||
clickX = currentPagePoint.x
|
||||
clickY = currentPagePoint.y
|
||||
console.log('📍 HolonTool: Method 2 - currentPagePoint:', { x: clickX, y: clickY })
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Try originPagePoint as last resort
|
||||
if (clickX === undefined || clickY === undefined) {
|
||||
const { originPagePoint } = this.editor.inputs
|
||||
if (originPagePoint && originPagePoint.x !== undefined && originPagePoint.y !== undefined) {
|
||||
clickX = originPagePoint.x
|
||||
clickY = originPagePoint.y
|
||||
console.log('📍 HolonTool: Method 3 - originPagePoint:', { x: clickX, y: clickY })
|
||||
}
|
||||
}
|
||||
|
||||
if (clickX === undefined || clickY === undefined) {
|
||||
console.error('❌ HolonTool: Could not determine click position!', { info, inputs: this.editor.inputs })
|
||||
}
|
||||
|
||||
// Create a new Holon shape at the click location
|
||||
this.createHolonShape(clickX, clickY)
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.cleanupTooltip()
|
||||
}
|
||||
|
||||
private cleanupTooltip = () => {
|
||||
// Remove mouse move listener
|
||||
if (this.mouseMoveHandler) {
|
||||
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||
this.mouseMoveHandler = undefined
|
||||
}
|
||||
|
||||
// Remove tooltip element
|
||||
if (this.tooltipElement) {
|
||||
document.body.removeChild(this.tooltipElement)
|
||||
this.tooltipElement = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private createHolonShape(clickX?: number, clickY?: number) {
|
||||
try {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
// Standardized size: 700x400 (matches default props to fit ID and button)
|
||||
const shapeWidth = 700
|
||||
const shapeHeight = 400
|
||||
|
||||
// Use click position if available, otherwise fall back to viewport center
|
||||
let baseX: number
|
||||
let baseY: number
|
||||
|
||||
if (clickX !== undefined && clickY !== undefined) {
|
||||
// Position new Holon shape at click location (centered on click)
|
||||
baseX = clickX - shapeWidth / 2 // Center the shape on click
|
||||
baseY = clickY - shapeHeight / 2 // Center the shape on click
|
||||
console.log('📍 HolonTool: Calculated base position from click:', { clickX, clickY, baseX, baseY, shapeWidth, shapeHeight })
|
||||
} else {
|
||||
// Fallback to viewport center if no click coordinates
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
baseX = centerX - shapeWidth / 2 // Center the shape
|
||||
baseY = centerY - shapeHeight / 2 // Center the shape
|
||||
}
|
||||
|
||||
// Find existing Holon shapes for naming
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const existingHolonShapes = allShapes.filter(s => s.type === 'Holon')
|
||||
|
||||
// ALWAYS use click position directly when provided - user clicked where they want it
|
||||
// Skip collision detection entirely for user clicks to ensure it appears exactly where clicked
|
||||
let finalX = baseX
|
||||
let finalY = baseY
|
||||
|
||||
if (clickX !== undefined && clickY !== undefined) {
|
||||
// User clicked - ALWAYS use that exact position, no collision detection
|
||||
// This ensures the shape appears exactly where the user clicked
|
||||
finalX = baseX
|
||||
finalY = baseY
|
||||
console.log('📍 Using click position directly (no collision check):', {
|
||||
clickPosition: { x: clickX, y: clickY },
|
||||
shapePosition: { x: finalX, y: finalY },
|
||||
shapeSize: { w: shapeWidth, h: shapeHeight }
|
||||
})
|
||||
} else {
|
||||
// For fallback (no click), use collision detection
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
)
|
||||
finalX = position.x
|
||||
finalY = position.y
|
||||
console.log('📍 No click position - using collision detection:', { finalX, finalY })
|
||||
}
|
||||
|
||||
// Default coordinates (can be changed by user)
|
||||
const defaultLat = 40.7128 // NYC
|
||||
const defaultLng = -74.0060
|
||||
const defaultResolution = 7 // City level
|
||||
|
||||
console.log('📍 HolonTool: Final position for shape:', { finalX, finalY, wasOverlap: clickX !== undefined && clickY !== undefined && (finalX !== baseX || finalY !== baseY) })
|
||||
|
||||
const holonShape = this.editor.createShape({
|
||||
type: 'Holon',
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
name: `Holon ${existingHolonShapes.length + 1}`,
|
||||
description: '',
|
||||
latitude: defaultLat,
|
||||
longitude: defaultLng,
|
||||
resolution: defaultResolution,
|
||||
holonId: '',
|
||||
isConnected: false,
|
||||
isEditing: true,
|
||||
selectedLens: 'general',
|
||||
data: {},
|
||||
connections: [],
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created Holon shape:', holonShape.id)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = this.editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([`shape:${holonShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Holon shape:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onSelect() {
|
||||
// Check if there are existing Holon shapes on the canvas
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const holonShapes = allShapes.filter(shape => shape.type === 'Holon')
|
||||
|
||||
if (holonShapes.length > 0) {
|
||||
// If Holon shapes exist, select them and center the view
|
||||
this.editor.setSelectedShapes(holonShapes.map(shape => shape.id))
|
||||
this.editor.zoomToFit()
|
||||
console.log('🎯 Holon tool selected - showing existing Holon shapes:', holonShapes.length)
|
||||
|
||||
// Add refresh all functionality
|
||||
this.addRefreshAllListener()
|
||||
} else {
|
||||
// If no Holon shapes exist, don't automatically create one
|
||||
// The user will create one by clicking on the canvas (onPointerDown)
|
||||
console.log('🎯 Holon tool selected - no Holon shapes found, waiting for user interaction')
|
||||
}
|
||||
}
|
||||
|
||||
private createHolonShape(clickX?: number, clickY?: number) {
|
||||
try {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
// Standardized size: 700x400 (matches default props to fit ID and button)
|
||||
const shapeWidth = 700
|
||||
const shapeHeight = 400
|
||||
|
||||
// Use click position if available, otherwise fall back to viewport center
|
||||
let baseX: number
|
||||
let baseY: number
|
||||
|
||||
if (clickX !== undefined && clickY !== undefined) {
|
||||
// Position new Holon shape at click location (centered on click)
|
||||
baseX = clickX - shapeWidth / 2 // Center the shape on click
|
||||
baseY = clickY - shapeHeight / 2 // Center the shape on click
|
||||
console.log('📍 HolonTool: Calculated base position from click:', { clickX, clickY, baseX, baseY, shapeWidth, shapeHeight })
|
||||
} else {
|
||||
// Fallback to viewport center if no click coordinates
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
baseX = centerX - shapeWidth / 2 // Center the shape
|
||||
baseY = centerY - shapeHeight / 2 // Center the shape
|
||||
}
|
||||
|
||||
// Find existing Holon shapes for naming
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const existingHolonShapes = allShapes.filter(s => s.type === 'Holon')
|
||||
|
||||
// ALWAYS use click position directly when provided - user clicked where they want it
|
||||
// Skip collision detection entirely for user clicks to ensure it appears exactly where clicked
|
||||
let finalX = baseX
|
||||
let finalY = baseY
|
||||
|
||||
if (clickX !== undefined && clickY !== undefined) {
|
||||
// User clicked - ALWAYS use that exact position, no collision detection
|
||||
// This ensures the shape appears exactly where the user clicked
|
||||
finalX = baseX
|
||||
finalY = baseY
|
||||
console.log('📍 Using click position directly (no collision check):', {
|
||||
clickPosition: { x: clickX, y: clickY },
|
||||
shapePosition: { x: finalX, y: finalY },
|
||||
shapeSize: { w: shapeWidth, h: shapeHeight }
|
||||
})
|
||||
} else {
|
||||
// For fallback (no click), use collision detection
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
)
|
||||
finalX = position.x
|
||||
finalY = position.y
|
||||
console.log('📍 No click position - using collision detection:', { finalX, finalY })
|
||||
}
|
||||
|
||||
// Default coordinates (can be changed by user)
|
||||
const defaultLat = 40.7128 // NYC
|
||||
const defaultLng = -74.0060
|
||||
const defaultResolution = 7 // City level
|
||||
|
||||
console.log('📍 HolonTool: Final position for shape:', { finalX, finalY, wasOverlap: clickX !== undefined && clickY !== undefined && (finalX !== baseX || finalY !== baseY) })
|
||||
|
||||
const holonShape = this.editor.createShape({
|
||||
type: 'Holon',
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
name: `Holon ${existingHolonShapes.length + 1}`,
|
||||
description: '',
|
||||
latitude: defaultLat,
|
||||
longitude: defaultLng,
|
||||
resolution: defaultResolution,
|
||||
holonId: '',
|
||||
isConnected: false,
|
||||
isEditing: true,
|
||||
selectedLens: 'general',
|
||||
data: {},
|
||||
connections: [],
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created Holon shape:', holonShape.id)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = this.editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([`shape:${holonShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Holon shape:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private addRefreshAllListener() {
|
||||
// Listen for refresh-all-holons event
|
||||
const handleRefreshAll = async () => {
|
||||
console.log('🔄 Refreshing all Holon shapes...')
|
||||
const shapeUtil = new HolonShape(this.editor)
|
||||
shapeUtil.editor = this.editor
|
||||
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const holonShapes = allShapes.filter(shape => shape.type === 'Holon')
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const shape of holonShapes) {
|
||||
try {
|
||||
// Trigger a refresh for each Holon shape
|
||||
const event = new CustomEvent('refresh-holon', {
|
||||
detail: { shapeId: shape.id }
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to refresh Holon ${shape.id}:`, error)
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
alert(`✅ Refreshed ${successCount} Holon shapes!${failCount > 0 ? ` (${failCount} failed)` : ''}`)
|
||||
} else {
|
||||
alert('❌ Failed to refresh any Holon shapes. Check console for details.')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('refresh-all-holons', handleRefreshAll)
|
||||
|
||||
// Clean up listener when tool is deselected
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('refresh-all-holons', handleRefreshAll)
|
||||
}
|
||||
|
||||
// Store cleanup function for later use
|
||||
;(this as any).cleanup = cleanup
|
||||
}
|
||||
|
||||
onExit() {
|
||||
// Clean up event listeners
|
||||
if ((this as any).cleanup) {
|
||||
;(this as any).cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { BaseBoxShapeTool } from "tldraw"
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
|
||||
|
||||
export class MarkdownTool extends BaseBoxShapeTool {
|
||||
static override id = "Markdown"
|
||||
shapeType = "Markdown"
|
||||
override initial = "idle"
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { BaseBoxShapeTool } from "tldraw"
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
|
||||
|
||||
export class MycrozineTemplateTool extends BaseBoxShapeTool {
|
||||
static override id = "MycrozineTemplate"
|
||||
shapeType = "MycrozineTemplate"
|
||||
override initial = "idle"
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class ObsNoteTool extends StateNode {
|
||||
static override id = "obs_note"
|
||||
static override initial = "idle"
|
||||
static override children = () => [ObsNoteIdle]
|
||||
|
||||
onSelect() {
|
||||
// Check if there are existing ObsNote shapes on the canvas
|
||||
|
|
@ -14,30 +16,176 @@ export class ObsNoteTool extends StateNode {
|
|||
// If ObsNote shapes exist, select them and center the view
|
||||
this.editor.setSelectedShapes(obsNoteShapes.map(shape => shape.id))
|
||||
this.editor.zoomToFit()
|
||||
console.log('🎯 Tool selected - showing existing ObsNote shapes:', obsNoteShapes.length)
|
||||
|
||||
// Add refresh all functionality
|
||||
this.addRefreshAllListener()
|
||||
} else {
|
||||
// If no ObsNote shapes exist, don't automatically open vault browser
|
||||
// The vault browser will open when the user clicks on the canvas (onPointerDown)
|
||||
console.log('🎯 Tool selected - no ObsNote shapes found, waiting for user interaction')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ObsNoteIdle extends StateNode {
|
||||
static override id = "idle"
|
||||
|
||||
tooltipElement?: HTMLDivElement
|
||||
mouseMoveHandler?: (e: MouseEvent) => void
|
||||
|
||||
override onEnter = () => {
|
||||
// Set cursor to cross (looks like +)
|
||||
this.editor.setCursor({ type: "cross", rotation: 0 })
|
||||
|
||||
// Create tooltip element
|
||||
this.tooltipElement = document.createElement('div')
|
||||
this.tooltipElement.style.cssText = `
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`
|
||||
this.tooltipElement.textContent = 'Click anywhere to place tool'
|
||||
|
||||
// Add tooltip to DOM
|
||||
document.body.appendChild(this.tooltipElement)
|
||||
|
||||
// Function to update tooltip position
|
||||
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||
if (this.tooltipElement) {
|
||||
const x = e.clientX + 15
|
||||
const y = e.clientY - 35
|
||||
|
||||
// Keep tooltip within viewport bounds
|
||||
const rect = this.tooltipElement.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let finalX = x
|
||||
let finalY = y
|
||||
|
||||
// Adjust if tooltip would go off the right edge
|
||||
if (x + rect.width > viewportWidth) {
|
||||
finalX = e.clientX - rect.width - 15
|
||||
}
|
||||
|
||||
// Adjust if tooltip would go off the bottom edge
|
||||
if (y + rect.height > viewportHeight) {
|
||||
finalY = e.clientY - rect.height - 15
|
||||
}
|
||||
|
||||
// Ensure tooltip doesn't go off the top or left
|
||||
finalX = Math.max(10, finalX)
|
||||
finalY = Math.max(10, finalY)
|
||||
|
||||
this.tooltipElement.style.left = `${finalX}px`
|
||||
this.tooltipElement.style.top = `${finalY}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Add mouse move listener
|
||||
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||
}
|
||||
|
||||
override onPointerDown = () => {
|
||||
// Get the click position in page coordinates
|
||||
const { currentPagePoint } = this.editor.inputs
|
||||
|
||||
// Create an ObsidianBrowser shape on the canvas at the click location
|
||||
this.createObsidianBrowserShape(currentPagePoint.x, currentPagePoint.y)
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.cleanupTooltip()
|
||||
}
|
||||
|
||||
private cleanupTooltip = () => {
|
||||
// Remove mouse move listener
|
||||
if (this.mouseMoveHandler) {
|
||||
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||
this.mouseMoveHandler = undefined
|
||||
}
|
||||
|
||||
// Remove tooltip element
|
||||
if (this.tooltipElement) {
|
||||
document.body.removeChild(this.tooltipElement)
|
||||
this.tooltipElement = undefined
|
||||
}
|
||||
}
|
||||
|
||||
onPointerDown() {
|
||||
// Open vault browser to select notes
|
||||
const event = new CustomEvent('open-obsidian-browser')
|
||||
window.dispatchEvent(event)
|
||||
|
||||
// Don't create any shapes - just open the vault browser
|
||||
return
|
||||
private createObsidianBrowserShape(clickX?: number, clickY?: number) {
|
||||
try {
|
||||
// Check if ObsidianBrowser already exists
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const existingBrowserShapes = allShapes.filter(shape => shape.type === 'ObsidianBrowser')
|
||||
|
||||
if (existingBrowserShapes.length > 0) {
|
||||
// If a browser already exists, just select it
|
||||
console.log('✅ ObsidianBrowser already exists, selecting it')
|
||||
this.editor.setSelectedShapes([existingBrowserShapes[0].id])
|
||||
this.editor.setCurrentTool('select')
|
||||
return
|
||||
}
|
||||
|
||||
// No existing browser, create a new one
|
||||
// Standardized size: 800x600
|
||||
const shapeWidth = 800
|
||||
const shapeHeight = 600
|
||||
|
||||
let finalX: number
|
||||
let finalY: number
|
||||
|
||||
if (clickX !== undefined && clickY !== undefined) {
|
||||
// User clicked - ALWAYS use that exact position (centered on click), no collision detection
|
||||
// This ensures the shape appears exactly where the user clicked, regardless of overlaps
|
||||
finalX = clickX - shapeWidth / 2 // Center the shape on click
|
||||
finalY = clickY - shapeHeight / 2 // Center the shape on click
|
||||
} else {
|
||||
// Fallback to viewport center if no click coordinates, with collision detection
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
const baseX = centerX - shapeWidth / 2
|
||||
const baseY = centerY - shapeHeight / 2
|
||||
|
||||
// Use collision detection for fallback case
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
)
|
||||
finalX = position.x
|
||||
finalY = position.y
|
||||
}
|
||||
|
||||
const browserShape = this.editor.createShape({
|
||||
type: 'ObsidianBrowser',
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
}
|
||||
})
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
this.editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating ObsidianBrowser shape:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private addRefreshAllListener() {
|
||||
// Listen for refresh-all-obsnotes event
|
||||
const handleRefreshAll = async () => {
|
||||
console.log('🔄 Refreshing all ObsNote shapes from vault...')
|
||||
const shapeUtil = new ObsNoteShape(this.editor)
|
||||
shapeUtil.editor = this.editor
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { BaseBoxShapeTool } from 'tldraw'
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from 'tldraw'
|
||||
|
||||
export class PromptShapeTool extends BaseBoxShapeTool {
|
||||
static override id = 'Prompt'
|
||||
static override initial = 'idle'
|
||||
override shapeType = 'Prompt'
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { BaseBoxShapeTool } from "tldraw"
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
|
||||
|
||||
export class SharedPianoTool extends BaseBoxShapeTool {
|
||||
static override id = "SharedPiano"
|
||||
shapeType = "SharedPiano"
|
||||
override initial = "idle"
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseBoxShapeTool } from 'tldraw'
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from 'tldraw'
|
||||
|
||||
export class SlideShapeTool extends BaseBoxShapeTool {
|
||||
static override id = 'Slide'
|
||||
|
|
@ -9,4 +9,8 @@ export class SlideShapeTool extends BaseBoxShapeTool {
|
|||
super(editor)
|
||||
//console.log('SlideShapeTool constructed', { id: this.id, shapeType: this.shapeType })
|
||||
}
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
|
||||
import { useWhisperTranscription } from "@/hooks/useWhisperTranscription"
|
||||
import { getOpenAIConfig, isOpenAIConfigured } from "@/lib/clientConfig"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class TranscriptionTool extends StateNode {
|
||||
static override id = "transcription"
|
||||
|
|
@ -13,10 +13,25 @@ export class TranscriptionTool extends StateNode {
|
|||
const transcriptionShapes = allShapes.filter(shape => shape.type === 'Transcription')
|
||||
|
||||
if (transcriptionShapes.length > 0) {
|
||||
// If Transcription shapes exist, select them and center the view
|
||||
this.editor.setSelectedShapes(transcriptionShapes.map(shape => `shape:${shape.id}`) as any)
|
||||
// If Transcription shapes exist, start whisper audio processing on the first one
|
||||
const firstTranscriptionShape = transcriptionShapes[0]
|
||||
console.log('🎯 Transcription tool selected - starting whisper audio processing on existing shape:', firstTranscriptionShape.id)
|
||||
|
||||
// Select the first transcription shape
|
||||
this.editor.setSelectedShapes([`shape:${firstTranscriptionShape.id}`] as any)
|
||||
|
||||
// Trigger the start transcription by dispatching a custom event
|
||||
// This will be caught by the TranscriptionShape component
|
||||
const startTranscriptionEvent = new CustomEvent('start-transcription', {
|
||||
detail: { shapeId: firstTranscriptionShape.id }
|
||||
})
|
||||
window.dispatchEvent(startTranscriptionEvent)
|
||||
|
||||
// Center the view on the transcription shape
|
||||
this.editor.zoomToFit()
|
||||
console.log('🎯 Transcription tool selected - showing existing Transcription shapes:', transcriptionShapes.length)
|
||||
|
||||
// Switch back to select tool
|
||||
this.editor.setCurrentTool('select')
|
||||
} else {
|
||||
// If no Transcription shapes exist, create a new one
|
||||
console.log('🎯 Transcription tool selected - creating new Transcription shape')
|
||||
|
|
@ -25,42 +40,63 @@ export class TranscriptionTool extends StateNode {
|
|||
}
|
||||
|
||||
onPointerDown() {
|
||||
// Get the click position in page coordinates
|
||||
const { currentPagePoint } = this.editor.inputs
|
||||
|
||||
// Create a new transcription shape at the click location
|
||||
this.createTranscriptionShape()
|
||||
this.createTranscriptionShape(currentPagePoint.x, currentPagePoint.y)
|
||||
}
|
||||
|
||||
private createTranscriptionShape() {
|
||||
private createTranscriptionShape(clickX?: number, clickY?: number) {
|
||||
try {
|
||||
// Get the current viewport center
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
// Use click position if provided, otherwise use viewport center
|
||||
let baseX: number
|
||||
let baseY: number
|
||||
|
||||
// Find existing transcription shapes to calculate stacking position
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const existingTranscriptionShapes = allShapes.filter(s => s.type === 'Transcription')
|
||||
if (clickX !== undefined && clickY !== undefined) {
|
||||
// Position at click location, centered on the cursor
|
||||
baseX = clickX - 200 // Center the 400px wide shape
|
||||
baseY = clickY - 100 // Center the 200px tall shape
|
||||
} else {
|
||||
// Fallback to viewport center (for onSelect case)
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
baseX = centerX - 200
|
||||
baseY = centerY - 100
|
||||
}
|
||||
|
||||
// Position new transcription shape
|
||||
const xPosition = centerX - 200 // Center the 400px wide shape
|
||||
const yPosition = centerY - 100 + (existingTranscriptionShapes.length * 250) // Stack vertically
|
||||
// Find a non-overlapping position
|
||||
const shapeWidth = 400
|
||||
const shapeHeight = 200
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
)
|
||||
|
||||
const transcriptionShape = this.editor.createShape({
|
||||
type: 'Transcription',
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
props: {
|
||||
w: 400,
|
||||
h: 200,
|
||||
text: '🎤 Transcription Ready\n\nClick the "Start Transcription" button to begin...',
|
||||
isEditing: false
|
||||
text: '',
|
||||
isEditing: false,
|
||||
isTranscribing: false,
|
||||
isPaused: false
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created transcription shape:', transcriptionShape.id)
|
||||
|
||||
// Select the new shape
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
this.editor.setSelectedShapes([`shape:${transcriptionShape.id}`] as any)
|
||||
|
||||
this.editor.setCurrentTool('select')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating transcription shape:', error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { BaseBoxShapeTool } from "tldraw"
|
||||
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
|
||||
|
||||
export class VideoChatTool extends BaseBoxShapeTool {
|
||||
static override id = "VideoChat"
|
||||
shapeType = "VideoChat"
|
||||
override initial = "idle"
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export const overrides: TLUiOverrides = {
|
|||
id: "gesture",
|
||||
name: "Gesture",
|
||||
icon: "👆",
|
||||
kbd: "g",
|
||||
label: "Gesture",
|
||||
onSelect: () => {
|
||||
editor.setCurrentTool("gesture")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
TLGeoShape,
|
||||
TLShape,
|
||||
useDefaultHelpers,
|
||||
useActions,
|
||||
} from "tldraw"
|
||||
import { TldrawUiMenuGroup } from "tldraw"
|
||||
import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw"
|
||||
|
|
@ -36,6 +37,7 @@ const getAllFrames = (editor: Editor) => {
|
|||
export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||
const editor = useEditor()
|
||||
const helpers = useDefaultHelpers()
|
||||
const actions = useActions()
|
||||
const tools = overrides.tools?.(editor, {}, helpers) ?? {}
|
||||
const customActions = getCustomActions(editor) as any
|
||||
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
|
||||
|
|
@ -120,7 +122,30 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
|
||||
return (
|
||||
<DefaultContextMenu {...props}>
|
||||
<DefaultContextMenuContent />
|
||||
{/* Essential non-edit commands from default context menu */}
|
||||
<TldrawUiMenuGroup id="default-actions">
|
||||
<TldrawUiMenuItem
|
||||
id="select-all"
|
||||
label="Select All"
|
||||
icon="select-all"
|
||||
kbd="ctrl+a"
|
||||
onSelect={() => actions['select-all'].onSelect("context-menu")}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="undo"
|
||||
label="Undo"
|
||||
icon="undo"
|
||||
kbd="ctrl+z"
|
||||
onSelect={() => actions.undo.onSelect("context-menu")}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="redo"
|
||||
label="Redo"
|
||||
icon="redo"
|
||||
kbd="ctrl+y"
|
||||
onSelect={() => actions.redo.onSelect("context-menu")}
|
||||
/>
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
{/* Frames List - Moved to top */}
|
||||
<TldrawUiMenuGroup id="frames-list">
|
||||
|
|
@ -153,21 +178,51 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.saveToPdf} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.llm} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.openObsidianBrowser} />
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
{/* Edit Actions Group */}
|
||||
<TldrawUiMenuGroup id="edit-actions">
|
||||
<TldrawUiMenuItem
|
||||
id="paste"
|
||||
label="Paste"
|
||||
icon="clipboard"
|
||||
kbd="ctrl+v"
|
||||
onSelect={() => {
|
||||
// Trigger paste using the browser's native paste functionality
|
||||
document.execCommand('paste')
|
||||
}}
|
||||
/>
|
||||
<TldrawUiMenuSubmenu id="edit-dropdown" label="Edit">
|
||||
<TldrawUiMenuItem
|
||||
id="cut"
|
||||
label="Cut"
|
||||
icon="scissors"
|
||||
kbd="ctrl+x"
|
||||
disabled={!hasSelection}
|
||||
onSelect={() => actions.cut.onSelect("context-menu")}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="copy"
|
||||
label="Copy"
|
||||
icon="copy"
|
||||
kbd="ctrl+c"
|
||||
disabled={!hasSelection}
|
||||
onSelect={() => actions.copy.onSelect("context-menu")}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="paste"
|
||||
label="Paste"
|
||||
icon="clipboard"
|
||||
kbd="ctrl+v"
|
||||
onSelect={() => actions.paste.onSelect("context-menu")}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="duplicate"
|
||||
label="Duplicate"
|
||||
icon="duplicate"
|
||||
kbd="ctrl+d"
|
||||
disabled={!hasSelection}
|
||||
onSelect={() => actions.duplicate.onSelect("context-menu")}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="delete"
|
||||
label="Delete"
|
||||
icon="trash"
|
||||
kbd="⌫"
|
||||
disabled={!hasSelection}
|
||||
onSelect={() => actions.delete.onSelect("context-menu")}
|
||||
/>
|
||||
</TldrawUiMenuSubmenu>
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
{/* Creation Tools Group */}
|
||||
|
|
@ -179,8 +234,11 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuItem {...tools.Markdown} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.MycrozineTemplate} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.SharedPiano} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.ObsidianNote} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Transcription} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.FathomMeetings} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Holon} disabled={hasSelection} />
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
{/* Collections Group */}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,10 @@ import {
|
|||
TLContent,
|
||||
DefaultMainMenuContent,
|
||||
useEditor,
|
||||
useExportAs,
|
||||
} from "tldraw";
|
||||
|
||||
export function CustomMainMenu() {
|
||||
const editor = useEditor()
|
||||
const exportAs = useExportAs()
|
||||
|
||||
const importJSON = (editor: Editor) => {
|
||||
const input = document.createElement("input");
|
||||
|
|
@ -27,6 +25,139 @@ export function CustomMainMenu() {
|
|||
const jsonData = JSON.parse(event.target.result)
|
||||
console.log('Parsed JSON data:', jsonData)
|
||||
|
||||
// Helper function to validate and normalize shape types
|
||||
const validateAndNormalizeShapeType = (shape: any): string => {
|
||||
if (!shape || !shape.type) return 'text'
|
||||
|
||||
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'SharedPiano', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'FathomTranscript', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare']
|
||||
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
|
||||
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]
|
||||
|
||||
// Check if original type is valid (preserves lowercase default shapes like 'embed', 'geo', etc.)
|
||||
if (allValidShapes.includes(shape.type)) {
|
||||
return shape.type
|
||||
}
|
||||
|
||||
// Normalize case: chatBox -> ChatBox, videoChat -> VideoChat, etc.
|
||||
const normalizedType = shape.type.charAt(0).toUpperCase() + shape.type.slice(1)
|
||||
|
||||
// Check if normalized version is valid (for custom shapes like ChatBox, VideoChat, etc.)
|
||||
if (allValidShapes.includes(normalizedType)) {
|
||||
return normalizedType
|
||||
}
|
||||
|
||||
// If not valid, convert to text shape
|
||||
console.warn(`⚠️ Unknown or unsupported shape type "${shape.type}", converting to text shape for shape:`, shape.id)
|
||||
return 'text'
|
||||
}
|
||||
|
||||
// Helper function to validate and fix invalid numeric values (NaN, Infinity)
|
||||
const validateNumericValue = (value: any, defaultValue: number, name: string): number => {
|
||||
if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) {
|
||||
console.warn(`⚠️ Invalid ${name} value (${value}), using default: ${defaultValue}`)
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Helper function to validate shape geometry data
|
||||
const validateShapeGeometry = (shape: any): boolean => {
|
||||
if (!shape || !shape.id) return false
|
||||
|
||||
// Validate basic numeric properties
|
||||
shape.x = validateNumericValue(shape.x, 0, 'x')
|
||||
shape.y = validateNumericValue(shape.y, 0, 'y')
|
||||
shape.rotation = validateNumericValue(shape.rotation, 0, 'rotation')
|
||||
shape.opacity = validateNumericValue(shape.opacity, 1, 'opacity')
|
||||
|
||||
// Validate shape-specific geometry based on type
|
||||
if (shape.type === 'line' && shape.props?.points) {
|
||||
// Validate line points
|
||||
if (Array.isArray(shape.props.points)) {
|
||||
shape.props.points = shape.props.points.filter((point: any) => {
|
||||
if (!point || typeof point !== 'object') return false
|
||||
const x = validateNumericValue(point.x, 0, 'point.x')
|
||||
const y = validateNumericValue(point.y, 0, 'point.y')
|
||||
return true
|
||||
}).map((point: any) => ({
|
||||
x: validateNumericValue(point.x, 0, 'point.x'),
|
||||
y: validateNumericValue(point.y, 0, 'point.y'),
|
||||
z: point.z !== undefined ? validateNumericValue(point.z, 0.5, 'point.z') : 0.5
|
||||
}))
|
||||
|
||||
// Line must have at least 2 points
|
||||
if (shape.props.points.length < 2) {
|
||||
console.warn(`⚠️ Line shape has insufficient points (${shape.props.points.length}), skipping shape:`, shape.id)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shape.type === 'draw' && shape.props?.segments) {
|
||||
// Validate draw segments
|
||||
if (Array.isArray(shape.props.segments)) {
|
||||
shape.props.segments = shape.props.segments.filter((segment: any) => {
|
||||
if (!segment || typeof segment !== 'object') return false
|
||||
if (segment.points && Array.isArray(segment.points)) {
|
||||
segment.points = segment.points.filter((point: any) => {
|
||||
if (!point || typeof point !== 'object') return false
|
||||
const x = validateNumericValue(point.x, 0, 'segment.point.x')
|
||||
const y = validateNumericValue(point.y, 0, 'segment.point.y')
|
||||
return true
|
||||
}).map((point: any) => ({
|
||||
x: validateNumericValue(point.x, 0, 'segment.point.x'),
|
||||
y: validateNumericValue(point.y, 0, 'segment.point.y')
|
||||
}))
|
||||
return segment.points.length > 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Draw must have at least 1 segment with points
|
||||
if (shape.props.segments.length === 0 ||
|
||||
!shape.props.segments.some((s: any) => s.points && s.points.length > 0)) {
|
||||
console.warn(`⚠️ Draw shape has no valid segments, skipping shape:`, shape.id)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shape.type === 'arrow' && shape.props?.points) {
|
||||
// Validate arrow points
|
||||
if (Array.isArray(shape.props.points)) {
|
||||
shape.props.points = shape.props.points.filter((point: any) => {
|
||||
if (!point || typeof point !== 'object') return false
|
||||
return true
|
||||
}).map((point: any) => ({
|
||||
x: validateNumericValue(point.x, 0, 'arrow.point.x'),
|
||||
y: validateNumericValue(point.y, 0, 'arrow.point.y'),
|
||||
z: point.z !== undefined ? validateNumericValue(point.z, 0.5, 'arrow.point.z') : 0.5
|
||||
}))
|
||||
|
||||
// Arrow must have at least 2 points
|
||||
if (shape.props.points.length < 2) {
|
||||
console.warn(`⚠️ Arrow shape has insufficient points (${shape.props.points.length}), skipping shape:`, shape.id)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate props numeric values
|
||||
if (shape.props) {
|
||||
if ('w' in shape.props) {
|
||||
shape.props.w = validateNumericValue(shape.props.w, 100, 'props.w')
|
||||
}
|
||||
if ('h' in shape.props) {
|
||||
shape.props.h = validateNumericValue(shape.props.h, 100, 'props.h')
|
||||
}
|
||||
if ('scale' in shape.props) {
|
||||
shape.props.scale = validateNumericValue(shape.props.scale, 1, 'props.scale')
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle different JSON formats
|
||||
let contentToImport: TLContent
|
||||
|
||||
|
|
@ -34,43 +165,113 @@ export function CustomMainMenu() {
|
|||
const fixIncompleteShape = (shape: any, pageId: string): any => {
|
||||
const fixedShape = { ...shape }
|
||||
|
||||
// Add missing required properties for all shapes
|
||||
if (!fixedShape.x) fixedShape.x = Math.random() * 400 + 50 // Random position
|
||||
if (!fixedShape.y) fixedShape.y = Math.random() * 300 + 50
|
||||
if (!fixedShape.rotation) fixedShape.rotation = 0
|
||||
if (!fixedShape.isLocked) fixedShape.isLocked = false
|
||||
if (!fixedShape.opacity) fixedShape.opacity = 1
|
||||
if (!fixedShape.meta) fixedShape.meta = {}
|
||||
if (!fixedShape.parentId) fixedShape.parentId = pageId
|
||||
// CRITICAL: Validate geometry first (fixes NaN/Infinity values)
|
||||
if (!validateShapeGeometry(fixedShape)) {
|
||||
console.warn(`⚠️ Shape failed geometry validation, skipping:`, fixedShape.id)
|
||||
return null // Return null to indicate shape should be skipped
|
||||
}
|
||||
|
||||
// Add shape-specific properties
|
||||
// CRITICAL: Validate and normalize shape type
|
||||
const normalizedType = validateAndNormalizeShapeType(fixedShape)
|
||||
if (normalizedType !== fixedShape.type) {
|
||||
console.log(`🔧 Normalizing shape type "${fixedShape.type}" to "${normalizedType}" for shape:`, fixedShape.id)
|
||||
fixedShape.type = normalizedType
|
||||
|
||||
// If converted to text, set up proper text shape props
|
||||
if (normalizedType === 'text') {
|
||||
if (!fixedShape.props) fixedShape.props = {}
|
||||
fixedShape.props = {
|
||||
...fixedShape.props,
|
||||
w: fixedShape.props.w || 300,
|
||||
color: fixedShape.props.color || 'black',
|
||||
size: fixedShape.props.size || 'm',
|
||||
font: fixedShape.props.font || 'draw',
|
||||
textAlign: fixedShape.props.textAlign || 'start',
|
||||
autoSize: fixedShape.props.autoSize !== undefined ? fixedShape.props.autoSize : false,
|
||||
scale: fixedShape.props.scale || 1,
|
||||
richText: fixedShape.props.richText || { content: [], type: 'doc' }
|
||||
}
|
||||
// Remove invalid properties for text shapes
|
||||
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
|
||||
invalidTextProps.forEach(prop => {
|
||||
if (prop in fixedShape.props) {
|
||||
delete (fixedShape.props as any)[prop]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Preserve existing coordinates - only set defaults if truly missing
|
||||
// x/y can be 0, which is a valid coordinate, so check for undefined/null
|
||||
// Note: validateShapeGeometry already ensures x/y are valid numbers
|
||||
if (fixedShape.x === undefined || fixedShape.x === null) {
|
||||
fixedShape.x = Math.random() * 400 + 50 // Random position only if missing
|
||||
}
|
||||
if (fixedShape.y === undefined || fixedShape.y === null) {
|
||||
fixedShape.y = Math.random() * 300 + 50 // Random position only if missing
|
||||
}
|
||||
|
||||
// Preserve rotation, isLocked, opacity - only set defaults if missing
|
||||
if (fixedShape.rotation === undefined || fixedShape.rotation === null) {
|
||||
fixedShape.rotation = 0
|
||||
}
|
||||
if (fixedShape.isLocked === undefined || fixedShape.isLocked === null) {
|
||||
fixedShape.isLocked = false
|
||||
}
|
||||
if (fixedShape.opacity === undefined || fixedShape.opacity === null) {
|
||||
fixedShape.opacity = 1
|
||||
}
|
||||
if (!fixedShape.meta || typeof fixedShape.meta !== 'object') {
|
||||
fixedShape.meta = {}
|
||||
}
|
||||
|
||||
// CRITICAL: Preserve parentId relationships (frames, groups, etc.)
|
||||
// Only set to pageId if parentId is truly missing
|
||||
// This preserves frame relationships and prevents content collapse
|
||||
if (!fixedShape.parentId || fixedShape.parentId === '') {
|
||||
fixedShape.parentId = pageId
|
||||
}
|
||||
|
||||
// CRITICAL: For geo shapes, w/h/geo MUST be in props, NOT at top level
|
||||
if (fixedShape.type === 'geo') {
|
||||
if (!fixedShape.w) fixedShape.w = 100
|
||||
if (!fixedShape.h) fixedShape.h = 100
|
||||
if (!fixedShape.geo) fixedShape.geo = 'rectangle'
|
||||
if (!fixedShape.insets) fixedShape.insets = [0, 0, 0, 0]
|
||||
if (!fixedShape.props) fixedShape.props = {
|
||||
geo: 'rectangle',
|
||||
w: fixedShape.w,
|
||||
h: fixedShape.h,
|
||||
color: 'black',
|
||||
fill: 'none',
|
||||
dash: 'draw',
|
||||
size: 'm',
|
||||
font: 'draw'
|
||||
}
|
||||
// Store w/h/geo values if they exist at top level
|
||||
const wValue = fixedShape.w !== undefined ? fixedShape.w : 100
|
||||
const hValue = fixedShape.h !== undefined ? fixedShape.h : 100
|
||||
const geoValue = fixedShape.geo !== undefined ? fixedShape.geo : 'rectangle'
|
||||
|
||||
// Remove w/h/geo from top level (TLDraw validation requires they be in props only)
|
||||
delete fixedShape.w
|
||||
delete fixedShape.h
|
||||
delete fixedShape.geo
|
||||
|
||||
// Ensure props exists and has the correct values
|
||||
if (!fixedShape.props) fixedShape.props = {}
|
||||
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
|
||||
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
|
||||
if (fixedShape.props.geo === undefined) fixedShape.props.geo = geoValue
|
||||
|
||||
// Set default props if missing
|
||||
if (!fixedShape.props.color) fixedShape.props.color = 'black'
|
||||
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
|
||||
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
|
||||
if (!fixedShape.props.size) fixedShape.props.size = 'm'
|
||||
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
|
||||
} else if (fixedShape.type === 'VideoChat') {
|
||||
if (!fixedShape.w) fixedShape.w = 200
|
||||
if (!fixedShape.h) fixedShape.h = 150
|
||||
if (!fixedShape.props) fixedShape.props = {
|
||||
w: fixedShape.w,
|
||||
h: fixedShape.h,
|
||||
color: 'black',
|
||||
fill: 'none',
|
||||
dash: 'draw',
|
||||
size: 'm',
|
||||
font: 'draw'
|
||||
}
|
||||
// VideoChat shapes also need w/h in props, not top level
|
||||
const wValue = fixedShape.w !== undefined ? fixedShape.w : 200
|
||||
const hValue = fixedShape.h !== undefined ? fixedShape.h : 150
|
||||
|
||||
delete fixedShape.w
|
||||
delete fixedShape.h
|
||||
|
||||
if (!fixedShape.props) fixedShape.props = {}
|
||||
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
|
||||
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
|
||||
if (!fixedShape.props.color) fixedShape.props.color = 'black'
|
||||
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
|
||||
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
|
||||
if (!fixedShape.props.size) fixedShape.props.size = 'm'
|
||||
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
|
||||
}
|
||||
|
||||
return fixedShape
|
||||
|
|
@ -96,8 +297,15 @@ export function CustomMainMenu() {
|
|||
|
||||
console.log('Extracted:', { shapes: shapes.length, bindings: bindings.length, assets: assets.length })
|
||||
|
||||
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
|
||||
// Shapes inside frames should NOT be in rootShapeIds (they're children of frames)
|
||||
const rootShapeIds = shapes
|
||||
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
|
||||
.map((shape: any) => shape.id)
|
||||
.filter(Boolean)
|
||||
|
||||
contentToImport = {
|
||||
rootShapeIds: shapes.map((shape: any) => shape.id).filter(Boolean),
|
||||
rootShapeIds: rootShapeIds,
|
||||
schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} },
|
||||
shapes: shapes,
|
||||
bindings: bindings,
|
||||
|
|
@ -121,7 +329,10 @@ export function CustomMainMenu() {
|
|||
Object.values(store).forEach((record: any) => {
|
||||
if (record && typeof record === 'object') {
|
||||
if (record.typeName === 'shape') {
|
||||
shapes.push(fixIncompleteShape(record, pageId))
|
||||
const fixedShape = fixIncompleteShape(record, pageId)
|
||||
if (fixedShape !== null) {
|
||||
shapes.push(fixedShape)
|
||||
}
|
||||
} else if (record.typeName === 'binding') {
|
||||
bindings.push(record)
|
||||
} else if (record.typeName === 'asset') {
|
||||
|
|
@ -132,8 +343,15 @@ export function CustomMainMenu() {
|
|||
|
||||
console.log('Extracted from Automerge format:', { shapes: shapes.length, bindings: bindings.length, assets: assets.length })
|
||||
|
||||
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
|
||||
// Shapes inside frames should NOT be in rootShapeIds (they're children of frames)
|
||||
const rootShapeIds = shapes
|
||||
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
|
||||
.map((shape: any) => shape.id)
|
||||
.filter(Boolean)
|
||||
|
||||
contentToImport = {
|
||||
rootShapeIds: shapes.map((shape: any) => shape.id).filter(Boolean),
|
||||
rootShapeIds: rootShapeIds,
|
||||
schema: jsonData.schema,
|
||||
shapes: shapes,
|
||||
bindings: bindings,
|
||||
|
|
@ -141,12 +359,26 @@ export function CustomMainMenu() {
|
|||
}
|
||||
} else if (jsonData.shapes && Array.isArray(jsonData.shapes)) {
|
||||
console.log('Detected standard TLContent format with', jsonData.shapes.length, 'shapes')
|
||||
// Find page ID or use default
|
||||
const pageId = jsonData.pages?.[0]?.id || 'page:default'
|
||||
// Find page ID from imported data or use current page
|
||||
const importedPageId = jsonData.pages?.[0]?.id || 'page:default'
|
||||
const currentPageId = editor.getCurrentPageId()
|
||||
const pageId = importedPageId // Use imported page ID, putContentOntoCurrentPage will handle mapping
|
||||
|
||||
// Fix shapes to ensure they have required properties
|
||||
const fixedShapes = jsonData.shapes.map((shape: any) => fixIncompleteShape(shape, pageId))
|
||||
// Filter out null shapes (shapes that failed validation)
|
||||
const fixedShapes = jsonData.shapes
|
||||
.map((shape: any) => fixIncompleteShape(shape, pageId))
|
||||
.filter((shape: any) => shape !== null)
|
||||
|
||||
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
|
||||
// Always recompute from fixed shapes to ensure correctness (shapes within frames should be excluded)
|
||||
const rootShapeIds = fixedShapes
|
||||
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
|
||||
.map((shape: any) => shape.id)
|
||||
.filter(Boolean)
|
||||
|
||||
contentToImport = {
|
||||
rootShapeIds: jsonData.rootShapeIds || fixedShapes.map((shape: any) => shape.id).filter(Boolean),
|
||||
rootShapeIds: rootShapeIds,
|
||||
schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} },
|
||||
shapes: fixedShapes,
|
||||
bindings: jsonData.bindings || [],
|
||||
|
|
@ -156,9 +388,20 @@ export function CustomMainMenu() {
|
|||
console.log('Detected unknown format, attempting fallback')
|
||||
// Try to extract shapes from any other format
|
||||
const pageId = 'page:default'
|
||||
const fixedShapes = (jsonData.shapes || []).map((shape: any) => fixIncompleteShape(shape, pageId))
|
||||
// Filter out null shapes (shapes that failed validation)
|
||||
const fixedShapes = (jsonData.shapes || [])
|
||||
.map((shape: any) => fixIncompleteShape(shape, pageId))
|
||||
.filter((shape: any) => shape !== null)
|
||||
|
||||
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
|
||||
// Always recompute from fixed shapes to ensure correctness (shapes within frames should be excluded)
|
||||
const rootShapeIds = fixedShapes
|
||||
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
|
||||
.map((shape: any) => shape.id)
|
||||
.filter(Boolean)
|
||||
|
||||
contentToImport = {
|
||||
rootShapeIds: jsonData.rootShapeIds || fixedShapes.map((shape: any) => shape.id).filter(Boolean),
|
||||
rootShapeIds: rootShapeIds,
|
||||
schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} },
|
||||
shapes: fixedShapes,
|
||||
bindings: jsonData.bindings || [],
|
||||
|
|
@ -195,6 +438,82 @@ export function CustomMainMenu() {
|
|||
contentToImport.assets = []
|
||||
}
|
||||
|
||||
// CRITICAL: Final sanitization - validate geometry, validate shape types, ensure all geo shapes have w/h/geo in props, not top level
|
||||
// Also ensure text shapes don't have props.text (should use props.richText instead)
|
||||
if (contentToImport.shapes) {
|
||||
contentToImport.shapes = contentToImport.shapes
|
||||
.map((shape: any) => {
|
||||
if (!shape || !shape.type) return null
|
||||
|
||||
// CRITICAL: Validate geometry first (fixes NaN/Infinity values)
|
||||
if (!validateShapeGeometry(shape)) {
|
||||
console.warn(`⚠️ Shape failed geometry validation in final sanitization, skipping:`, shape.id)
|
||||
return null
|
||||
}
|
||||
|
||||
return shape
|
||||
})
|
||||
.filter((shape: any) => shape !== null)
|
||||
.map((shape: any) => {
|
||||
if (!shape || !shape.type) return shape
|
||||
|
||||
// Validate and normalize shape type
|
||||
const normalizedType = validateAndNormalizeShapeType(shape)
|
||||
if (normalizedType !== shape.type) {
|
||||
console.log(`🔧 Normalizing shape type "${shape.type}" to "${normalizedType}" for shape:`, shape.id)
|
||||
shape.type = normalizedType
|
||||
|
||||
// If converted to text, set up proper text shape props
|
||||
if (normalizedType === 'text') {
|
||||
if (!shape.props) shape.props = {}
|
||||
shape.props = {
|
||||
...shape.props,
|
||||
w: shape.props.w || 300,
|
||||
color: shape.props.color || 'black',
|
||||
size: shape.props.size || 'm',
|
||||
font: shape.props.font || 'draw',
|
||||
textAlign: shape.props.textAlign || 'start',
|
||||
autoSize: shape.props.autoSize !== undefined ? shape.props.autoSize : false,
|
||||
scale: shape.props.scale || 1,
|
||||
richText: shape.props.richText || { content: [], type: 'doc' }
|
||||
}
|
||||
// Remove invalid properties for text shapes
|
||||
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
|
||||
invalidTextProps.forEach(prop => {
|
||||
if (prop in shape.props) {
|
||||
delete (shape.props as any)[prop]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (shape.type === 'geo') {
|
||||
const wValue = 'w' in shape ? shape.w : undefined
|
||||
const hValue = 'h' in shape ? shape.h : undefined
|
||||
const geoValue = 'geo' in shape ? shape.geo : undefined
|
||||
|
||||
// Remove from top level
|
||||
delete shape.w
|
||||
delete shape.h
|
||||
delete shape.geo
|
||||
|
||||
// Ensure props exists and move values there
|
||||
if (!shape.props) shape.props = {}
|
||||
if (wValue !== undefined && !shape.props.w) shape.props.w = wValue
|
||||
if (hValue !== undefined && !shape.props.h) shape.props.h = hValue
|
||||
if (geoValue !== undefined && !shape.props.geo) shape.props.geo = geoValue
|
||||
}
|
||||
|
||||
// CRITICAL: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text)
|
||||
// Text shapes should only use props.richText, not props.text
|
||||
if (shape && shape.type === 'text' && shape.props && 'text' in shape.props) {
|
||||
delete shape.props.text
|
||||
}
|
||||
|
||||
return shape
|
||||
})
|
||||
}
|
||||
|
||||
console.log('About to call putContentOntoCurrentPage with:', contentToImport)
|
||||
|
||||
try {
|
||||
|
|
@ -216,10 +535,71 @@ export function CustomMainMenu() {
|
|||
contentToImport.shapes.forEach((shape: any) => {
|
||||
try {
|
||||
if (shape && shape.id && shape.type) {
|
||||
// CRITICAL: Validate geometry first (fixes NaN/Infinity values)
|
||||
if (!validateShapeGeometry(shape)) {
|
||||
console.warn(`⚠️ Shape failed geometry validation in fallback, skipping:`, shape.id)
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Validate and normalize shape type
|
||||
const normalizedType = validateAndNormalizeShapeType(shape)
|
||||
if (normalizedType !== shape.type) {
|
||||
console.log(`🔧 Normalizing shape type "${shape.type}" to "${normalizedType}" for shape:`, shape.id)
|
||||
shape.type = normalizedType
|
||||
|
||||
// If converted to text, set up proper text shape props
|
||||
if (normalizedType === 'text') {
|
||||
if (!shape.props) shape.props = {}
|
||||
shape.props = {
|
||||
...shape.props,
|
||||
w: shape.props.w || 300,
|
||||
color: shape.props.color || 'black',
|
||||
size: shape.props.size || 'm',
|
||||
font: shape.props.font || 'draw',
|
||||
textAlign: shape.props.textAlign || 'start',
|
||||
autoSize: shape.props.autoSize !== undefined ? shape.props.autoSize : false,
|
||||
scale: shape.props.scale || 1,
|
||||
richText: shape.props.richText || { content: [], type: 'doc' }
|
||||
}
|
||||
// Remove invalid properties for text shapes
|
||||
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
|
||||
invalidTextProps.forEach(prop => {
|
||||
if (prop in shape.props) {
|
||||
delete (shape.props as any)[prop]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure isLocked property is set
|
||||
if (shape.isLocked === undefined) {
|
||||
shape.isLocked = false
|
||||
}
|
||||
|
||||
// CRITICAL: Final sanitization - ensure geo shapes don't have w/h/geo at top level
|
||||
if (shape.type === 'geo') {
|
||||
const wValue = 'w' in shape ? shape.w : undefined
|
||||
const hValue = 'h' in shape ? shape.h : undefined
|
||||
const geoValue = 'geo' in shape ? shape.geo : undefined
|
||||
|
||||
// Remove from top level
|
||||
delete shape.w
|
||||
delete shape.h
|
||||
delete shape.geo
|
||||
|
||||
// Ensure props exists and move values there
|
||||
if (!shape.props) shape.props = {}
|
||||
if (wValue !== undefined && !shape.props.w) shape.props.w = wValue
|
||||
if (hValue !== undefined && !shape.props.h) shape.props.h = hValue
|
||||
if (geoValue !== undefined && !shape.props.geo) shape.props.geo = geoValue
|
||||
}
|
||||
|
||||
// CRITICAL: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text)
|
||||
// Text shapes should only use props.richText, not props.text
|
||||
if (shape.type === 'text' && shape.props && 'text' in shape.props) {
|
||||
delete shape.props.text
|
||||
}
|
||||
|
||||
editor.createShape(shape)
|
||||
}
|
||||
} catch (shapeError) {
|
||||
|
|
@ -257,8 +637,63 @@ export function CustomMainMenu() {
|
|||
input.click();
|
||||
};
|
||||
const exportJSON = (editor: Editor) => {
|
||||
const exportName = `props-${Math.round(+new Date() / 1000).toString().slice(5)}`
|
||||
exportAs(Array.from(editor.getCurrentPageShapeIds()), 'json' as any, exportName)
|
||||
try {
|
||||
// Get all shapes from the current page
|
||||
const shapes = editor.getCurrentPageShapes()
|
||||
|
||||
if (shapes.length === 0) {
|
||||
alert('No shapes to export')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current page ID
|
||||
const currentPageId = editor.getCurrentPageId()
|
||||
|
||||
// Get root shape IDs (shapes without a parent or with page as parent)
|
||||
const rootShapeIds = shapes
|
||||
.filter(shape => !shape.parentId || shape.parentId === currentPageId)
|
||||
.map(shape => shape.id)
|
||||
|
||||
// Get all bindings from the store
|
||||
const store = editor.store
|
||||
const bindings = store.allRecords()
|
||||
.filter(record => record.typeName === 'binding')
|
||||
.map(record => record as any)
|
||||
|
||||
// Get all assets from the store
|
||||
const assets = store.allRecords()
|
||||
.filter(record => record.typeName === 'asset')
|
||||
.map(record => record as any)
|
||||
|
||||
// Get schema from the store
|
||||
const schema = editor.store.schema.serialize()
|
||||
|
||||
// Construct the content object matching the import format
|
||||
const content: TLContent = {
|
||||
rootShapeIds: rootShapeIds,
|
||||
schema: schema,
|
||||
shapes: shapes.map(shape => shape as any),
|
||||
bindings: bindings,
|
||||
assets: assets,
|
||||
}
|
||||
|
||||
// Convert to JSON string
|
||||
const jsonString = JSON.stringify(content, null, 2)
|
||||
|
||||
// Create a blob and download it
|
||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `canvas-export-${Date.now()}.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Error exporting JSON:', error)
|
||||
alert('Failed to export JSON. Please try again.')
|
||||
}
|
||||
};
|
||||
|
||||
const fitToContent = (editor: Editor) => {
|
||||
|
|
@ -320,41 +755,53 @@ export function CustomMainMenu() {
|
|||
if (!fixedShape.meta) fixedShape.meta = {};
|
||||
if (!fixedShape.parentId) fixedShape.parentId = pageId;
|
||||
|
||||
// Add shape-specific properties
|
||||
// CRITICAL: For geo shapes, w/h/geo MUST be in props, NOT at top level
|
||||
if (fixedShape.type === 'geo') {
|
||||
if (!fixedShape.w) fixedShape.w = 100;
|
||||
if (!fixedShape.h) fixedShape.h = 100;
|
||||
if (!fixedShape.geo) fixedShape.geo = 'rectangle';
|
||||
if (!fixedShape.insets) fixedShape.insets = [0, 0, 0, 0];
|
||||
if (!fixedShape.props) fixedShape.props = {
|
||||
geo: 'rectangle',
|
||||
w: fixedShape.w,
|
||||
h: fixedShape.h,
|
||||
color: 'black',
|
||||
fill: 'none',
|
||||
dash: 'draw',
|
||||
size: 'm',
|
||||
font: 'draw',
|
||||
align: 'middle',
|
||||
verticalAlign: 'middle',
|
||||
growY: 0,
|
||||
url: '',
|
||||
scale: 1,
|
||||
labelColor: 'black',
|
||||
richText: [] as any
|
||||
};
|
||||
// Store w/h/geo values if they exist at top level
|
||||
const wValue = fixedShape.w !== undefined ? fixedShape.w : 100
|
||||
const hValue = fixedShape.h !== undefined ? fixedShape.h : 100
|
||||
const geoValue = fixedShape.geo !== undefined ? fixedShape.geo : 'rectangle'
|
||||
|
||||
// Remove w/h/geo from top level (TLDraw validation requires they be in props only)
|
||||
delete fixedShape.w
|
||||
delete fixedShape.h
|
||||
delete fixedShape.geo
|
||||
|
||||
// Ensure props exists and has the correct values
|
||||
if (!fixedShape.props) fixedShape.props = {}
|
||||
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
|
||||
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
|
||||
if (fixedShape.props.geo === undefined) fixedShape.props.geo = geoValue
|
||||
|
||||
// Set default props if missing
|
||||
if (!fixedShape.props.color) fixedShape.props.color = 'black'
|
||||
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
|
||||
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
|
||||
if (!fixedShape.props.size) fixedShape.props.size = 'm'
|
||||
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
|
||||
if (!fixedShape.props.align) fixedShape.props.align = 'middle'
|
||||
if (!fixedShape.props.verticalAlign) fixedShape.props.verticalAlign = 'middle'
|
||||
if (fixedShape.props.growY === undefined) fixedShape.props.growY = 0
|
||||
if (!fixedShape.props.url) fixedShape.props.url = ''
|
||||
if (fixedShape.props.scale === undefined) fixedShape.props.scale = 1
|
||||
if (!fixedShape.props.labelColor) fixedShape.props.labelColor = 'black'
|
||||
if (!fixedShape.props.richText) fixedShape.props.richText = [] as any
|
||||
} else if (fixedShape.type === 'VideoChat') {
|
||||
if (!fixedShape.w) fixedShape.w = 200;
|
||||
if (!fixedShape.h) fixedShape.h = 150;
|
||||
if (!fixedShape.props) fixedShape.props = {
|
||||
w: fixedShape.w,
|
||||
h: fixedShape.h,
|
||||
color: 'black',
|
||||
fill: 'none',
|
||||
dash: 'draw',
|
||||
size: 'm',
|
||||
font: 'draw'
|
||||
};
|
||||
// VideoChat shapes also need w/h in props, not top level
|
||||
const wValue = fixedShape.w !== undefined ? fixedShape.w : 200
|
||||
const hValue = fixedShape.h !== undefined ? fixedShape.h : 150
|
||||
|
||||
delete fixedShape.w
|
||||
delete fixedShape.h
|
||||
|
||||
if (!fixedShape.props) fixedShape.props = {}
|
||||
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
|
||||
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
|
||||
if (!fixedShape.props.color) fixedShape.props.color = 'black'
|
||||
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
|
||||
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
|
||||
if (!fixedShape.props.size) fixedShape.props.size = 'm'
|
||||
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
|
||||
}
|
||||
|
||||
return fixedShape;
|
||||
|
|
|
|||
|
|
@ -9,9 +9,13 @@ import { useAuth } from "../context/AuthContext"
|
|||
import LoginButton from "../components/auth/LoginButton"
|
||||
import StarBoardButton from "../components/StarBoardButton"
|
||||
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||||
import { HolonBrowser } from "../components/HolonBrowser"
|
||||
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
||||
import { createShapeId } from "tldraw"
|
||||
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { LocationShareDialog } from "../components/location/LocationShareDialog"
|
||||
|
||||
export function CustomToolbar() {
|
||||
const editor = useEditor()
|
||||
|
|
@ -23,7 +27,9 @@ export function CustomToolbar() {
|
|||
const { session, setSession, clearSession } = useAuth()
|
||||
const [showProfilePopup, setShowProfilePopup] = useState(false)
|
||||
const [showVaultBrowser, setShowVaultBrowser] = useState(false)
|
||||
const [showHolonBrowser, setShowHolonBrowser] = useState(false)
|
||||
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
|
||||
const [showFathomPanel, setShowFathomPanel] = useState(false)
|
||||
const profilePopupRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -87,41 +93,257 @@ export function CustomToolbar() {
|
|||
}
|
||||
}, [session.obsidianVaultPath, session.obsidianVaultName, showVaultBrowser])
|
||||
|
||||
// Listen for open-obsidian-browser event from toolbar button
|
||||
// Listen for open-fathom-meetings event - now creates a shape instead of modal
|
||||
useEffect(() => {
|
||||
const handleOpenBrowser = () => {
|
||||
const handleOpenFathomMeetings = () => {
|
||||
console.log('🔧 Received open-fathom-meetings event')
|
||||
|
||||
// Allow multiple FathomMeetingsBrowser instances - users can work with multiple meeting browsers
|
||||
console.log('🔧 Creating new FathomMeetingsBrowser shape')
|
||||
|
||||
// Get the current viewport center
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
|
||||
// Position new browser shape at center
|
||||
const xPosition = centerX - 350 // Center the 700px wide shape
|
||||
const yPosition = centerY - 300 // Center the 600px tall shape
|
||||
|
||||
try {
|
||||
const browserShape = editor.createShape({
|
||||
type: 'FathomMeetingsBrowser',
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
props: {
|
||||
w: 700,
|
||||
h: 600,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created FathomMeetingsBrowser shape:', browserShape.id)
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
editor.setCurrentTool('select')
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating FathomMeetingsBrowser shape:', error)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('open-fathom-meetings', handleOpenFathomMeetings)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('open-fathom-meetings', handleOpenFathomMeetings)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Listen for open-obsidian-browser event - now creates a shape instead of modal
|
||||
useEffect(() => {
|
||||
const handleOpenBrowser = (event?: CustomEvent) => {
|
||||
console.log('🔧 Received open-obsidian-browser event')
|
||||
|
||||
// Check if ObsidianBrowser already exists
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const existingBrowserShapes = allShapes.filter(shape => shape.type === 'ObsidianBrowser')
|
||||
|
||||
// If vault browser is already open, close it
|
||||
if (showVaultBrowser) {
|
||||
console.log('🔧 Vault browser already open, closing it')
|
||||
setShowVaultBrowser(false)
|
||||
if (existingBrowserShapes.length > 0) {
|
||||
// If a browser already exists, just select it
|
||||
console.log('✅ ObsidianBrowser already exists, selecting it')
|
||||
editor.setSelectedShapes([existingBrowserShapes[0].id])
|
||||
editor.setCurrentTool('hand')
|
||||
return
|
||||
}
|
||||
|
||||
// No existing browser, create a new one
|
||||
console.log('🔧 Creating new ObsidianBrowser shape')
|
||||
|
||||
// Try to get click position from event or use current page point
|
||||
let xPosition: number
|
||||
let yPosition: number
|
||||
|
||||
// Check if user already has a vault selected
|
||||
if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') {
|
||||
console.log('🔧 Vault already selected, opening search interface')
|
||||
setVaultBrowserMode('keyboard')
|
||||
setShowVaultBrowser(true)
|
||||
} else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) {
|
||||
console.log('🔧 Folder-selected vault exists, opening search interface')
|
||||
setVaultBrowserMode('keyboard')
|
||||
setShowVaultBrowser(true)
|
||||
// Check if event has click coordinates
|
||||
// Standardized size: 800x600
|
||||
const shapeWidth = 800
|
||||
const shapeHeight = 600
|
||||
|
||||
const clickPoint = (event as any)?.detail?.point
|
||||
if (clickPoint) {
|
||||
// Use click coordinates from event
|
||||
xPosition = clickPoint.x - shapeWidth / 2 // Center the shape on click
|
||||
yPosition = clickPoint.y - shapeHeight / 2 // Center the shape on click
|
||||
console.log('📍 Positioning at event click location:', { clickPoint, xPosition, yPosition })
|
||||
} else {
|
||||
console.log('🔧 No vault selected, opening vault selection')
|
||||
setVaultBrowserMode('button')
|
||||
setShowVaultBrowser(true)
|
||||
// Try to get current page point (if called from a click)
|
||||
const currentPagePoint = editor.inputs.currentPagePoint
|
||||
if (currentPagePoint && currentPagePoint.x !== undefined && currentPagePoint.y !== undefined) {
|
||||
xPosition = currentPagePoint.x - shapeWidth / 2 // Center the shape on click
|
||||
yPosition = currentPagePoint.y - shapeHeight / 2 // Center the shape on click
|
||||
console.log('📍 Positioning at current page point:', { currentPagePoint, xPosition, yPosition })
|
||||
} else {
|
||||
// Fallback to viewport center if no click coordinates available
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
xPosition = centerX - shapeWidth / 2 // Center the shape
|
||||
yPosition = centerY - shapeHeight / 2 // Center the shape
|
||||
console.log('📍 Positioning at viewport center (fallback):', { centerX, centerY, xPosition, yPosition })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const browserShape = editor.createShape({
|
||||
type: 'ObsidianBrowser',
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created ObsidianBrowser shape:', browserShape.id)
|
||||
|
||||
// Select the new shape and switch to hand tool
|
||||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
editor.setCurrentTool('hand')
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating ObsidianBrowser shape:', error)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('open-obsidian-browser', handleOpenBrowser as EventListener)
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('open-obsidian-browser', handleOpenBrowser as EventListener)
|
||||
}
|
||||
}, [session.obsidianVaultPath, session.obsidianVaultName, showVaultBrowser])
|
||||
}, [editor])
|
||||
|
||||
// Listen for open-holon-browser event - now creates a shape instead of modal
|
||||
useEffect(() => {
|
||||
const handleOpenHolonBrowser = () => {
|
||||
console.log('🔧 Received open-holon-browser event')
|
||||
|
||||
// Check if a HolonBrowser shape already exists
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const existingBrowserShapes = allShapes.filter(s => s.type === 'HolonBrowser')
|
||||
|
||||
if (existingBrowserShapes.length > 0) {
|
||||
// If a browser already exists, just select it
|
||||
console.log('✅ HolonBrowser already exists, selecting it')
|
||||
editor.setSelectedShapes([existingBrowserShapes[0].id])
|
||||
editor.setCurrentTool('select')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔧 Creating new HolonBrowser shape')
|
||||
|
||||
// Get the current viewport center
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
|
||||
// Position new browser shape at center
|
||||
const xPosition = centerX - 400 // Center the 800px wide shape
|
||||
const yPosition = centerY - 300 // Center the 600px tall shape
|
||||
|
||||
try {
|
||||
const browserShape = editor.createShape({
|
||||
type: 'HolonBrowser',
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
props: {
|
||||
w: 800,
|
||||
h: 600,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created HolonBrowser shape:', browserShape.id)
|
||||
|
||||
// Select the new shape and switch to hand tool
|
||||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
editor.setCurrentTool('hand')
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating HolonBrowser shape:', error)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('open-holon-browser', handleOpenHolonBrowser)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('open-holon-browser', handleOpenHolonBrowser)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Handle Holon selection from browser
|
||||
const handleHolonSelect = (holonData: HolonData) => {
|
||||
console.log('🎯 Creating Holon shape from data:', holonData)
|
||||
|
||||
try {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = editor.getCamera()
|
||||
editor.stopCameraAnimation()
|
||||
|
||||
// Get the current viewport center
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
|
||||
// Standardized size: 700x400 (matches default props to fit ID and button)
|
||||
const shapeWidth = 700
|
||||
const shapeHeight = 400
|
||||
|
||||
// Position new Holon shape at viewport center
|
||||
const xPosition = centerX - shapeWidth / 2
|
||||
const yPosition = centerY - shapeHeight / 2
|
||||
|
||||
const holonShape = editor.createShape({
|
||||
type: 'Holon',
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
name: holonData.name,
|
||||
description: holonData.description || '',
|
||||
latitude: holonData.latitude,
|
||||
longitude: holonData.longitude,
|
||||
resolution: holonData.resolution,
|
||||
holonId: holonData.id,
|
||||
isConnected: true,
|
||||
isEditing: false,
|
||||
selectedLens: 'general',
|
||||
data: holonData.data,
|
||||
connections: [],
|
||||
lastUpdated: holonData.timestamp
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created Holon shape from data:', holonShape.id)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = editor.getCamera()
|
||||
editor.stopCameraAnimation()
|
||||
editor.setSelectedShapes([`shape:${holonShape.id}`] as any)
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraAfterSelect.z !== cameraAfterSelect.z) {
|
||||
editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Holon shape from data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for create-obsnote-shapes event from the tool
|
||||
useEffect(() => {
|
||||
|
|
@ -213,100 +435,8 @@ export function CustomToolbar() {
|
|||
})
|
||||
}
|
||||
|
||||
// Layout functions for Obsidian notes
|
||||
const findNonOverlappingPosition = (baseX: number, baseY: number, width: number = 300, height: number = 200, excludeShapeIds: string[] = []) => {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
// Check against all shapes, not just ObsNote shapes
|
||||
const existingShapes = allShapes.filter(s => !excludeShapeIds.includes(s.id))
|
||||
|
||||
// Try positions in a spiral pattern with more positions
|
||||
const positions = [
|
||||
{ x: baseX, y: baseY }, // Center
|
||||
{ x: baseX + width + 20, y: baseY }, // Right
|
||||
{ x: baseX - width - 20, y: baseY }, // Left
|
||||
{ x: baseX, y: baseY - height - 20 }, // Above
|
||||
{ x: baseX, y: baseY + height + 20 }, // Below
|
||||
{ x: baseX + width + 20, y: baseY - height - 20 }, // Top-right
|
||||
{ x: baseX - width - 20, y: baseY - height - 20 }, // Top-left
|
||||
{ x: baseX + width + 20, y: baseY + height + 20 }, // Bottom-right
|
||||
{ x: baseX - width - 20, y: baseY + height + 20 }, // Bottom-left
|
||||
// Additional positions for better coverage
|
||||
{ x: baseX + (width + 20) * 2, y: baseY }, // Far right
|
||||
{ x: baseX - (width + 20) * 2, y: baseY }, // Far left
|
||||
{ x: baseX, y: baseY - (height + 20) * 2 }, // Far above
|
||||
{ x: baseX, y: baseY + (height + 20) * 2 }, // Far below
|
||||
]
|
||||
|
||||
for (const pos of positions) {
|
||||
let hasOverlap = false
|
||||
|
||||
for (const existingShape of existingShapes) {
|
||||
const shapeBounds = editor.getShapePageBounds(existingShape.id)
|
||||
if (shapeBounds) {
|
||||
// Add padding around shapes for better spacing
|
||||
const padding = 10
|
||||
const overlap = !(
|
||||
pos.x + width + padding < shapeBounds.x - padding ||
|
||||
pos.x - padding > shapeBounds.x + shapeBounds.w + padding ||
|
||||
pos.y + height + padding < shapeBounds.y - padding ||
|
||||
pos.y - padding > shapeBounds.y + shapeBounds.h + padding
|
||||
)
|
||||
|
||||
if (overlap) {
|
||||
hasOverlap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasOverlap) {
|
||||
return pos
|
||||
}
|
||||
}
|
||||
|
||||
// If all positions overlap, use a more sophisticated grid-based approach
|
||||
const gridSize = Math.max(width, height) + 40 // Increased spacing
|
||||
const gridX = Math.floor(baseX / gridSize) * gridSize
|
||||
const gridY = Math.floor(baseY / gridSize) * gridSize
|
||||
|
||||
// Try multiple grid positions
|
||||
for (let offsetX = 0; offsetX < 5; offsetX++) {
|
||||
for (let offsetY = 0; offsetY < 5; offsetY++) {
|
||||
const testX = gridX + offsetX * gridSize
|
||||
const testY = gridY + offsetY * gridSize
|
||||
|
||||
let hasOverlap = false
|
||||
for (const existingShape of existingShapes) {
|
||||
const shapeBounds = editor.getShapePageBounds(existingShape.id)
|
||||
if (shapeBounds) {
|
||||
const padding = 10
|
||||
const overlap = !(
|
||||
testX + width + padding < shapeBounds.x - padding ||
|
||||
testX - padding > shapeBounds.x + shapeBounds.w + padding ||
|
||||
testY + height + padding < shapeBounds.y - padding ||
|
||||
testY - padding > shapeBounds.y + shapeBounds.h + padding
|
||||
)
|
||||
|
||||
if (overlap) {
|
||||
hasOverlap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasOverlap) {
|
||||
return { x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: place far to the right
|
||||
return { x: baseX + 500, y: baseY }
|
||||
}
|
||||
|
||||
const handleObsNoteSelect = (obsNote: ObsidianObsNote) => {
|
||||
console.log('🎯 handleObsNoteSelect called with:', obsNote)
|
||||
|
||||
// Get current camera position to place the obs_note
|
||||
const camera = editor.getCamera()
|
||||
const viewportCenter = editor.getViewportScreenCenter()
|
||||
|
|
@ -315,44 +445,43 @@ export function CustomToolbar() {
|
|||
const baseX = isNaN(viewportCenter.x) ? camera.x : viewportCenter.x
|
||||
const baseY = isNaN(viewportCenter.y) ? camera.y : viewportCenter.y
|
||||
|
||||
console.log('🎯 Creating obs_note shape at base:', { baseX, baseY, viewportCenter, camera })
|
||||
|
||||
// Find a non-overlapping position
|
||||
const position = findNonOverlappingPosition(baseX, baseY, 300, 200, [])
|
||||
|
||||
// Get vault information from session
|
||||
const vaultPath = session.obsidianVaultPath
|
||||
const vaultName = session.obsidianVaultName
|
||||
|
||||
// Create a new obs_note shape with vault information
|
||||
const obsNoteShape = ObsNoteShape.createFromObsidianObsNote(obsNote, position.x, position.y, createShapeId(), vaultPath, vaultName)
|
||||
const obsNoteShape = ObsNoteShape.createFromObsidianObsNote(obsNote, baseX, baseY, createShapeId(), vaultPath, vaultName)
|
||||
|
||||
console.log('🎯 Created obs_note shape:', obsNoteShape)
|
||||
console.log('🎯 Shape position:', position)
|
||||
console.log('🎯 Vault info:', { vaultPath, vaultName })
|
||||
// Use the ObsNote shape directly - no conversion needed
|
||||
const convertedShape = obsNoteShape
|
||||
|
||||
// Add the shape to the canvas
|
||||
try {
|
||||
editor.createShapes([obsNoteShape])
|
||||
console.log('🎯 Successfully added shape to canvas')
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = editor.getCamera()
|
||||
editor.stopCameraAnimation()
|
||||
|
||||
editor.createShapes([convertedShape])
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the newly created shape so user can see it
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = editor.getCamera()
|
||||
editor.stopCameraAnimation()
|
||||
editor.setSelectedShapes([obsNoteShape.id])
|
||||
console.log('🎯 Selected newly created shape:', obsNoteShape.id)
|
||||
|
||||
// Center the camera on the new shape
|
||||
editor.zoomToFit()
|
||||
|
||||
// Switch to hand tool after adding the shape
|
||||
editor.setCurrentTool('hand')
|
||||
console.log('🎯 Switched to hand tool after adding ObsNote')
|
||||
editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Check if shape was actually added
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const existingObsNoteShapes = allShapes.filter(s => s.type === 'ObsNote')
|
||||
console.log('🎯 Total ObsNote shapes on canvas:', existingObsNoteShapes.length)
|
||||
} catch (error) {
|
||||
console.error('🎯 Error adding shape to canvas:', error)
|
||||
}
|
||||
|
|
@ -362,8 +491,6 @@ export function CustomToolbar() {
|
|||
}
|
||||
|
||||
const handleObsNotesSelect = (obsNotes: ObsidianObsNote[]) => {
|
||||
console.log('🎯 handleObsNotesSelect called with:', obsNotes.length, 'notes')
|
||||
|
||||
// Get current camera position to place the obs_notes
|
||||
const camera = editor.getCamera()
|
||||
const viewportCenter = editor.getViewportScreenCenter()
|
||||
|
|
@ -372,61 +499,58 @@ export function CustomToolbar() {
|
|||
const baseX = isNaN(viewportCenter.x) ? camera.x : viewportCenter.x
|
||||
const baseY = isNaN(viewportCenter.y) ? camera.y : viewportCenter.y
|
||||
|
||||
console.log('🎯 Creating obs_note shapes at base:', { baseX, baseY, viewportCenter, camera })
|
||||
|
||||
// Get vault information from session
|
||||
const vaultPath = session.obsidianVaultPath
|
||||
const vaultName = session.obsidianVaultName
|
||||
|
||||
// Create obs_note shapes with improved collision avoidance
|
||||
// Create obs_note shapes
|
||||
const obsNoteShapes: any[] = []
|
||||
const createdShapeIds: string[] = []
|
||||
|
||||
for (let index = 0; index < obsNotes.length; index++) {
|
||||
const obs_note = obsNotes[index]
|
||||
|
||||
// Start with a grid-based position as a hint
|
||||
// Use a grid-based position
|
||||
const gridCols = 3
|
||||
const gridWidth = 320
|
||||
const gridHeight = 220
|
||||
const hintX = baseX + (index % gridCols) * gridWidth
|
||||
const hintY = baseY + Math.floor(index / gridCols) * gridHeight
|
||||
const xPosition = baseX + (index % gridCols) * gridWidth
|
||||
const yPosition = baseY + Math.floor(index / gridCols) * gridHeight
|
||||
|
||||
// Find non-overlapping position for this specific note
|
||||
// Exclude already created shapes in this batch
|
||||
const position = findNonOverlappingPosition(hintX, hintY, 300, 200, createdShapeIds)
|
||||
|
||||
const shape = ObsNoteShape.createFromObsidianObsNote(obs_note, position.x, position.y, createShapeId(), vaultPath, vaultName)
|
||||
const shape = ObsNoteShape.createFromObsidianObsNote(obs_note, xPosition, yPosition, createShapeId(), vaultPath, vaultName)
|
||||
obsNoteShapes.push(shape)
|
||||
createdShapeIds.push(shape.id)
|
||||
}
|
||||
|
||||
console.log('🎯 Created obs_note shapes:', obsNoteShapes)
|
||||
console.log('🎯 Vault info:', { vaultPath, vaultName })
|
||||
// Use the ObsNote shapes directly - no conversion needed
|
||||
const convertedShapes = obsNoteShapes
|
||||
|
||||
// Add all shapes to the canvas
|
||||
try {
|
||||
editor.createShapes(obsNoteShapes)
|
||||
console.log('🎯 Successfully added shapes to canvas')
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = editor.getCamera()
|
||||
editor.stopCameraAnimation()
|
||||
|
||||
editor.createShapes(convertedShapes)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select all newly created shapes so user can see them
|
||||
const newShapeIds = obsNoteShapes.map(shape => shape.id)
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = editor.getCamera()
|
||||
editor.stopCameraAnimation()
|
||||
editor.setSelectedShapes(newShapeIds)
|
||||
console.log('🎯 Selected newly created shapes:', newShapeIds)
|
||||
|
||||
// Center the camera on all new shapes
|
||||
editor.zoomToFit()
|
||||
|
||||
// Switch to hand tool after adding the shapes
|
||||
editor.setCurrentTool('hand')
|
||||
console.log('🎯 Switched to hand tool after adding ObsNotes')
|
||||
editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Check if shapes were actually added
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const existingObsNoteShapes = allShapes.filter(s => s.type === 'ObsNote')
|
||||
console.log('🎯 Total ObsNote shapes on canvas:', existingObsNoteShapes.length)
|
||||
} catch (error) {
|
||||
console.error('🎯 Error adding shapes to canvas:', error)
|
||||
}
|
||||
|
|
@ -800,6 +924,23 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["FathomTranscript"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["FathomTranscript"]}
|
||||
icon="video"
|
||||
label="Fathom Transcript"
|
||||
isSelected={tools["FathomTranscript"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Holon"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Holon"]}
|
||||
icon="globe"
|
||||
label="Holon"
|
||||
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{/* Share Location tool removed for now */}
|
||||
{/* Refresh All ObsNotes Button */}
|
||||
{(() => {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
|
|
@ -818,14 +959,10 @@ export function CustomToolbar() {
|
|||
})()}
|
||||
</DefaultToolbar>
|
||||
|
||||
{/* Obsidian Vault Browser */}
|
||||
{showVaultBrowser && (
|
||||
<ObsidianVaultBrowser
|
||||
onObsNoteSelect={handleObsNoteSelect}
|
||||
onObsNotesSelect={handleObsNotesSelect}
|
||||
onClose={() => setShowVaultBrowser(false)}
|
||||
autoOpenFolderPicker={vaultBrowserMode === 'button'}
|
||||
showVaultBrowser={showVaultBrowser}
|
||||
{/* Fathom Meetings Panel */}
|
||||
{showFathomPanel && (
|
||||
<FathomMeetingsPanel
|
||||
onClose={() => setShowFathomPanel(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
TldrawUiInput,
|
||||
} from "tldraw"
|
||||
import React from "react"
|
||||
import { PROVIDERS } from "../lib/settings"
|
||||
import { PROVIDERS, AI_PERSONALITIES } from "../lib/settings"
|
||||
import { useAuth } from "../context/AuthContext"
|
||||
|
||||
export function SettingsDialog({ onClose }: TLUiDialogProps) {
|
||||
|
|
@ -52,26 +52,70 @@ export function SettingsDialog({ onClose }: TLUiDialogProps) {
|
|||
}
|
||||
})
|
||||
|
||||
const [personality, setPersonality] = React.useState(() => {
|
||||
try {
|
||||
// First try to get user-specific settings if logged in
|
||||
if (session.authed && session.username) {
|
||||
const userApiKeys = localStorage.getItem(`${session.username}_api_keys`)
|
||||
if (userApiKeys) {
|
||||
try {
|
||||
const parsed = JSON.parse(userApiKeys)
|
||||
if (parsed.personality) {
|
||||
return parsed.personality
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global settings
|
||||
const stored = localStorage.getItem("openai_api_key")
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored)
|
||||
if (parsed.personality) {
|
||||
return parsed.personality
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to fallback
|
||||
}
|
||||
}
|
||||
return 'web-developer'
|
||||
} catch (e) {
|
||||
return 'web-developer'
|
||||
}
|
||||
})
|
||||
|
||||
const handleKeyChange = (provider: string, value: string) => {
|
||||
const newKeys = { ...apiKeys, [provider]: value }
|
||||
setApiKeys(newKeys)
|
||||
|
||||
saveSettings(newKeys, personality)
|
||||
}
|
||||
|
||||
const handlePersonalityChange = (newPersonality: string) => {
|
||||
setPersonality(newPersonality)
|
||||
saveSettings(apiKeys, newPersonality)
|
||||
}
|
||||
|
||||
const saveSettings = (keys: any, personalityValue: string) => {
|
||||
// Save to localStorage with the new structure
|
||||
const settings = {
|
||||
keys: newKeys,
|
||||
provider: provider === 'openai' ? 'openai' : provider, // Use the actual provider
|
||||
keys: keys,
|
||||
provider: 'openai', // Default provider
|
||||
models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])),
|
||||
personality: personalityValue,
|
||||
}
|
||||
|
||||
// If user is logged in, save to user-specific storage
|
||||
if (session.authed && session.username) {
|
||||
console.log(`💾 Saving user-specific API keys for ${session.username}:`, settings);
|
||||
console.log(`💾 Saving user-specific settings for ${session.username}:`, settings);
|
||||
localStorage.setItem(`${session.username}_api_keys`, JSON.stringify(settings))
|
||||
|
||||
// Also save to global storage as fallback
|
||||
localStorage.setItem("openai_api_key", JSON.stringify(settings))
|
||||
} else {
|
||||
console.log("💾 Saving global API keys to localStorage:", settings);
|
||||
console.log("💾 Saving global settings to localStorage:", settings);
|
||||
localStorage.setItem("openai_api_key", JSON.stringify(settings))
|
||||
}
|
||||
}
|
||||
|
|
@ -87,11 +131,41 @@ export function SettingsDialog({ onClose }: TLUiDialogProps) {
|
|||
return (
|
||||
<>
|
||||
<TldrawUiDialogHeader>
|
||||
<TldrawUiDialogTitle>AI API Keys</TldrawUiDialogTitle>
|
||||
<TldrawUiDialogTitle>AI Settings</TldrawUiDialogTitle>
|
||||
<TldrawUiDialogCloseButton />
|
||||
</TldrawUiDialogHeader>
|
||||
<TldrawUiDialogBody style={{ maxWidth: 400 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* AI Personality Selector */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<label style={{ fontWeight: "500", fontSize: "14px" }}>
|
||||
AI Personality
|
||||
</label>
|
||||
<select
|
||||
value={personality}
|
||||
onChange={(e) => handlePersonalityChange(e.target.value)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
backgroundColor: "white",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
{AI_PERSONALITIES.map((personality) => (
|
||||
<option key={personality.id} value={personality.id}>
|
||||
{personality.name} - {personality.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div style={{ borderTop: "1px solid #e5e7eb", paddingTop: "16px" }}>
|
||||
<h3 style={{ fontSize: "16px", fontWeight: "600", marginBottom: "16px" }}>
|
||||
API Keys
|
||||
</h3>
|
||||
{PROVIDERS.map((provider) => (
|
||||
<div key={provider.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
|
|
@ -140,6 +214,7 @@ export function SettingsDialog({ onClose }: TLUiDialogProps) {
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div> {/* Close API Keys Section */}
|
||||
|
||||
<div style={{
|
||||
padding: "12px",
|
||||
|
|
|
|||
|
|
@ -164,7 +164,6 @@ export const overrides: TLUiOverrides = {
|
|||
id: "gesture",
|
||||
icon: "draw",
|
||||
label: "Gesture",
|
||||
kbd: "g",
|
||||
readonlyOk: true,
|
||||
type: "gesture",
|
||||
onSelect: () => editor.setCurrentTool("gesture"),
|
||||
|
|
@ -187,11 +186,71 @@ export const overrides: TLUiOverrides = {
|
|||
type: "Transcription",
|
||||
onSelect: () => editor.setCurrentTool("transcription"),
|
||||
},
|
||||
FathomTranscript: {
|
||||
id: "fathom-transcript",
|
||||
icon: "file-text",
|
||||
label: "Fathom Transcript",
|
||||
kbd: "alt+f",
|
||||
readonlyOk: true,
|
||||
type: "FathomTranscript",
|
||||
onSelect: () => {
|
||||
// Dispatch custom event to open Fathom meetings panel
|
||||
const event = new CustomEvent('open-fathom-meetings')
|
||||
window.dispatchEvent(event)
|
||||
},
|
||||
},
|
||||
Holon: {
|
||||
id: "holon",
|
||||
icon: "circle",
|
||||
label: "Holon",
|
||||
kbd: "alt+h",
|
||||
readonlyOk: true,
|
||||
type: "Holon",
|
||||
onSelect: () => editor.setCurrentTool("holon"),
|
||||
},
|
||||
FathomMeetings: {
|
||||
id: "fathom-meetings",
|
||||
icon: "calendar",
|
||||
label: "Fathom Meetings",
|
||||
kbd: "alt+f",
|
||||
readonlyOk: true,
|
||||
// Removed type property to prevent automatic shape creation
|
||||
// Shape creation is handled manually in FathomMeetingsTool.onPointerDown
|
||||
onSelect: () => editor.setCurrentTool("fathom-meetings"),
|
||||
},
|
||||
hand: {
|
||||
...tools.hand,
|
||||
onDoubleClick: (info: any) => {
|
||||
editor.zoomIn(info.point, { animation: { duration: 200 } })
|
||||
},
|
||||
onPointerDown: (info: any) => {
|
||||
// Make hand tool drag shapes when clicking on them (without requiring selection)
|
||||
// Since tldraw already detects shapes on hover (cursor changes), getShapeAtPoint should work
|
||||
const shape = editor.getShapeAtPoint(info.point)
|
||||
|
||||
if (shape) {
|
||||
// Drag the shape directly without selecting it first
|
||||
editor.dispatch({
|
||||
type: "pointer",
|
||||
name: "pointer_down",
|
||||
point: info.point,
|
||||
button: info.button,
|
||||
shiftKey: info.shiftKey,
|
||||
altKey: info.altKey,
|
||||
ctrlKey: info.ctrlKey,
|
||||
metaKey: info.metaKey,
|
||||
pointerId: info.pointerId,
|
||||
target: "shape",
|
||||
shape,
|
||||
isPen: false,
|
||||
accelKey: info.ctrlKey || info.metaKey,
|
||||
})
|
||||
return // Don't let hand tool move camera
|
||||
}
|
||||
|
||||
// If not clicking on a shape, use default hand tool behavior (move camera)
|
||||
;(tools.hand as any).onPointerDown?.(info)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
@ -383,18 +442,49 @@ export const overrides: TLUiOverrides = {
|
|||
|
||||
try {
|
||||
llm(prompt, (partialResponse: string) => {
|
||||
|
||||
const targetShape = editor.getShape(edge.to) as TLGeoShape
|
||||
|
||||
// Convert plain text to richText format for TLDraw
|
||||
// Split by newlines to create multiple paragraphs
|
||||
const paragraphs = partialResponse.split('\n').filter(line => line.trim() !== '')
|
||||
const richTextContent = paragraphs.length > 0
|
||||
? paragraphs.map(paragraph => ({
|
||||
type: 'paragraph' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: paragraph
|
||||
}
|
||||
]
|
||||
}))
|
||||
: [
|
||||
{
|
||||
type: 'paragraph' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: partialResponse || ''
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const richText = {
|
||||
content: richTextContent,
|
||||
type: 'doc' as const
|
||||
}
|
||||
|
||||
editor.updateShape({
|
||||
id: edge.to,
|
||||
type: "geo",
|
||||
props: {
|
||||
...targetShape.props,
|
||||
richText: (targetShape.props as any)?.richText || [] as any, // Ensure richText exists
|
||||
richText: richText, // Store text in richText format for display
|
||||
},
|
||||
meta: {
|
||||
...targetShape.meta,
|
||||
text: partialResponse, // Store text in meta instead of props
|
||||
// Keep text in meta for backwards compatibility if needed
|
||||
text: partialResponse,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import OpenAI from "openai";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { makeRealSettings } from "@/lib/settings";
|
||||
import { makeRealSettings, AI_PERSONALITIES } from "@/lib/settings";
|
||||
|
||||
export async function llm(
|
||||
userPrompt: string,
|
||||
onToken: (partialResponse: string, done?: boolean) => void,
|
||||
customPersonality?: string,
|
||||
) {
|
||||
// Validate the callback function
|
||||
if (typeof onToken !== 'function') {
|
||||
|
|
@ -38,11 +39,17 @@ export async function llm(
|
|||
if (!settings) {
|
||||
settings = {
|
||||
provider: 'openai',
|
||||
models: { openai: 'gpt-4o', anthropic: 'claude-3-5-sonnet-20241022' },
|
||||
keys: { openai: '', anthropic: '', google: '' }
|
||||
models: { openai: 'gpt-4o', anthropic: 'claude-sonnet-4-5-20250929' },
|
||||
keys: { openai: '', anthropic: '', google: '' },
|
||||
personality: 'web-developer'
|
||||
};
|
||||
}
|
||||
|
||||
// Override personality if custom personality is provided
|
||||
if (customPersonality) {
|
||||
settings.personality = customPersonality;
|
||||
}
|
||||
|
||||
const availableKeys = settings.keys || {}
|
||||
|
||||
// Get all available providers with valid keys
|
||||
|
|
@ -55,33 +62,72 @@ export async function llm(
|
|||
throw new Error("No valid API key found for any provider")
|
||||
}
|
||||
|
||||
// Try each provider in order until one succeeds
|
||||
// Try each provider/key combination in order until one succeeds
|
||||
let lastError: Error | null = null;
|
||||
const attemptedProviders: string[] = [];
|
||||
|
||||
// List of fallback models for Anthropic if the primary model fails
|
||||
// Try newest models first (Sonnet 4.5, then Sonnet 4), then fall back to older models
|
||||
const anthropicFallbackModels = [
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-20250522',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
];
|
||||
|
||||
for (const { provider, apiKey, model } of availableProviders) {
|
||||
try {
|
||||
console.log(`🔄 Attempting to use ${provider} API (${model})...`);
|
||||
attemptedProviders.push(provider);
|
||||
attemptedProviders.push(`${provider} (${model})`);
|
||||
|
||||
// Add retry logic for temporary failures
|
||||
await callProviderAPIWithRetry(provider, apiKey, model, userPrompt, onToken);
|
||||
console.log(`✅ Successfully used ${provider} API`);
|
||||
await callProviderAPIWithRetry(provider, apiKey, model, userPrompt, onToken, settings);
|
||||
console.log(`✅ Successfully used ${provider} API (${model})`);
|
||||
return; // Success, exit the function
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check if it's an authentication error (401, 403) - don't retry these
|
||||
if (errorMessage.includes('401') || errorMessage.includes('403') || errorMessage.includes('Unauthorized')) {
|
||||
console.warn(`❌ ${provider} API authentication failed (invalid API key):`, errorMessage);
|
||||
// Mark this API key as invalid for future attempts
|
||||
// Check if it's a model not found error (404) for Anthropic - try fallback models
|
||||
if (provider === 'anthropic' && (errorMessage.includes('404') || errorMessage.includes('not_found_error') || errorMessage.includes('model:'))) {
|
||||
console.warn(`❌ ${provider} model ${model} not found, trying fallback models...`);
|
||||
|
||||
// Try fallback models
|
||||
let fallbackSucceeded = false;
|
||||
for (const fallbackModel of anthropicFallbackModels) {
|
||||
if (fallbackModel === model) continue; // Skip the one we already tried
|
||||
|
||||
try {
|
||||
console.log(`🔄 Trying fallback model: ${fallbackModel}...`);
|
||||
attemptedProviders.push(`${provider} (${fallbackModel})`);
|
||||
await callProviderAPIWithRetry(provider, apiKey, fallbackModel, userPrompt, onToken, settings);
|
||||
console.log(`✅ Successfully used ${provider} API with fallback model ${fallbackModel}`);
|
||||
fallbackSucceeded = true;
|
||||
return; // Success, exit the function
|
||||
} catch (fallbackError) {
|
||||
const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||
console.warn(`❌ Fallback model ${fallbackModel} also failed:`, fallbackErrorMessage);
|
||||
// Continue to next fallback model
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackSucceeded) {
|
||||
console.warn(`❌ All ${provider} models failed`);
|
||||
lastError = error as Error;
|
||||
}
|
||||
} else if (errorMessage.includes('401') || errorMessage.includes('403') ||
|
||||
errorMessage.includes('Unauthorized') || errorMessage.includes('Invalid API key') ||
|
||||
errorMessage.includes('expired') || errorMessage.includes('Expired')) {
|
||||
console.warn(`❌ ${provider} API authentication failed (invalid/expired API key):`, errorMessage);
|
||||
// Mark this specific API key as invalid for future attempts
|
||||
markApiKeyAsInvalid(provider, apiKey);
|
||||
console.log(`🔄 Will try next available API key...`);
|
||||
lastError = error as Error;
|
||||
} else {
|
||||
console.warn(`❌ ${provider} API failed:`, errorMessage);
|
||||
console.warn(`❌ ${provider} API failed (non-auth error):`, errorMessage);
|
||||
lastError = error as Error;
|
||||
}
|
||||
|
||||
lastError = error as Error;
|
||||
// Continue to next provider
|
||||
// Continue to next provider/key
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,34 +137,51 @@ export async function llm(
|
|||
}
|
||||
|
||||
// Helper function to get all available providers with their keys and models
|
||||
// Now supports multiple keys per provider (stored as comma-separated or array)
|
||||
function getAvailableProviders(availableKeys: Record<string, string>, settings: any) {
|
||||
const providers = [];
|
||||
|
||||
// First, try the preferred provider
|
||||
if (settings.provider && availableKeys[settings.provider] && availableKeys[settings.provider].trim() !== '') {
|
||||
const apiKey = availableKeys[settings.provider];
|
||||
if (isValidApiKey(settings.provider, apiKey) && !isApiKeyInvalid(settings.provider, apiKey)) {
|
||||
// Helper to add a provider key if valid
|
||||
const addProviderKey = (provider: string, apiKey: string, model?: string) => {
|
||||
if (isValidApiKey(provider, apiKey) && !isApiKeyInvalid(provider, apiKey)) {
|
||||
providers.push({
|
||||
provider: settings.provider,
|
||||
provider: provider,
|
||||
apiKey: apiKey,
|
||||
model: settings.models[settings.provider] || getDefaultModel(settings.provider)
|
||||
model: model || settings.models[provider] || getDefaultModel(provider)
|
||||
});
|
||||
} else if (isApiKeyInvalid(settings.provider, apiKey)) {
|
||||
console.log(`⏭️ Skipping ${settings.provider} API key (marked as invalid)`);
|
||||
return true;
|
||||
} else if (isApiKeyInvalid(provider, apiKey)) {
|
||||
console.log(`⏭️ Skipping ${provider} API key (marked as invalid)`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// First, try the preferred provider - support multiple keys if stored as comma-separated
|
||||
if (settings.provider && availableKeys[settings.provider]) {
|
||||
const keyValue = availableKeys[settings.provider];
|
||||
// Check if it's a comma-separated list of keys
|
||||
if (keyValue.includes(',') && keyValue.trim() !== '') {
|
||||
const keys = keyValue.split(',').map(k => k.trim()).filter(k => k !== '');
|
||||
for (const apiKey of keys) {
|
||||
addProviderKey(settings.provider, apiKey);
|
||||
}
|
||||
} else if (keyValue.trim() !== '') {
|
||||
addProviderKey(settings.provider, keyValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Then add all other available providers (excluding the preferred one)
|
||||
// Support multiple keys per provider
|
||||
for (const [key, value] of Object.entries(availableKeys)) {
|
||||
if (typeof value === 'string' && value.trim() !== '' && key !== settings.provider) {
|
||||
if (isValidApiKey(key, value) && !isApiKeyInvalid(key, value)) {
|
||||
providers.push({
|
||||
provider: key,
|
||||
apiKey: value,
|
||||
model: settings.models[key] || getDefaultModel(key)
|
||||
});
|
||||
} else if (isApiKeyInvalid(key, value)) {
|
||||
console.log(`⏭️ Skipping ${key} API key (marked as invalid)`);
|
||||
if (key !== settings.provider && typeof value === 'string' && value.trim() !== '') {
|
||||
// Check if it's a comma-separated list of keys
|
||||
if (value.includes(',')) {
|
||||
const keys = value.split(',').map(k => k.trim()).filter(k => k !== '');
|
||||
for (const apiKey of keys) {
|
||||
addProviderKey(key, apiKey);
|
||||
}
|
||||
} else {
|
||||
addProviderKey(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -308,13 +371,14 @@ async function callProviderAPIWithRetry(
|
|||
model: string,
|
||||
userPrompt: string,
|
||||
onToken: (partialResponse: string, done?: boolean) => void,
|
||||
settings?: any,
|
||||
maxRetries: number = 2
|
||||
) {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await callProviderAPI(provider, apiKey, model, userPrompt, onToken);
|
||||
await callProviderAPI(provider, apiKey, model, userPrompt, onToken, settings);
|
||||
return; // Success
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
|
@ -387,15 +451,30 @@ function isApiKeyInvalid(provider: string, apiKey: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Helper function to get system prompt based on personality
|
||||
function getSystemPrompt(settings: any): string {
|
||||
const personality = settings.personality || 'web-developer';
|
||||
const personalityConfig = AI_PERSONALITIES.find(p => p.id === personality);
|
||||
|
||||
if (personalityConfig) {
|
||||
return personalityConfig.systemPrompt;
|
||||
}
|
||||
|
||||
// Fallback to custom system prompt or default
|
||||
return settings.prompts?.system || 'You are a helpful assistant.';
|
||||
}
|
||||
|
||||
// Helper function to call the appropriate provider API
|
||||
async function callProviderAPI(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
userPrompt: string,
|
||||
onToken: (partialResponse: string, done?: boolean) => void
|
||||
onToken: (partialResponse: string, done?: boolean) => void,
|
||||
settings?: any
|
||||
) {
|
||||
let partial = "";
|
||||
const systemPrompt = settings ? getSystemPrompt(settings) : 'You are a helpful assistant.';
|
||||
|
||||
if (provider === 'openai') {
|
||||
const openai = new OpenAI({
|
||||
|
|
@ -406,7 +485,7 @@ async function callProviderAPI(
|
|||
const stream = await openai.chat.completions.create({
|
||||
model: model,
|
||||
messages: [
|
||||
{ role: "system", content: 'You are a helpful assistant.' },
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
stream: true,
|
||||
|
|
@ -423,22 +502,53 @@ async function callProviderAPI(
|
|||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
|
||||
const stream = await anthropic.messages.create({
|
||||
model: model,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{ role: "user", content: userPrompt }
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
||||
const content = chunk.delta.text || "";
|
||||
partial += content;
|
||||
onToken(partial, false);
|
||||
// Anthropic now supports system messages in the API
|
||||
// Try with system message first, fallback to prepending if needed
|
||||
try {
|
||||
const stream = await anthropic.messages.create({
|
||||
model: model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{ role: "user", content: userPrompt }
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
||||
const content = chunk.delta.text || "";
|
||||
partial += content;
|
||||
onToken(partial, false);
|
||||
}
|
||||
}
|
||||
} catch (systemError: any) {
|
||||
// If system message fails, try without it (for older API versions)
|
||||
if (systemError.message && systemError.message.includes('system')) {
|
||||
const fullPrompt = `${systemPrompt}\n\nUser: ${userPrompt}`;
|
||||
const stream = await anthropic.messages.create({
|
||||
model: model,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{ role: "user", content: fullPrompt }
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
||||
const content = chunk.delta.text || "";
|
||||
partial += content;
|
||||
onToken(partial, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw systemError;
|
||||
}
|
||||
}
|
||||
// Call onToken with done=true after streaming completes
|
||||
onToken(partial, true);
|
||||
return; // Exit early since we handle streaming above
|
||||
} else {
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
|
|
@ -460,6 +570,20 @@ async function autoMigrateAPIKeys() {
|
|||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.keys && (parsed.keys.openai || parsed.keys.anthropic)) {
|
||||
// Migrate invalid model names
|
||||
if (parsed.models) {
|
||||
let needsUpdate = false;
|
||||
// Migrate any invalid claude-3-5-sonnet models to claude-3-opus (which works)
|
||||
if (parsed.models.anthropic === 'claude-3-5-sonnet-20241022' ||
|
||||
parsed.models.anthropic === 'claude-3-5-sonnet-20240620') {
|
||||
parsed.models.anthropic = 'claude-3-opus-20240229';
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (needsUpdate) {
|
||||
localStorage.setItem("openai_api_key", JSON.stringify(parsed));
|
||||
console.log('🔄 Migrated invalid Anthropic model name to claude-3-opus-20240229');
|
||||
}
|
||||
}
|
||||
return; // Already migrated
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -479,7 +603,7 @@ async function autoMigrateAPIKeys() {
|
|||
provider: provider,
|
||||
models: {
|
||||
openai: 'gpt-4o',
|
||||
anthropic: 'claude-3-5-sonnet-20241022',
|
||||
anthropic: 'claude-sonnet-4-5-20250929',
|
||||
google: 'gemini-1.5-flash'
|
||||
},
|
||||
keys: {
|
||||
|
|
@ -506,7 +630,8 @@ function getDefaultModel(provider: string): string {
|
|||
case 'openai':
|
||||
return 'gpt-4o'
|
||||
case 'anthropic':
|
||||
return 'claude-3-5-sonnet-20241022'
|
||||
// Use Claude Sonnet 4.5 as default (newest and best model)
|
||||
return 'claude-sonnet-4-5-20250929'
|
||||
default:
|
||||
return 'gpt-4o'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TLAssetStore, uniqueId } from 'tldraw'
|
||||
import { WORKER_URL } from '../routes/Board'
|
||||
import { WORKER_URL } from '../constants/workerUrl'
|
||||
|
||||
export const multiplayerAssetStore: TLAssetStore = {
|
||||
async upload(_asset, file) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
import { Editor, TLShape, Box } from "@tldraw/tldraw"
|
||||
|
||||
/**
|
||||
* Check if two boxes overlap
|
||||
*/
|
||||
function boxesOverlap(
|
||||
box1: { x: number; y: number; w: number; h: number },
|
||||
box2: { x: number; y: number; w: number; h: number },
|
||||
padding: number = 10
|
||||
): boolean {
|
||||
return !(
|
||||
box1.x + box1.w + padding < box2.x - padding ||
|
||||
box1.x - padding > box2.x + box2.w + padding ||
|
||||
box1.y + box1.h + padding < box2.y - padding ||
|
||||
box1.y - padding > box2.y + box2.h + padding
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bounding box of a shape
|
||||
*/
|
||||
function getShapeBounds(editor: Editor, shape: TLShape | string): Box | null {
|
||||
const shapeId = typeof shape === 'string' ? shape : shape.id
|
||||
return editor.getShapePageBounds(shapeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shape overlaps with any other custom shapes and move it aside if needed
|
||||
*/
|
||||
export function resolveOverlaps(editor: Editor, shapeId: string): void {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat', 'FathomTranscript',
|
||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'SharedPiano', 'MycrozineTemplate', 'ChatBox'
|
||||
]
|
||||
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape || !customShapeTypes.includes(shape.type)) return
|
||||
|
||||
const shapeBounds = getShapeBounds(editor, shape)
|
||||
if (!shapeBounds) return
|
||||
|
||||
const shapeBox = {
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
w: shapeBounds.w,
|
||||
h: shapeBounds.h
|
||||
}
|
||||
|
||||
// Check all other custom shapes for overlaps
|
||||
const otherShapes = allShapes.filter(
|
||||
s => s.id !== shapeId && customShapeTypes.includes(s.type)
|
||||
)
|
||||
|
||||
for (const otherShape of otherShapes) {
|
||||
const otherBounds = getShapeBounds(editor, otherShape)
|
||||
if (!otherBounds) continue
|
||||
|
||||
const otherBox = {
|
||||
x: otherShape.x,
|
||||
y: otherShape.y,
|
||||
w: otherBounds.w,
|
||||
h: otherBounds.h
|
||||
}
|
||||
|
||||
if (boxesOverlap(shapeBox, otherBox, 20)) {
|
||||
// Simple solution: move the shape to the right of the overlapping shape
|
||||
const newX = otherBox.x + otherBox.w + 20
|
||||
const newY = shapeBox.y // Keep same Y position
|
||||
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: shape.type,
|
||||
x: newX,
|
||||
y: newY,
|
||||
})
|
||||
|
||||
// Recursively check if the new position also overlaps (shouldn't happen often)
|
||||
const newBounds = getShapeBounds(editor, shapeId)
|
||||
if (newBounds) {
|
||||
const newShapeBox = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
w: newBounds.w,
|
||||
h: newBounds.h
|
||||
}
|
||||
|
||||
// If still overlapping, try moving down instead
|
||||
if (boxesOverlap(newShapeBox, otherBox, 20)) {
|
||||
const newY2 = otherBox.y + otherBox.h + 20
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: shape.type,
|
||||
x: shapeBox.x, // Keep original X
|
||||
y: newY2,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Only resolve one overlap at a time to avoid infinite loops
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a non-overlapping position for a new shape using spiral search
|
||||
*/
|
||||
export function findNonOverlappingPosition(
|
||||
editor: Editor,
|
||||
baseX: number,
|
||||
baseY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
excludeShapeIds: string[] = []
|
||||
): { x: number; y: number } {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat', 'FathomTranscript',
|
||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'SharedPiano', 'MycrozineTemplate', 'ChatBox'
|
||||
]
|
||||
|
||||
const existingShapes = allShapes.filter(
|
||||
s => !excludeShapeIds.includes(s.id) && customShapeTypes.includes(s.type)
|
||||
)
|
||||
|
||||
const padding = 20
|
||||
const stepSize = Math.max(width, height) + padding
|
||||
|
||||
// Helper function to check if a position overlaps with any existing shape
|
||||
const positionOverlaps = (x: number, y: number): boolean => {
|
||||
const testBox = { x, y, w: width, h: height }
|
||||
|
||||
for (const existingShape of existingShapes) {
|
||||
const shapeBounds = getShapeBounds(editor, existingShape)
|
||||
if (shapeBounds) {
|
||||
const existingBox = {
|
||||
x: existingShape.x,
|
||||
y: existingShape.y,
|
||||
w: shapeBounds.w,
|
||||
h: shapeBounds.h
|
||||
}
|
||||
|
||||
if (boxesOverlap(testBox, existingBox, padding)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// First, check the base position
|
||||
if (!positionOverlaps(baseX, baseY)) {
|
||||
return { x: baseX, y: baseY }
|
||||
}
|
||||
|
||||
// Spiral search pattern: check positions in expanding circles
|
||||
// Try positions: right, down, left, up, then expand radius
|
||||
const directions = [
|
||||
{ dx: stepSize, dy: 0 }, // Right
|
||||
{ dx: 0, dy: stepSize }, // Down
|
||||
{ dx: -stepSize, dy: 0 }, // Left
|
||||
{ dx: 0, dy: -stepSize }, // Up
|
||||
{ dx: stepSize, dy: stepSize }, // Down-right
|
||||
{ dx: -stepSize, dy: stepSize }, // Down-left
|
||||
{ dx: -stepSize, dy: -stepSize }, // Up-left
|
||||
{ dx: stepSize, dy: -stepSize }, // Up-right
|
||||
]
|
||||
|
||||
// Try positions at increasing distances
|
||||
for (let radius = 1; radius <= 10; radius++) {
|
||||
for (const dir of directions) {
|
||||
const testX = baseX + dir.dx * radius
|
||||
const testY = baseY + dir.dy * radius
|
||||
|
||||
if (!positionOverlaps(testX, testY)) {
|
||||
return { x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all positions overlap (unlikely), return a position far to the right
|
||||
return { x: baseX + stepSize * 10, y: baseY }
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { TLBookmarkAsset, AssetRecordType, getHashForString } from "tldraw"
|
||||
import { WORKER_URL } from "../routes/Board"
|
||||
import { WORKER_URL } from "../constants/workerUrl"
|
||||
|
||||
export async function unfurlBookmarkUrl({
|
||||
url,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Worker Environment Switcher
|
||||
# Usage: ./switch-worker-env.sh [local|dev|production]
|
||||
|
||||
ENV=${1:-dev}
|
||||
|
||||
case $ENV in
|
||||
"local")
|
||||
echo "🔧 Switching to LOCAL worker environment"
|
||||
echo "VITE_WORKER_ENV=local" > .env.local
|
||||
echo "✅ Set to use local worker on port 5172"
|
||||
echo "📝 Make sure to run: npm run dev:worker:local"
|
||||
;;
|
||||
"dev")
|
||||
echo "🔧 Switching to DEV worker environment"
|
||||
echo "VITE_WORKER_ENV=dev" > .env.local
|
||||
echo "✅ Set to use Cloudflare dev environment"
|
||||
echo "🌐 URL: https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev"
|
||||
;;
|
||||
"production")
|
||||
echo "🔧 Switching to PRODUCTION worker environment"
|
||||
echo "VITE_WORKER_ENV=production" > .env.local
|
||||
echo "✅ Set to use production environment"
|
||||
echo "🌐 URL: https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Invalid environment. Use: local, dev, or production"
|
||||
echo "📖 Available environments:"
|
||||
echo " local - Use local worker (port 5172)"
|
||||
echo " dev - Use Cloudflare dev environment"
|
||||
echo " production - Use production environment"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🔄 Restart your dev server to apply changes:"
|
||||
echo " npm run dev"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Test script for data conversion edge cases
|
||||
* This script tests the conversion logic with various malformed data scenarios
|
||||
*/
|
||||
|
||||
// Mock the conversion functions to test them
|
||||
// In a real scenario, these would be imported from AutomergeDurableObject
|
||||
|
||||
interface ConversionStats {
|
||||
total: number
|
||||
converted: number
|
||||
skipped: number
|
||||
errors: number
|
||||
errorDetails: string[]
|
||||
}
|
||||
|
||||
// Test cases for edge cases
|
||||
const testCases = {
|
||||
// Test case 1: Missing state.id
|
||||
missingStateId: {
|
||||
documents: [
|
||||
{ state: { typeName: 'shape', x: 0, y: 0 } }, // Missing id
|
||||
{ state: { id: 'shape:test1', typeName: 'shape', x: 0, y: 0 } } // Valid
|
||||
]
|
||||
},
|
||||
|
||||
// Test case 2: Missing state.typeName
|
||||
missingTypeName: {
|
||||
documents: [
|
||||
{ state: { id: 'shape:test2', x: 0, y: 0 } }, // Missing typeName
|
||||
{ state: { id: 'shape:test3', typeName: 'shape', x: 0, y: 0 } } // Valid
|
||||
]
|
||||
},
|
||||
|
||||
// Test case 3: Null/undefined records
|
||||
nullRecords: {
|
||||
documents: [
|
||||
null,
|
||||
undefined,
|
||||
{ state: { id: 'shape:test4', typeName: 'shape', x: 0, y: 0 } }
|
||||
]
|
||||
},
|
||||
|
||||
// Test case 4: Missing state property
|
||||
missingState: {
|
||||
documents: [
|
||||
{ id: 'shape:test5' }, // Missing state
|
||||
{ state: { id: 'shape:test6', typeName: 'shape', x: 0, y: 0 } } // Valid
|
||||
]
|
||||
},
|
||||
|
||||
// Test case 5: Invalid ID type
|
||||
invalidIdType: {
|
||||
documents: [
|
||||
{ state: { id: 12345, typeName: 'shape', x: 0, y: 0 } }, // ID is number, not string
|
||||
{ state: { id: 'shape:test7', typeName: 'shape', x: 0, y: 0 } } // Valid
|
||||
]
|
||||
},
|
||||
|
||||
// Test case 6: Custom records (obsidian_vault)
|
||||
customRecords: {
|
||||
documents: [
|
||||
{ state: { id: 'obsidian_vault:test', typeName: 'obsidian_vault', data: {} } },
|
||||
{ state: { id: 'shape:test8', typeName: 'shape', x: 0, y: 0 } }
|
||||
]
|
||||
},
|
||||
|
||||
// Test case 7: Malformed shapes (missing required properties)
|
||||
malformedShapes: {
|
||||
documents: [
|
||||
{ state: { id: 'shape:test9', typeName: 'shape' } }, // Missing x, y
|
||||
{ state: { id: 'shape:test10', typeName: 'shape', x: 0 } }, // Missing y
|
||||
{ state: { id: 'shape:test11', typeName: 'shape', x: 0, y: 0, type: 'geo', w: 100, h: 100 } } // w/h at top level
|
||||
]
|
||||
},
|
||||
|
||||
// Test case 8: Empty documents array
|
||||
emptyDocuments: {
|
||||
documents: []
|
||||
},
|
||||
|
||||
// Test case 9: Mixed valid and invalid
|
||||
mixedValidInvalid: {
|
||||
documents: [
|
||||
{ state: { id: 'shape:valid1', typeName: 'shape', x: 0, y: 0 } },
|
||||
null,
|
||||
{ state: { id: 'shape:valid2', typeName: 'shape', x: 10, y: 20 } },
|
||||
{ state: { typeName: 'shape' } }, // Missing id
|
||||
{ state: { id: 'shape:valid3', typeName: 'shape', x: 30, y: 40 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Expected results for validation
|
||||
const expectedResults = {
|
||||
missingStateId: {
|
||||
converted: 1,
|
||||
skipped: 1,
|
||||
errors: 0
|
||||
},
|
||||
missingTypeName: {
|
||||
converted: 1,
|
||||
skipped: 1,
|
||||
errors: 0
|
||||
},
|
||||
nullRecords: {
|
||||
converted: 1,
|
||||
skipped: 2,
|
||||
errors: 0
|
||||
},
|
||||
missingState: {
|
||||
converted: 1,
|
||||
skipped: 1,
|
||||
errors: 0
|
||||
},
|
||||
invalidIdType: {
|
||||
converted: 1,
|
||||
skipped: 1,
|
||||
errors: 0
|
||||
},
|
||||
customRecords: {
|
||||
converted: 2, // Both should be converted
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
customRecordCount: 1
|
||||
},
|
||||
malformedShapes: {
|
||||
converted: 3, // All should be converted (shape migration will fix them)
|
||||
skipped: 0,
|
||||
errors: 0
|
||||
},
|
||||
emptyDocuments: {
|
||||
converted: 0,
|
||||
skipped: 0,
|
||||
errors: 0
|
||||
},
|
||||
mixedValidInvalid: {
|
||||
converted: 3,
|
||||
skipped: 2,
|
||||
errors: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate the migration function (simplified version)
|
||||
function simulateMigrateDocumentsToStore(oldDoc: any): { store: any, stats: ConversionStats } {
|
||||
const newDoc = {
|
||||
store: {},
|
||||
schema: { version: 1, recordVersions: {} }
|
||||
}
|
||||
|
||||
const stats: ConversionStats = {
|
||||
total: 0,
|
||||
converted: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
errorDetails: []
|
||||
}
|
||||
|
||||
if (oldDoc.documents && Array.isArray(oldDoc.documents)) {
|
||||
stats.total = oldDoc.documents.length
|
||||
|
||||
oldDoc.documents.forEach((doc: any, index: number) => {
|
||||
try {
|
||||
if (!doc) {
|
||||
stats.skipped++
|
||||
stats.errorDetails.push(`Document at index ${index} is null or undefined`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!doc.state) {
|
||||
stats.skipped++
|
||||
stats.errorDetails.push(`Document at index ${index} missing state property`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!doc.state.id) {
|
||||
stats.skipped++
|
||||
stats.errorDetails.push(`Document at index ${index} missing state.id`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!doc.state.typeName) {
|
||||
stats.skipped++
|
||||
stats.errorDetails.push(`Document at index ${index} missing state.typeName (id: ${doc.state.id})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof doc.state.id !== 'string') {
|
||||
stats.skipped++
|
||||
stats.errorDetails.push(`Document at index ${index} has invalid state.id type: ${typeof doc.state.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
(newDoc.store as any)[doc.state.id] = doc.state
|
||||
stats.converted++
|
||||
} catch (error) {
|
||||
stats.errors++
|
||||
const errorMsg = `Error migrating document at index ${index}: ${error instanceof Error ? error.message : String(error)}`
|
||||
stats.errorDetails.push(errorMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { store: newDoc.store, stats }
|
||||
}
|
||||
|
||||
// Run tests
|
||||
console.log('🧪 Testing data conversion edge cases...\n')
|
||||
|
||||
let passedTests = 0
|
||||
let failedTests = 0
|
||||
|
||||
for (const [testName, testCase] of Object.entries(testCases)) {
|
||||
console.log(`\n📋 Test: ${testName}`)
|
||||
const result = simulateMigrateDocumentsToStore(testCase)
|
||||
const expected = expectedResults[testName as keyof typeof expectedResults]
|
||||
|
||||
if (expected) {
|
||||
const passed =
|
||||
result.stats.converted === expected.converted &&
|
||||
result.stats.skipped === expected.skipped &&
|
||||
result.stats.errors === expected.errors
|
||||
|
||||
if (passed) {
|
||||
console.log(`✅ PASSED`)
|
||||
passedTests++
|
||||
} else {
|
||||
console.log(`❌ FAILED`)
|
||||
console.log(` Expected: converted=${expected.converted}, skipped=${expected.skipped}, errors=${expected.errors}`)
|
||||
console.log(` Got: converted=${result.stats.converted}, skipped=${result.stats.skipped}, errors=${result.stats.errors}`)
|
||||
failedTests++
|
||||
}
|
||||
|
||||
// Check custom records if expected
|
||||
if (expected.customRecordCount !== undefined) {
|
||||
const customRecords = Object.values(result.store).filter((r: any) =>
|
||||
r.id && typeof r.id === 'string' && r.id.startsWith('obsidian_vault:')
|
||||
)
|
||||
if (customRecords.length === expected.customRecordCount) {
|
||||
console.log(`✅ Custom records check passed: ${customRecords.length}`)
|
||||
} else {
|
||||
console.log(`❌ Custom records check failed: expected ${expected.customRecordCount}, got ${customRecords.length}`)
|
||||
failedTests++
|
||||
}
|
||||
}
|
||||
|
||||
if (result.stats.errorDetails.length > 0) {
|
||||
console.log(` Warnings: ${result.stats.errorDetails.length} (showing first 3)`)
|
||||
result.stats.errorDetails.slice(0, 3).forEach((detail, i) => {
|
||||
console.log(` ${i + 1}. ${detail}`)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ No expected results defined for this test`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Test Summary:`)
|
||||
console.log(` ✅ Passed: ${passedTests}`)
|
||||
console.log(` ❌ Failed: ${failedTests}`)
|
||||
console.log(` 📈 Total: ${passedTests + failedTests}`)
|
||||
|
||||
if (failedTests === 0) {
|
||||
console.log(`\n🎉 All tests passed!`)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.log(`\n⚠️ Some tests failed. Review the output above.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
// Test script for holon data loading with ID 1002848305066
|
||||
import { holosphereService } from './src/lib/HoloSphereService'
|
||||
|
||||
const HOLON_ID = '1002848305066'
|
||||
|
||||
async function testHolonDataLoading() {
|
||||
console.log('🧪 Testing Holon Data Loading')
|
||||
console.log('================================')
|
||||
console.log(`Testing with Holon ID: ${HOLON_ID}`)
|
||||
console.log('')
|
||||
|
||||
try {
|
||||
// Initialize the service
|
||||
const isInitialized = await holosphereService.initialize()
|
||||
console.log('✅ HoloSphere initialized:', isInitialized)
|
||||
|
||||
if (!isInitialized) {
|
||||
console.log('❌ HoloSphere not initialized, cannot proceed')
|
||||
return
|
||||
}
|
||||
|
||||
// List of lenses to check
|
||||
const lensesToCheck = [
|
||||
'active_users',
|
||||
'users',
|
||||
'rankings',
|
||||
'stats',
|
||||
'tasks',
|
||||
'progress',
|
||||
'events',
|
||||
'activities',
|
||||
'items',
|
||||
'shopping',
|
||||
'active_items',
|
||||
'proposals',
|
||||
'offers',
|
||||
'requests',
|
||||
'checklists',
|
||||
'roles'
|
||||
]
|
||||
|
||||
console.log(`\n📂 Checking ${lensesToCheck.length} data categories...\n`)
|
||||
|
||||
const allData = {}
|
||||
|
||||
for (const lens of lensesToCheck) {
|
||||
console.log(`\n📂 Checking lens: ${lens}`)
|
||||
console.log('----------------------------')
|
||||
|
||||
try {
|
||||
// Test the new getDataWithWait method
|
||||
const startTime = Date.now()
|
||||
const lensData = await holosphereService.getDataWithWait(HOLON_ID, lens, 2000)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (lensData && Object.keys(lensData).length > 0) {
|
||||
console.log(`✅ Found data in ${lens} (${duration}ms)`)
|
||||
console.log(` Keys: ${Object.keys(lensData).length}`)
|
||||
console.log(` Sample keys: ${Object.keys(lensData).slice(0, 5).join(', ')}`)
|
||||
allData[lens] = lensData
|
||||
} else {
|
||||
console.log(`⚠️ No data found in ${lens} (${duration}ms)`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`❌ Error loading ${lens}:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n\n📊 SUMMARY')
|
||||
console.log('================================')
|
||||
console.log(`Total categories with data: ${Object.keys(allData).length}`)
|
||||
|
||||
if (Object.keys(allData).length > 0) {
|
||||
console.log('\n✅ Categories with data:')
|
||||
for (const [lens, data] of Object.entries(allData)) {
|
||||
console.log(` - ${lens}: ${Object.keys(data).length} entries`)
|
||||
}
|
||||
|
||||
console.log('\n📄 Sample data from first category:')
|
||||
const firstLens = Object.keys(allData)[0]
|
||||
const firstData = allData[firstLens]
|
||||
const firstKey = Object.keys(firstData)[0]
|
||||
console.log(` Lens: ${firstLens}`)
|
||||
console.log(` Key: ${firstKey}`)
|
||||
console.log(` Value:`, JSON.stringify(firstData[firstKey], null, 2).substring(0, 200))
|
||||
} else {
|
||||
console.log('\n⚠️ No data found in any category')
|
||||
console.log(' This could mean:')
|
||||
console.log(' 1. The holon ID has no data stored yet')
|
||||
console.log(' 2. The Gun network is not accessible')
|
||||
console.log(' 3. The data is stored under different lens names')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed with error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testHolonDataLoading().then(() => {
|
||||
console.log('\n🏁 Test complete')
|
||||
process.exit(0)
|
||||
}).catch(err => {
|
||||
console.error('❌ Test crashed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
# Testing R2 Data Conversion
|
||||
|
||||
This guide helps you test the data conversion from old tldraw sync format to new automerge sync format using actual data from your R2 bucket.
|
||||
|
||||
## Overview
|
||||
|
||||
The conversion system handles three data formats:
|
||||
1. **Automerge Array Format**: `[{ state: {...} }, ...]`
|
||||
2. **Store Format**: `{ store: { "recordId": {...}, ... }, schema: {...} }` (already converted)
|
||||
3. **Old Documents Format**: `{ documents: [{ state: {...} }, ...] }` (legacy tldraw sync)
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Identify Test Rooms
|
||||
|
||||
First, identify rooms in your R2 bucket that use the old format:
|
||||
|
||||
```bash
|
||||
# List all rooms in R2
|
||||
# You can use wrangler CLI or Cloudflare dashboard
|
||||
wrangler r2 object list TLDRAW_BUCKET --prefix "rooms/"
|
||||
```
|
||||
|
||||
### 2. Check Data Format
|
||||
|
||||
For each room, check its format:
|
||||
|
||||
```typescript
|
||||
// Example: Check a room's format
|
||||
const roomId = "your-room-id"
|
||||
const doc = await r2.get(`rooms/${roomId}`)
|
||||
const data = await doc.json()
|
||||
|
||||
// Check format
|
||||
if (Array.isArray(data)) {
|
||||
console.log("Format: Automerge Array")
|
||||
} else if (data.store) {
|
||||
console.log("Format: Store Format (already converted)")
|
||||
} else if (data.documents) {
|
||||
console.log("Format: Old Documents Format (needs conversion)")
|
||||
} else {
|
||||
console.log("Format: Unknown")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Conversion
|
||||
|
||||
The conversion happens automatically when a room is loaded. To test:
|
||||
|
||||
1. **Load the room in your app** - The `AutomergeDurableObject.getDocument()` method will automatically detect and convert the format
|
||||
2. **Check the logs** - Look for conversion statistics in the worker logs:
|
||||
- `📊 Automerge to Store conversion statistics`
|
||||
- `📊 Documents to Store migration statistics`
|
||||
- `📊 Shape migration statistics`
|
||||
|
||||
### 4. Verify Data Integrity
|
||||
|
||||
After conversion, verify:
|
||||
|
||||
1. **All shapes are present**: Check that shape count matches
|
||||
2. **Custom shapes preserved**: Verify ObsNote, Holon, etc. have all their properties
|
||||
3. **Custom records preserved**: Check that obsidian_vault records are present
|
||||
4. **No validation errors**: Shapes should render without errors
|
||||
|
||||
## Expected Log Output
|
||||
|
||||
When a room is converted, you should see logs like:
|
||||
|
||||
```
|
||||
Converting Automerge document format to store format for room abc123
|
||||
📊 Automerge to Store conversion statistics: {
|
||||
total: 150,
|
||||
converted: 148,
|
||||
skipped: 2,
|
||||
errors: 0,
|
||||
storeKeys: 148,
|
||||
customRecordCount: 1,
|
||||
customRecordIds: ['obsidian_vault:test'],
|
||||
errorCount: 0
|
||||
}
|
||||
✅ Verified 1 custom records preserved during conversion
|
||||
|
||||
🔄 Server-side: Starting shape migration for room abc123
|
||||
📊 Shape migration statistics: {
|
||||
total: 120,
|
||||
migrated: 45,
|
||||
skipped: 75,
|
||||
errors: 0,
|
||||
shapeTypes: { geo: 50, arrow: 20, ObsNote: 10, ... },
|
||||
customShapesCount: 10,
|
||||
customShapeIds: ['shape:obs1', 'shape:holon1', ...],
|
||||
errorCount: 0
|
||||
}
|
||||
✅ Verified 10 custom shapes preserved during migration
|
||||
```
|
||||
|
||||
## Manual Testing Script
|
||||
|
||||
You can create a test script to verify conversion:
|
||||
|
||||
```typescript
|
||||
// test-r2-room.ts
|
||||
import { AutomergeDurableObject } from './worker/AutomergeDurableObject'
|
||||
|
||||
async function testRoomConversion(roomId: string) {
|
||||
// This would need to be run in a Cloudflare Worker context
|
||||
// or use wrangler dev to test locally
|
||||
|
||||
const env = {
|
||||
TLDRAW_BUCKET: yourR2Bucket
|
||||
}
|
||||
|
||||
// Create a mock Durable Object state
|
||||
const ctx = {
|
||||
storage: {
|
||||
get: async (key: string) => roomId,
|
||||
put: async (key: string, value: any) => {}
|
||||
},
|
||||
blockConcurrencyWhile: async (fn: () => Promise<void>) => await fn()
|
||||
}
|
||||
|
||||
const do = new AutomergeDurableObject(ctx as any, env as any)
|
||||
|
||||
// Load and convert
|
||||
const doc = await do.getDocument()
|
||||
|
||||
// Verify
|
||||
console.log('Conversion complete:', {
|
||||
storeKeys: Object.keys(doc.store).length,
|
||||
shapes: Object.values(doc.store).filter((r: any) => r.typeName === 'shape').length,
|
||||
customRecords: Object.values(doc.store).filter((r: any) =>
|
||||
r.id && typeof r.id === 'string' && r.id.startsWith('obsidian_vault:')
|
||||
).length
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Records are skipped during conversion
|
||||
|
||||
**Cause**: Missing required fields (id, typeName, state)
|
||||
|
||||
**Solution**: Check the error details in logs. The conversion will skip invalid records but log warnings.
|
||||
|
||||
### Issue: Custom shapes missing properties
|
||||
|
||||
**Cause**: Shape migration may have failed
|
||||
|
||||
**Solution**: Check shape migration logs. Custom shape props should be preserved automatically.
|
||||
|
||||
### Issue: Custom records (obsidian_vault) missing
|
||||
|
||||
**Cause**: They were filtered out during conversion
|
||||
|
||||
**Solution**: This shouldn't happen - custom records are preserved. Check logs for `customRecordCount`.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
After conversion, verify:
|
||||
|
||||
- [ ] All shapes are present (count matches)
|
||||
- [ ] Custom shapes (ObsNote, Holon, etc.) have all properties
|
||||
- [ ] Custom records (obsidian_vault) are preserved
|
||||
- [ ] No validation errors when loading the room
|
||||
- [ ] Shapes render correctly in the UI
|
||||
- [ ] All text content is preserved
|
||||
- [ ] All metadata is preserved
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If conversion fails:
|
||||
|
||||
1. The original data in R2 is **not modified** until the first save
|
||||
2. You can restore from backup if needed
|
||||
3. Check worker logs for specific errors
|
||||
4. The conversion creates a new document if it fails, so original data is safe
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Test with a few sample rooms first
|
||||
2. Monitor logs for any warnings or errors
|
||||
3. Verify data integrity after conversion
|
||||
4. Once confident, the conversion will happen automatically for all rooms
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue