Merge pull request #6 from Jeff-Emmett/automerge/obsidian/transcribe/AI-API-attempt

Automerge/obsidian/transcribe/ai api attempt
This commit is contained in:
Jeff Emmett 2025-11-10 11:54:11 -08:00 committed by GitHub
commit 2b8ae53d9e
134 changed files with 29881 additions and 1255 deletions

54
.github/workflows/quartz-sync.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Quartz Sync
on:
push:
paths:
- 'content/**'
- 'src/lib/quartzSync.ts'
workflow_dispatch:
inputs:
note_id:
description: 'Specific note ID to sync'
required: false
type: string
jobs:
sync-quartz:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Quartz
run: |
npx quartz build
env:
QUARTZ_PUBLISH: true
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
cname: ${{ secrets.QUARTZ_DOMAIN }}
- name: Notify sync completion
if: always()
run: |
echo "Quartz sync completed at $(date)"
echo "Triggered by: ${{ github.event_name }}"
echo "Commit: ${{ github.sha }}"

37
CLOUDFLARE_PAGES_SETUP.md Normal file
View File

@ -0,0 +1,37 @@
# Cloudflare Pages Configuration
## Issue
Cloudflare Pages cannot use the same `wrangler.toml` file as Workers because:
- `wrangler.toml` contains Worker-specific configuration (main, account_id, triggers, etc.)
- Pages projects have different configuration requirements
- Pages cannot have both `main` and `pages_build_output_dir` in the same file
## Solution: Configure in Cloudflare Dashboard
Since `wrangler.toml` is for Workers only, configure Pages settings in the Cloudflare Dashboard:
### Steps:
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/)
2. Navigate to **Pages** → Your Project
3. Go to **Settings** → **Builds & deployments**
4. Configure:
- **Build command**: `npm run build`
- **Build output directory**: `dist`
- **Root directory**: `/` (or leave empty)
5. Save settings
### Alternative: Use Environment Variables
If you need to configure Pages via code, you can set environment variables in the Cloudflare Pages dashboard under **Settings****Environment variables**.
## Worker Deployment
Workers are deployed separately using:
```bash
npm run deploy:worker
```
or
```bash
wrangler deploy
```
The `wrangler.toml` file is used only for Worker deployments, not Pages.

186
DATA_CONVERSION_GUIDE.md Normal file
View File

@ -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

141
DATA_CONVERSION_SUMMARY.md Normal file
View File

@ -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

139
FATHOM_INTEGRATION.md Normal file
View File

@ -0,0 +1,139 @@
# 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

232
QUARTZ_SYNC_SETUP.md Normal file
View File

@ -0,0 +1,232 @@
# Quartz Database Setup Guide
This guide explains how to set up a Quartz database with read/write permissions for your canvas website. Based on the [Quartz static site generator](https://quartz.jzhao.xyz/) architecture, there are several approaches available.
## Overview
Quartz is a static site generator that transforms Markdown content into websites. To enable read/write functionality, we've implemented multiple sync approaches that work with Quartz's architecture.
## Setup Options
### 1. GitHub Integration (Recommended)
This is the most natural approach since Quartz is designed to work with GitHub repositories.`
#### Prerequisites
- A GitHub repository containing your Quartz site
- A GitHub Personal Access Token with repository write permissions
#### Setup Steps
1. **Create a GitHub Personal Access Token:**
- Go to GitHub Settings → Developer settings → Personal access tokens
- Generate a new token with `repo` permissions for the Jeff-Emmett/quartz repository
- Copy the token
2. **Configure Environment Variables:**
Create a `.env.local` file in your project root with:
```bash
# GitHub Integration for Jeff-Emmett/quartz
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here
NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz
```
**Important:** Replace `your_github_token_here` with your actual GitHub Personal Access Token.
3. **Set up GitHub Actions (Optional):**
- The included `.github/workflows/quartz-sync.yml` will automatically rebuild your Quartz site when content changes
- Make sure your repository has GitHub Pages enabled
#### How It Works
- When you sync a note, it creates/updates a Markdown file in your GitHub repository
- The file is placed in the `content/` directory with proper frontmatter
- GitHub Actions automatically rebuilds and deploys your Quartz site
- Your changes appear on your live Quartz site within minutes
### 2. Cloudflare Integration
Uses your existing Cloudflare infrastructure for persistent storage.
#### Prerequisites
- Cloudflare account with R2 and Durable Objects enabled
- API token with appropriate permissions
#### Setup Steps
1. **Create Cloudflare API Token:**
- Go to Cloudflare Dashboard → My Profile → API Tokens
- Create a token with `Cloudflare R2:Edit` and `Durable Objects:Edit` permissions
- Note your Account ID
2. **Configure Environment Variables:**
```bash
# Add to your .env.local file
NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_api_key_here
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_account_id_here
NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-bucket-name
```
3. **Deploy the API Endpoint:**
- The `src/pages/api/quartz/sync.ts` endpoint handles Cloudflare storage
- Deploy this to your Cloudflare Workers or Vercel
#### How It Works
- Notes are stored in Cloudflare R2 for persistence
- Durable Objects handle real-time sync across devices
- The API endpoint manages note storage and retrieval
- Changes are immediately available to all connected clients
### 3. Direct Quartz API
If your Quartz site exposes an API for content updates.
#### Setup Steps
1. **Configure Environment Variables:**
```bash
# Add to your .env.local file
NEXT_PUBLIC_QUARTZ_API_URL=https://your-quartz-site.com/api
NEXT_PUBLIC_QUARTZ_API_KEY=your_api_key_here
```
2. **Implement API Endpoints:**
- Your Quartz site needs to expose `/api/notes` endpoints
- See the example implementation in the sync code
### 4. Webhook Integration
Send updates to a webhook that processes and syncs to Quartz.
#### Setup Steps
1. **Configure Environment Variables:**
```bash
# Add to your .env.local file
NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook-endpoint.com/quartz-sync
NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_webhook_secret_here
```
2. **Set up Webhook Handler:**
- Create an endpoint that receives note updates
- Process the updates and sync to your Quartz site
- Implement proper authentication using the webhook secret
## Configuration
### Environment Variables
Create a `.env.local` file with the following variables:
```bash
# GitHub Integration
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token
NEXT_PUBLIC_QUARTZ_REPO=username/repo-name
# Cloudflare Integration
NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_api_key
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_account_id
NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-bucket-name
# Quartz API Integration
NEXT_PUBLIC_QUARTZ_API_URL=https://your-site.com/api
NEXT_PUBLIC_QUARTZ_API_KEY=your_api_key
# Webhook Integration
NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook.com/sync
NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_secret
```
### Runtime Configuration
You can also configure sync settings at runtime:
```typescript
import { saveQuartzSyncSettings } from '@/config/quartzSync'
// Enable/disable specific sync methods
saveQuartzSyncSettings({
github: { enabled: true },
cloudflare: { enabled: false },
webhook: { enabled: true }
})
```
## Usage
### Basic Sync
The sync functionality is automatically integrated into your ObsNote shapes. When you edit a note and click "Sync Updates", it will:
1. Try the configured sync methods in order of preference
2. Fall back to local storage if all methods fail
3. Provide feedback on the sync status
### Advanced Sync
For more control, you can use the QuartzSync class directly:
```typescript
import { QuartzSync, createQuartzNoteFromShape } from '@/lib/quartzSync'
const sync = new QuartzSync({
githubToken: 'your_token',
githubRepo: 'username/repo'
})
const note = createQuartzNoteFromShape(shape)
await sync.smartSync(note)
```
## Troubleshooting
### Common Issues
1. **"No vault configured for sync"**
- Make sure you've selected a vault in the Obsidian Vault Browser
- Check that the vault path is properly saved in your session
2. **GitHub API errors**
- Verify your GitHub token has the correct permissions
- Check that the repository name is correct (username/repo-name format)
3. **Cloudflare sync failures**
- Ensure your API key has the necessary permissions
- Verify the account ID and bucket name are correct
4. **Environment variables not loading**
- Make sure your `.env.local` file is in the project root
- Restart your development server after adding new variables
### Debug Mode
Enable debug logging by opening the browser console. The sync process provides detailed logs for troubleshooting.
## Security Considerations
1. **API Keys**: Never commit API keys to version control
2. **GitHub Tokens**: Use fine-grained tokens with minimal required permissions
3. **Webhook Secrets**: Always use strong, unique secrets for webhook authentication
4. **CORS**: Configure CORS properly for API endpoints
## Best Practices
1. **Start with GitHub Integration**: It's the most reliable and well-supported approach
2. **Use Fallbacks**: Always have local storage as a fallback option
3. **Monitor Sync Status**: Check the console logs for sync success/failure
4. **Test Thoroughly**: Verify sync works with different types of content
5. **Backup Important Data**: Don't rely solely on sync for critical content
## Support
For issues or questions:
1. Check the console logs for detailed error messages
2. Verify your environment variables are set correctly
3. Test with a simple note first
4. Check the GitHub repository for updates and issues
## References
- [Quartz Documentation](https://quartz.jzhao.xyz/)
- [Quartz GitHub Repository](https://github.com/jackyzha0/quartz)
- [GitHub API Documentation](https://docs.github.com/en/rest)
- [Cloudflare R2 Documentation](https://developers.cloudflare.com/r2/)

View File

@ -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

View File

@ -0,0 +1,84 @@
# TLDraw Interactive Elements - Z-Index Requirements
## Important Note for Developers
When creating tldraw shapes that contain interactive elements (buttons, inputs, links, etc.), you **MUST** set appropriate z-index values to ensure these elements are clickable and accessible.
## The Problem
TLDraw's canvas has its own event handling and layering system. Interactive elements within custom shapes can be blocked by the canvas's event listeners, making them unclickable or unresponsive.
## The Solution
Always add the following CSS properties to interactive elements:
```css
.interactive-element {
position: relative;
z-index: 1000; /* or higher if needed */
}
```
## Examples
### Buttons
```css
.custom-button {
/* ... other styles ... */
position: relative;
z-index: 1000;
}
```
### Input Fields
```css
.custom-input {
/* ... other styles ... */
position: relative;
z-index: 1000;
}
```
### Links
```css
.custom-link {
/* ... other styles ... */
position: relative;
z-index: 1000;
}
```
## Z-Index Guidelines
- **1000**: Standard interactive elements (buttons, inputs, links)
- **1001-1999**: Dropdowns, modals, tooltips
- **2000+**: Critical overlays, error messages
## Testing Checklist
Before deploying any tldraw shape with interactive elements:
- [ ] Test clicking all buttons/links
- [ ] Test input field focus and typing
- [ ] Test hover states
- [ ] Test on different screen sizes
- [ ] Verify elements work when shape is selected/deselected
- [ ] Verify elements work when shape is moved/resized
## Common Issues
1. **Elements appear clickable but don't respond** → Add z-index
2. **Hover states don't work** → Add z-index
3. **Elements work sometimes but not others** → Check z-index conflicts
4. **Mobile touch events don't work** → Ensure z-index is high enough
## Files to Remember
This note should be updated whenever new interactive elements are added to tldraw shapes. Current shapes with interactive elements:
- `src/components/TranscribeComponent.tsx` - Copy button (z-index: 1000)
## Last Updated
Created: [Current Date]
Last Updated: [Current Date]

60
TRANSCRIPTION_SETUP.md Normal file
View File

@ -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

90
WORKER_ENV_GUIDE.md Normal file
View File

@ -0,0 +1,90 @@
# 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

View File

@ -0,0 +1,214 @@
# Enhanced Audio Transcription with Speaker Identification
This document describes the enhanced audio transcription system that identifies different speakers and ensures complete transcript preservation in real-time.
## 🎯 Key Features
### 1. **Speaker Identification**
- **Voice Fingerprinting**: Uses audio analysis to create unique voice profiles for each speaker
- **Real-time Detection**: Automatically identifies when speakers change during conversation
- **Visual Indicators**: Each speaker gets a unique color and label for easy identification
- **Speaker Statistics**: Tracks speaking time and segment count for each participant
### 2. **Enhanced Transcript Structure**
- **Structured Segments**: Each transcript segment includes speaker ID, timestamps, and confidence scores
- **Complete Preservation**: No words are lost during real-time updates
- **Backward Compatibility**: Maintains legacy transcript format for existing integrations
- **Multiple Export Formats**: Support for text, JSON, and SRT subtitle formats
### 3. **Real-time Updates**
- **Live Speaker Detection**: Continuously monitors voice activity and speaker changes
- **Interim Text Display**: Shows partial results as they're being spoken
- **Smooth Transitions**: Seamless updates between interim and final transcript segments
- **Auto-scroll**: Automatically scrolls to show the latest content
## 🔧 Technical Implementation
### Audio Analysis System
The system uses advanced audio analysis to identify speakers:
```typescript
interface VoiceCharacteristics {
pitch: number // Fundamental frequency
volume: number // Audio amplitude
spectralCentroid: number // Frequency distribution center
mfcc: number[] // Mel-frequency cepstral coefficients
zeroCrossingRate: number // Voice activity indicator
energy: number // Overall audio energy
}
```
### Speaker Identification Algorithm
1. **Voice Activity Detection**: Monitors audio levels to detect when someone is speaking
2. **Feature Extraction**: Analyzes voice characteristics in real-time
3. **Similarity Matching**: Compares current voice with known speaker profiles
4. **Profile Creation**: Creates new speaker profiles for unrecognized voices
5. **Confidence Scoring**: Assigns confidence levels to speaker identifications
### Transcript Management
The enhanced transcript system provides:
```typescript
interface TranscriptSegment {
id: string // Unique segment identifier
speakerId: string // Associated speaker ID
speakerName: string // Display name for speaker
text: string // Transcribed text
startTime: number // Segment start time (ms)
endTime: number // Segment end time (ms)
confidence: number // Recognition confidence (0-1)
isFinal: boolean // Whether segment is finalized
}
```
## 🎨 User Interface Enhancements
### Speaker Display
- **Color-coded Labels**: Each speaker gets a unique color for easy identification
- **Speaker List**: Shows all identified speakers with speaking time statistics
- **Current Speaker Highlighting**: Highlights the currently speaking participant
- **Speaker Management**: Ability to rename speakers and manage their profiles
### Transcript Controls
- **Show/Hide Speaker Labels**: Toggle speaker name display
- **Show/Hide Timestamps**: Toggle timestamp display for each segment
- **Auto-scroll Toggle**: Control automatic scrolling behavior
- **Export Options**: Download transcripts in multiple formats
### Visual Indicators
- **Border Colors**: Each transcript segment has a colored border matching the speaker
- **Speaking Status**: Visual indicators show who is currently speaking
- **Interim Text**: Italicized, gray text shows partial results
- **Final Text**: Regular text shows confirmed transcript segments
## 📊 Data Export and Analysis
### Export Formats
1. **Text Format**:
```
[00:01:23] Speaker 1: Hello, how are you today?
[00:01:28] Speaker 2: I'm doing well, thank you for asking.
```
2. **JSON Format**:
```json
{
"segments": [...],
"speakers": [...],
"sessionStartTime": 1234567890,
"totalDuration": 300000
}
```
3. **SRT Subtitle Format**:
```
1
00:00:01,230 --> 00:00:05,180
Speaker 1: Hello, how are you today?
```
### Statistics and Analytics
The system tracks comprehensive statistics:
- Total speaking time per speaker
- Number of segments per speaker
- Average segment length
- Session duration and timeline
- Recognition confidence scores
## 🔄 Real-time Processing Flow
1. **Audio Capture**: Microphone stream is captured and analyzed
2. **Voice Activity Detection**: System detects when someone starts/stops speaking
3. **Speaker Identification**: Voice characteristics are analyzed and matched to known speakers
4. **Speech Recognition**: Web Speech API processes audio into text
5. **Transcript Update**: New segments are added with speaker information
6. **UI Update**: Interface updates to show new content with speaker labels
## 🛠️ Configuration Options
### Audio Analysis Settings
- **Voice Activity Threshold**: Sensitivity for detecting speech
- **Silence Timeout**: Time before considering a speaker change
- **Similarity Threshold**: Minimum similarity for speaker matching
- **Feature Update Rate**: How often voice profiles are updated
### Display Options
- **Speaker Colors**: Customizable color palette for speakers
- **Timestamp Format**: Choose between different time display formats
- **Auto-scroll Behavior**: Control when and how auto-scrolling occurs
- **Segment Styling**: Customize visual appearance of transcript segments
## 🔍 Troubleshooting
### Common Issues
1. **Speaker Not Identified**:
- Ensure good microphone quality
- Check for background noise
- Verify speaker is speaking clearly
- Allow time for voice profile creation
2. **Incorrect Speaker Assignment**:
- Check microphone positioning
- Verify audio quality
- Consider adjusting similarity threshold
- Manually rename speakers if needed
3. **Missing Transcript Segments**:
- Check internet connection stability
- Verify browser compatibility
- Ensure microphone permissions are granted
- Check for audio processing errors
### Performance Optimization
1. **Audio Quality**: Use high-quality microphones for better speaker identification
2. **Environment**: Minimize background noise for clearer voice analysis
3. **Browser**: Use Chrome or Chromium-based browsers for best performance
4. **Network**: Ensure stable internet connection for speech recognition
## 🚀 Future Enhancements
### Planned Features
- **Machine Learning Integration**: Improved speaker identification using ML models
- **Voice Cloning Detection**: Identify when speakers are using voice modification
- **Emotion Recognition**: Detect emotional tone in speech
- **Language Detection**: Automatic language identification and switching
- **Cloud Processing**: Offload heavy processing to cloud services
### Integration Possibilities
- **Video Analysis**: Combine with video feeds for enhanced speaker detection
- **Meeting Platforms**: Integration with Zoom, Teams, and other platforms
- **AI Summarization**: Automatic meeting summaries with speaker attribution
- **Search and Indexing**: Full-text search across all transcript segments
## 📝 Usage Examples
### Basic Usage
1. Start a video chat session
2. Click the transcription button
3. Allow microphone access
4. Begin speaking - speakers will be automatically identified
5. View real-time transcript with speaker labels
### Advanced Features
1. **Customize Display**: Toggle speaker labels and timestamps
2. **Export Transcripts**: Download in your preferred format
3. **Manage Speakers**: Rename speakers for better organization
4. **Analyze Statistics**: View speaking time and participation metrics
### Integration with Other Tools
- **Meeting Notes**: Combine with note-taking tools
- **Action Items**: Extract action items with speaker attribution
- **Follow-up**: Use transcripts for meeting follow-up and documentation
- **Compliance**: Maintain records for regulatory requirements
---
*The enhanced transcription system provides a comprehensive solution for real-time speaker identification and transcript management, ensuring no spoken words are lost while providing rich metadata about conversation participants.*

View File

@ -0,0 +1,157 @@
# Obsidian Vault Integration
This document describes the Obsidian vault integration feature that allows you to import and work with your Obsidian notes directly on the canvas.
## Features
- **Vault Import**: Load your local Obsidian vault using the File System Access API
- **Searchable Interface**: Browse and search through all your obs_notes with real-time filtering
- **Tag-based Filtering**: Filter obs_notes by tags for better organization
- **Canvas Integration**: Drag obs_notes from the browser directly onto the canvas as rectangle shapes
- **Rich ObsNote Display**: ObsNotes show title, content preview, tags, and metadata
- **Markdown Rendering**: Support for basic markdown formatting in obs_note previews
## How to Use
### 1. Access the Obsidian Browser
You can access the Obsidian browser in multiple ways:
- **Toolbar Button**: Click the "Obsidian Note" button in the toolbar (file-text icon)
- **Context Menu**: Right-click on the canvas and select "Open Obsidian Browser"
- **Keyboard Shortcut**: Press `Alt+O` to open the browser
- **Tool Selection**: Select the "Obsidian Note" tool from the toolbar or context menu
This will open the Obsidian Vault Browser overlay
### 2. Load Your Vault
The browser will attempt to use the File System Access API to let you select your Obsidian vault directory. If this isn't supported in your browser, it will fall back to demo data.
**Supported Browsers for File System Access API:**
- Chrome 86+
- Edge 86+
- Opera 72+
### 3. Browse and Search ObsNotes
- **Search**: Use the search box to find obs_notes by title, content, or tags
- **Filter by Tags**: Click on any tag to filter obs_notes by that tag
- **Clear Filters**: Click "Clear Filters" to remove all active filters
### 4. Add ObsNotes to Canvas
- Click on any obs_note in the browser to add it to the canvas
- The obs_note will appear as a rectangle shape at the center of your current view
- You can move, resize, and style the obs_note shapes like any other canvas element
### 5. Keyboard Shortcuts
- **Alt+O**: Open Obsidian browser or select Obsidian Note tool
- **Escape**: Close the Obsidian browser
- **Enter**: Select the currently highlighted obs_note (when browsing)
## ObsNote Shape Features
### Display Options
- **Title**: Shows the obs_note title at the top
- **Content Preview**: Displays a formatted preview of the obs_note content
- **Tags**: Shows up to 3 tags, with a "+N" indicator for additional tags
- **Metadata**: Displays file path and link count
### Styling
- **Background Color**: Customizable background color
- **Text Color**: Customizable text color
- **Preview Mode**: Toggle between preview and full content view
### Markdown Support
The obs_note shapes support basic markdown formatting:
- Headers (# ## ###)
- Bold (**text**)
- Italic (*text*)
- Inline code (`code`)
- Lists (- item, 1. item)
- Wiki links ([[link]])
- External links ([text](url))
## File Structure
```
src/
├── lib/
│ └── obsidianImporter.ts # Core vault import logic
├── shapes/
│ └── NoteShapeUtil.tsx # Canvas shape for displaying notes
├── tools/
│ └── NoteTool.ts # Tool for creating note shapes
├── components/
│ ├── ObsidianVaultBrowser.tsx # Main browser interface
│ └── ObsidianToolbarButton.tsx # Toolbar button component
└── css/
├── obsidian-browser.css # Browser styling
└── obsidian-toolbar.css # Toolbar button styling
```
## Technical Details
### ObsidianImporter Class
The `ObsidianImporter` class handles:
- Reading markdown files from directories
- Parsing frontmatter and metadata
- Extracting tags, links, and other obs_note properties
- Searching and filtering functionality
### ObsNoteShape Class
The `ObsNoteShape` class extends TLDraw's `BaseBoxShapeUtil` and provides:
- Rich obs_note display with markdown rendering
- Interactive preview/full content toggle
- Customizable styling options
- Integration with TLDraw's shape system
### File System Access
The integration uses the modern File System Access API when available, with graceful fallback to demo data for browsers that don't support it.
## Browser Compatibility
- **File System Access API**: Chrome 86+, Edge 86+, Opera 72+
- **Fallback Mode**: All modern browsers (uses demo data)
- **Canvas Rendering**: All browsers supported by TLDraw
## Future Enhancements
Potential improvements for future versions:
- Real-time vault synchronization
- Bidirectional editing (edit obs_notes on canvas, sync back to vault)
- Advanced search with regex support
- ObsNote linking and backlink visualization
- Custom obs_note templates
- Export canvas content back to Obsidian
- Support for Obsidian plugins and custom CSS
## Troubleshooting
### Vault Won't Load
- Ensure you're using a supported browser
- Check that the selected directory contains markdown files
- Verify you have read permissions for the directory
### ObsNotes Not Displaying Correctly
- Check that the markdown files are properly formatted
- Ensure the files have `.md` extensions
- Verify the obs_note content isn't corrupted
### Performance Issues
- Large vaults may take time to load initially
- Consider filtering by tags to reduce the number of displayed obs_notes
- Use search to quickly find specific obs_notes
## Contributing
To extend the Obsidian integration:
1. Add new features to the `ObsidianImporter` class
2. Extend the `NoteShape` for new display options
3. Update the `ObsidianVaultBrowser` for new UI features
4. Add corresponding CSS styles for new components

171
docs/TRANSCRIPTION_TOOL.md Normal file
View File

@ -0,0 +1,171 @@
# Transcription Tool for Canvas
The Transcription Tool is a powerful feature that allows you to transcribe audio from participants in your Canvas sessions using the Web Speech API. This tool provides real-time speech-to-text conversion, making it easy to capture and document conversations, presentations, and discussions.
## Features
### 🎤 Real-time Transcription
- Live speech-to-text conversion using the Web Speech API
- Support for multiple languages including English, Spanish, French, German, and more
- Continuous recording with interim and final results
### 🌐 Multi-language Support
- **English (US/UK)**: Primary language support
- **European Languages**: Spanish, French, German, Italian, Portuguese
- **Asian Languages**: Japanese, Korean, Chinese (Simplified)
- Easy language switching during recording sessions
### 👥 Participant Management
- Automatic participant detection and tracking
- Individual transcript tracking for each speaker
- Visual indicators for speaking status
### 📝 Transcript Management
- Real-time transcript display with auto-scroll
- Clear transcript functionality
- Download transcripts as text files
- Persistent storage within the Canvas session
### ⚙️ Advanced Controls
- Auto-scroll toggle for better reading experience
- Recording start/stop controls
- Error handling and status indicators
- Microphone permission management
## How to Use
### 1. Adding the Tool to Your Canvas
1. In your Canvas session, look for the **Transcribe** tool in the toolbar
2. Click on the Transcribe tool icon
3. Click and drag on the canvas to create a transcription widget
4. The widget will appear with default dimensions (400x300 pixels)
### 2. Starting a Recording Session
1. **Select Language**: Choose your preferred language from the dropdown menu
2. **Enable Auto-scroll**: Check the auto-scroll checkbox for automatic scrolling
3. **Start Recording**: Click the "🎤 Start Recording" button
4. **Grant Permissions**: Allow microphone access when prompted by your browser
### 3. During Recording
- **Live Transcription**: See real-time text as people speak
- **Participant Tracking**: Monitor who is speaking
- **Status Indicators**: Red dot shows active recording
- **Auto-scroll**: Transcript automatically scrolls to show latest content
### 4. Managing Your Transcript
- **Stop Recording**: Click "⏹️ Stop Recording" to end the session
- **Clear Transcript**: Use "🗑️ Clear" to reset the transcript
- **Download**: Click "💾 Download" to save as a text file
## Browser Compatibility
### ✅ Supported Browsers
- **Chrome/Chromium**: Full support with `webkitSpeechRecognition`
- **Edge (Chromium)**: Full support
- **Safari**: Limited support (may require additional setup)
### ❌ Unsupported Browsers
- **Firefox**: No native support for Web Speech API
- **Internet Explorer**: No support
### 🔧 Recommended Setup
For the best experience, use **Chrome** or **Chromium-based browsers** with:
- Microphone access enabled
- HTTPS connection (required for microphone access)
- Stable internet connection
## Technical Details
### Web Speech API Integration
The tool uses the Web Speech API's `SpeechRecognition` interface:
- **Continuous Mode**: Enables ongoing transcription
- **Interim Results**: Shows partial results in real-time
- **Language Detection**: Automatically adjusts to selected language
- **Error Handling**: Graceful fallback for unsupported features
### Audio Processing
- **Microphone Access**: Secure microphone permission handling
- **Audio Stream Management**: Proper cleanup of audio resources
- **Quality Optimization**: Optimized for voice recognition
### Data Persistence
- **Session Storage**: Transcripts persist during the Canvas session
- **Shape Properties**: All settings and data stored in the Canvas shape
- **Real-time Updates**: Changes sync across all participants
## Troubleshooting
### Common Issues
#### "Speech recognition not supported in this browser"
- **Solution**: Use Chrome or a Chromium-based browser
- **Alternative**: Check if you're using the latest browser version
#### "Unable to access microphone"
- **Solution**: Check browser permissions for microphone access
- **Alternative**: Ensure you're on an HTTPS connection
#### Poor transcription quality
- **Solutions**:
- Speak clearly and at a moderate pace
- Reduce background noise
- Ensure good microphone positioning
- Check internet connection stability
#### Language not working correctly
- **Solution**: Verify the selected language matches the spoken language
- **Alternative**: Try restarting the recording session
### Performance Tips
1. **Close unnecessary tabs** to free up system resources
2. **Use a good quality microphone** for better accuracy
3. **Minimize background noise** in your environment
4. **Speak at a natural pace** - not too fast or slow
5. **Ensure stable internet connection** for optimal performance
## Future Enhancements
### Planned Features
- **Speaker Identification**: Advanced voice recognition for multiple speakers
- **Export Formats**: Support for PDF, Word, and other document formats
- **Real-time Translation**: Multi-language translation capabilities
- **Voice Commands**: Canvas control through voice commands
- **Cloud Storage**: Automatic transcript backup and sharing
### Integration Possibilities
- **Daily.co Integration**: Enhanced participant detection from video sessions
- **AI Enhancement**: Improved accuracy using machine learning
- **Collaborative Editing**: Real-time transcript editing by multiple users
- **Search and Indexing**: Full-text search within transcripts
## Support and Feedback
If you encounter issues or have suggestions for improvements:
1. **Check Browser Compatibility**: Ensure you're using a supported browser
2. **Review Permissions**: Verify microphone access is granted
3. **Check Network**: Ensure stable internet connection
4. **Report Issues**: Contact the development team with detailed error information
## Privacy and Security
### Data Handling
- **Local Processing**: Speech recognition happens locally in your browser
- **No Cloud Storage**: Transcripts are not automatically uploaded to external services
- **Session Privacy**: Data is only shared within your Canvas session
- **User Control**: You control when and what to record
### Best Practices
- **Inform Participants**: Let others know when recording
- **Respect Privacy**: Don't record sensitive or confidential information
- **Secure Sharing**: Be careful when sharing transcript files
- **Regular Cleanup**: Clear transcripts when no longer needed
---
*The Transcription Tool is designed to enhance collaboration and documentation in Canvas sessions. Use it responsibly and respect the privacy of all participants.*

125
github-integration-setup.md Normal file
View File

@ -0,0 +1,125 @@
# GitHub Integration Setup for Quartz Sync
## Quick Setup Guide
### 1. Create GitHub Personal Access Token
1. Go to: https://github.com/settings/tokens
2. Click "Generate new token" → "Generate new token (classic)"
3. Configure:
- **Note:** "Canvas Website Quartz Sync"
- **Expiration:** 90 days (or your preference)
- **Scopes:**
- ✅ `repo` (Full control of private repositories)
- ✅ `workflow` (Update GitHub Action workflows)
4. Click "Generate token" and **copy it immediately**
### 2. Set Up Your Quartz Repository
For the Jeff-Emmett/quartz repository, you can either:
**Option A: Use the existing Jeff-Emmett/quartz repository**
- Fork the repository to your GitHub account
- Clone your fork locally
- Set up the environment variables to point to your fork
**Option B: Create a new Quartz repository**
```bash
# Create a new Quartz site
git clone https://github.com/jackyzha0/quartz.git your-quartz-site
cd your-quartz-site
npm install
npx quartz create
# Push to GitHub
git add .
git commit -m "Initial Quartz setup"
git remote add origin https://github.com/your-username/your-quartz-repo.git
git push -u origin main
```
### 3. Configure Environment Variables
Create a `.env.local` file in your project root:
```bash
# GitHub Integration for Quartz Sync
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here
NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz
NEXT_PUBLIC_QUARTZ_BRANCH=main
```
### 4. Enable GitHub Pages
1. Go to your repository → Settings → Pages
2. Source: "GitHub Actions"
3. This will automatically deploy your Quartz site when you push changes
### 5. Test the Integration
1. Start your development server: `npm run dev`
2. Import some Obsidian notes or create new ones
3. Edit a note and click "Sync Updates"
4. Check your GitHub repository - you should see new/updated files in the `content/` directory
5. Your Quartz site should automatically rebuild and show the changes
## How It Works
1. **When you sync a note:**
- The system creates/updates a Markdown file in your GitHub repository
- File is placed in the `content/` directory with proper frontmatter
- GitHub Actions automatically rebuilds and deploys your Quartz site
2. **File structure in your repository:**
```
your-quartz-repo/
├── content/
│ ├── note-1.md
│ ├── note-2.md
│ └── ...
├── .github/workflows/
│ └── quartz-sync.yml
└── ...
```
3. **Automatic deployment:**
- Changes trigger GitHub Actions workflow
- Quartz site rebuilds automatically
- Changes appear on your live site within minutes
## Troubleshooting
### Common Issues
1. **"GitHub API error: 401 Unauthorized"**
- Check your GitHub token is correct
- Verify the token has `repo` permissions
2. **"Repository not found"**
- Check the repository name format: `username/repo-name`
- Ensure the repository exists and is accessible
3. **"Sync successful but no changes on site"**
- Check GitHub Actions tab for workflow status
- Verify GitHub Pages is enabled
- Wait a few minutes for the build to complete
### Debug Mode
Check the browser console for detailed sync logs:
- Look for "✅ Successfully synced to Quartz!" messages
- Check for any error messages in red
## Security Notes
- Never commit your `.env.local` file to version control
- Use fine-grained tokens with minimal required permissions
- Regularly rotate your GitHub tokens
## Next Steps
Once set up, you can:
- Edit notes directly in the canvas
- Sync changes to your Quartz site
- Share your live Quartz site with others
- Use GitHub's version control for your notes

1116
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,12 @@
"description": "Jeff Emmett's personal website", "description": "Jeff Emmett's personal website",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker\"", "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker:local\"",
"dev:client": "vite --host --port 5173", "dev:client": "vite --host 0.0.0.0 --port 5173",
"dev:worker": "wrangler dev --config wrangler.dev.toml --remote --port 5172", "dev:worker": "wrangler dev --config wrangler.dev.toml --remote --port 5172",
"dev:worker:local": "wrangler dev --config wrangler.dev.toml --port 5172 --ip 0.0.0.0", "dev:worker:local": "wrangler dev --config wrangler.dev.toml --port 5172 --ip 0.0.0.0",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:worker": "wrangler build --config wrangler.dev.toml",
"preview": "vite preview", "preview": "vite preview",
"deploy": "tsc && vite build && vercel deploy --prod && wrangler deploy", "deploy": "tsc && vite build && vercel deploy --prod && wrangler deploy",
"deploy:worker": "wrangler deploy", "deploy:worker": "wrangler deploy",
@ -23,22 +24,27 @@
"@automerge/automerge": "^3.1.1", "@automerge/automerge": "^3.1.1",
"@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo": "^2.2.0",
"@automerge/automerge-repo-react-hooks": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0",
"@chengsokdara/use-whisper": "^0.2.0",
"@daily-co/daily-js": "^0.60.0", "@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0", "@daily-co/daily-react": "^0.20.0",
"@oddjs/odd": "^0.37.2", "@oddjs/odd": "^0.37.2",
"@tldraw/assets": "^3.15.4", "@tldraw/assets": "^3.15.4",
"@tldraw/sync": "^3.15.4",
"@tldraw/sync-core": "^3.15.4",
"@tldraw/tldraw": "^3.15.4", "@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4", "@tldraw/tlschema": "^3.15.4",
"@types/markdown-it": "^14.1.1", "@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5", "@uiw/react-md-editor": "^4.0.5",
"@vercel/analytics": "^1.2.2", "@vercel/analytics": "^1.2.2",
"@xenova/transformers": "^2.17.2",
"ai": "^4.1.0", "ai": "^4.1.0",
"ajv": "^8.17.1",
"cherry-markdown": "^0.8.57", "cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7", "cloudflare-workers-unfurl": "^0.0.7",
"fathom-typescript": "^0.0.36",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gun": "^0.2020.1241",
"h3-js": "^4.3.0",
"holosphere": "^1.1.20",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"itty-router": "^5.0.17", "itty-router": "^5.0.17",
"jotai": "^2.6.0", "jotai": "^2.6.0",
@ -55,6 +61,7 @@
"react-router-dom": "^7.0.2", "react-router-dom": "^7.0.2",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"tldraw": "^3.15.4", "tldraw": "^3.15.4",
"use-whisper": "^0.0.1",
"vercel": "^39.1.1", "vercel": "^39.1.1",
"webcola": "^3.4.0", "webcola": "^3.4.0",
"webnative": "^0.36.3" "webnative": "^0.36.3"

24
quartz-sync.env.example Normal file
View File

@ -0,0 +1,24 @@
# Quartz Sync Configuration
# Copy this file to .env.local and fill in your actual values
# GitHub Integration (Recommended)
# Get your token from: https://github.com/settings/tokens
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here
# Format: username/repository-name
NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz
# Cloudflare Integration
# Get your API key from: https://dash.cloudflare.com/profile/api-tokens
NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_cloudflare_api_key_here
# Find your Account ID in the Cloudflare dashboard sidebar
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id_here
# Optional: Specify a custom R2 bucket name
NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-quartz-notes-bucket
# Quartz API Integration (if your Quartz site has an API)
NEXT_PUBLIC_QUARTZ_API_URL=https://your-quartz-site.com/api
NEXT_PUBLIC_QUARTZ_API_KEY=your_quartz_api_key_here
# Webhook Integration (for custom sync handlers)
NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook-endpoint.com/quartz-sync
NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_webhook_secret_here

View File

@ -17,7 +17,11 @@ import "@/css/auth.css"; // Import auth styles
import "@/css/crypto-auth.css"; // Import crypto auth styles import "@/css/crypto-auth.css"; // Import crypto auth styles
import "@/css/starred-boards.css"; // Import starred boards styles import "@/css/starred-boards.css"; // Import starred boards styles
import "@/css/user-profile.css"; // Import user profile styles import "@/css/user-profile.css"; // Import user profile styles
import "@/css/location.css"; // Import location sharing styles
import { Dashboard } from "./routes/Dashboard"; 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 { useState, useEffect } from 'react';
// Import React Context providers // Import React Context providers
@ -25,6 +29,7 @@ import { AuthProvider, useAuth } from './context/AuthContext';
import { FileSystemProvider } from './context/FileSystemContext'; import { FileSystemProvider } from './context/FileSystemContext';
import { NotificationProvider } from './context/NotificationContext'; import { NotificationProvider } from './context/NotificationContext';
import NotificationsDisplay from './components/NotificationsDisplay'; import NotificationsDisplay from './components/NotificationsDisplay';
import { ErrorBoundary } from './components/ErrorBoundary';
// Import auth components // Import auth components
import CryptoLogin from './components/auth/CryptoLogin'; import CryptoLogin from './components/auth/CryptoLogin';
@ -32,34 +37,47 @@ import CryptoDebug from './components/auth/CryptoDebug';
inject(); inject();
const callObject = Daily.createCallObject(); // Initialize Daily.co call object with error handling
let callObject: any = null;
try {
// Only create call object if we're in a secure context and mediaDevices is available
if (typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
navigator.mediaDevices) {
callObject = Daily.createCallObject();
}
} catch (error) {
console.warn('Daily.co call object initialization failed:', error);
// Continue without video chat functionality
}
/**
* Optional Auth Route component
* Allows guests to browse, but provides login option
*/
const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
const { session } = useAuth();
const [isInitialized, setIsInitialized] = useState(false);
// Wait for authentication to initialize before rendering
useEffect(() => {
if (!session.loading) {
setIsInitialized(true);
}
}, [session.loading]);
if (!isInitialized) {
return <div className="loading">Loading...</div>;
}
// Always render the content, authentication is optional
return <>{children}</>;
};
/** /**
* Main App with context providers * Main App with context providers
*/ */
const AppWithProviders = () => { const AppWithProviders = () => {
/**
* Optional Auth Route component
* Allows guests to browse, but provides login option
*/
const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
const { session } = useAuth();
const [isInitialized, setIsInitialized] = useState(false);
// Wait for authentication to initialize before rendering
useEffect(() => {
if (!session.loading) {
setIsInitialized(true);
}
}, [session.loading]);
if (!isInitialized) {
return <div className="loading">Loading...</div>;
}
// Always render the content, authentication is optional
return <>{children}</>;
};
/** /**
* Auth page - renders login/register component (kept for direct access) * Auth page - renders login/register component (kept for direct access)
@ -80,65 +98,83 @@ const AppWithProviders = () => {
}; };
return ( return (
<AuthProvider> <ErrorBoundary>
<FileSystemProvider> <AuthProvider>
<NotificationProvider> <FileSystemProvider>
<DailyProvider callObject={callObject}> <NotificationProvider>
<BrowserRouter> <DailyProvider callObject={callObject}>
{/* Display notifications */} <BrowserRouter>
<NotificationsDisplay /> {/* Display notifications */}
<NotificationsDisplay />
<Routes> <Routes>
{/* Auth routes */} {/* Auth routes */}
<Route path="/login" element={<AuthPage />} /> <Route path="/login" element={<AuthPage />} />
{/* Optional auth routes */} {/* Optional auth routes */}
<Route path="/" element={ <Route path="/" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<Default /> <Default />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
<Route path="/contact" element={ <Route path="/contact" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<Contact /> <Contact />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
<Route path="/board/:slug" element={ <Route path="/board/:slug" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<Board /> <Board />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
<Route path="/inbox" element={ <Route path="/inbox" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<Inbox /> <Inbox />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
<Route path="/debug" element={ <Route path="/debug" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<CryptoDebug /> <CryptoDebug />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
<Route path="/dashboard" element={ <Route path="/dashboard" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<Dashboard /> <Dashboard />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
<Route path="/presentations" element={ <Route path="/presentations" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<Presentations /> <Presentations />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
<Route path="/presentations/resilience" element={ <Route path="/presentations/resilience" element={
<OptionalAuthRoute> <OptionalAuthRoute>
<Resilience /> <Resilience />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
</Routes> {/* Location sharing routes */}
</BrowserRouter> <Route path="/share-location" element={
</DailyProvider> <OptionalAuthRoute>
</NotificationProvider> <LocationShareCreate />
</FileSystemProvider> </OptionalAuthRoute>
</AuthProvider> } />
<Route path="/location/:token" element={
<OptionalAuthRoute>
<LocationShareView />
</OptionalAuthRoute>
} />
<Route path="/location-dashboard" element={
<OptionalAuthRoute>
<LocationDashboardRoute />
</OptionalAuthRoute>
} />
</Routes>
</BrowserRouter>
</DailyProvider>
</NotificationProvider>
</FileSystemProvider>
</AuthProvider>
</ErrorBoundary>
); );
}; };

View File

@ -200,6 +200,8 @@ export class Drawing extends StateNode {
onGestureEnd = () => { onGestureEnd = () => {
const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape 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 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 gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps)
const score_pass = gesture.score > 0.2 const score_pass = gesture.score > 0.2
@ -210,51 +212,63 @@ export class Drawing extends StateNode {
} else if (!score_confident) { } else if (!score_confident) {
score_color = "yellow" score_color = "yellow"
} }
// Execute the gesture action if recognized
if (score_pass) { if (score_pass) {
gesture.onComplete?.(this.editor, shape) gesture.onComplete?.(this.editor, shape)
} }
let opacity = 1
const labelShape: TLShapePartial<TLTextShape> = { // Delete the gesture shape immediately - it's just a command, not a persistent shape
id: createShapeId(), this.editor.deleteShape(shape.id)
type: "text",
x: this.editor.inputs.currentPagePoint.x + 20, // Optionally show a temporary label with fade-out
y: this.editor.inputs.currentPagePoint.y,
props: {
size: "xl",
text: gesture.name,
color: score_color,
} as any,
}
if (SHOW_LABELS) { 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) this.editor.createShape(labelShape)
}
const intervalId = setInterval(() => { // Fade out and delete the label
if (opacity > 0) { let opacity = 1
this.editor.updateShape({ const intervalId = setInterval(() => {
...shape, if (opacity > 0) {
opacity: opacity, this.editor.updateShape({
props: { ...labelShape,
...shape.props, opacity: opacity,
color: score_color, props: {
}, ...labelShape.props,
}) color: score_color,
this.editor.updateShape({ },
...labelShape, })
opacity: opacity, opacity = Math.max(0, opacity - 0.025)
props: { } else {
...labelShape.props, clearInterval(intervalId)
color: score_color,
},
})
opacity = Math.max(0, opacity - 0.025)
} else {
clearInterval(intervalId)
this.editor.deleteShape(shape.id)
if (SHOW_LABELS) {
this.editor.deleteShape(labelShape.id) this.editor.deleteShape(labelShape.id)
} }
} }, 20)
}, 20) }
} }
override onPointerMove: TLEventHandlers["onPointerMove"] = () => { override onPointerMove: TLEventHandlers["onPointerMove"] = () => {
@ -344,6 +358,7 @@ export class Drawing extends StateNode {
x: originPagePoint.x, x: originPagePoint.x,
y: originPagePoint.y, y: originPagePoint.y,
opacity: 0.5, opacity: 0.5,
isLocked: false,
props: { props: {
isPen: this.isPenOrStylus, isPen: this.isPenOrStylus,
segments: [ segments: [

View File

@ -0,0 +1,686 @@
import { TLRecord, RecordId, TLStore } from "@tldraw/tldraw"
import * as Automerge from "@automerge/automerge"
export function applyAutomergePatchesToTLStore(
patches: Automerge.Patch[],
store: TLStore
) {
const toRemove: TLRecord["id"][] = []
const updatedObjects: { [id: string]: TLRecord } = {}
patches.forEach((patch) => {
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)
// 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,
y: 0,
rotation: 0,
isLocked: false,
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: {}
}
}
}
let 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') {
let correctTypeName = record.typeName
if (id.startsWith('shape:') && record.typeName !== 'shape') {
correctTypeName = 'shape'
} else if (id.startsWith('page:') && record.typeName !== 'page') {
correctTypeName = 'page'
} else if (id.startsWith('camera:') && record.typeName !== 'camera') {
correctTypeName = 'camera'
} else if (id.startsWith('instance:') && record.typeName !== 'instance') {
correctTypeName = 'instance'
} else if (id.startsWith('pointer:') && record.typeName !== 'pointer') {
correctTypeName = 'pointer'
} else if (id.startsWith('document:') && record.typeName !== 'document') {
correctTypeName = 'document'
}
// Create new object with correct typeName if it changed
if (correctTypeName !== record.typeName) {
record = { ...record, typeName: correctTypeName } as TLRecord
}
}
switch (patch.action) {
case "insert": {
updatedObjects[id] = applyInsertToObject(patch, record)
break
}
case "put":
updatedObjects[id] = applyPutToObject(patch, record)
break
case "del": {
const id = pathToId(patch.path)
toRemove.push(id as TLRecord["id"])
break
}
case "splice": {
updatedObjects[id] = applySpliceToObject(patch, record)
break
}
case "inc": {
updatedObjects[id] = applyIncToObject(patch, record)
break
}
case "mark":
case "unmark":
case "conflict": {
// These actions are not currently supported for TLDraw
console.log("Unsupported patch action:", patch.action)
break
}
default: {
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
const toPut: TLRecord[] = []
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
// 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 (finalSanitized.length) store.put(finalSanitized)
})
}
// 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 }
// 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 || 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
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.index) sanitized.index = 'a1'
if (!sanitized.parentId) sanitized.parentId = 'page:page'
if (!sanitized.props || typeof sanitized.props !== 'object') 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'
}
}
// 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'
}
// 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
}
}
// 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') {
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)
}
}
// 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: [] }
}
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
}
// 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)
}
// 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
}
}
return sanitized
}
const isStorePatch = (patch: Automerge.Patch): boolean => {
return patch.path[0] === "store" && patch.path.length > 1
}
// Helper function to safely get a record from the store
const getRecordFromStore = (store: TLStore, id: string): TLRecord | null => {
try {
return store.get(id as any) as TLRecord | null
} catch {
return null
}
}
// path: ["store", "camera:page:page", "x"] => "camera:page:page"
const pathToId = (path: Automerge.Prop[]): RecordId<any> => {
return path[1] as RecordId<any>
}
const applyInsertToObject = (patch: Automerge.InsertPatch, object: any): TLRecord => {
const { path, values } = patch
let current = object
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 || 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 = Array.isArray(current[pathEnd]) ? current[pathEnd].slice(0) : []
clone.splice(insertionPoint, 0, ...values)
current[pathEnd] = clone
return object
}
const applyPutToObject = (patch: Automerge.PutPatch, object: any): TLRecord => {
const { path, value } = patch
let current = object
// special case
if (path.length === 2) {
// this would be creating the object, but we have done
return object
}
const parts = path.slice(2, -2)
const property = path[path.length - 1] as string
const target = path[path.length - 2] as string
if (path.length === 3) {
return { ...object, [property]: value }
}
// 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
}
const applySpliceToObject = (patch: Automerge.SpliceTextPatch, object: any): TLRecord => {
const { path, value } = patch
let current = object
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 || 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")
}
current[pathEnd] = value // .splice(insertionPoint, 0, value)
return object
}
const applyIncToObject = (patch: Automerge.IncPatch, object: any): TLRecord => {
const { path, value } = patch
let current = object
const parts = path.slice(2, -1)
const pathEnd = path[path.length - 1] as string
for (const part of parts) {
if (current[part] === undefined) {
throw new Error("NO WAY")
}
current = current[part]
}
current[pathEnd] = (current[pathEnd] || 0) + value
return object
}

View File

@ -0,0 +1,442 @@
import { Repo, DocHandle, NetworkAdapter, PeerId, PeerMetadata, Message } from "@automerge/automerge-repo"
import { TLStoreSnapshot } from "@tldraw/tldraw"
import { init } from "./index"
export class CloudflareAdapter {
private repo: Repo
private handles: Map<string, DocHandle<TLStoreSnapshot>> = new Map()
private workerUrl: string
private networkAdapter: CloudflareNetworkAdapter
// Track last persisted state to detect changes
private lastPersistedState: Map<string, string> = new Map()
constructor(workerUrl: string, roomId?: string) {
this.workerUrl = workerUrl
this.networkAdapter = new CloudflareNetworkAdapter(workerUrl, roomId)
// Create repo with network adapter
this.repo = new Repo({
sharePolicy: async () => true, // Allow sharing with all peers
network: [this.networkAdapter],
})
}
async getHandle(roomId: string): Promise<DocHandle<TLStoreSnapshot>> {
if (!this.handles.has(roomId)) {
console.log(`Creating new Automerge handle for room ${roomId}`)
const handle = this.repo.create<TLStoreSnapshot>()
// Initialize with default store if this is a new document
handle.change((doc) => {
if (!doc.store) {
console.log("Initializing new document with default store")
init(doc)
}
})
this.handles.set(roomId, handle)
} else {
console.log(`Reusing existing Automerge handle for room ${roomId}`)
}
return this.handles.get(roomId)!
}
// Generate a simple hash of the document state for change detection
private generateDocHash(doc: any): string {
// Create a stable string representation of the document
// Focus on the store data which is what actually changes
const storeData = doc.store || {}
const storeKeys = Object.keys(storeData).sort()
const storeString = JSON.stringify(storeData, storeKeys)
// Simple hash function (you could use a more sophisticated one if needed)
let hash = 0
for (let i = 0; i < storeString.length; i++) {
const char = storeString.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // Convert to 32-bit integer
}
const hashString = hash.toString()
return hashString
}
async saveToCloudflare(roomId: string): Promise<void> {
const handle = this.handles.get(roomId)
if (!handle) {
console.log(`No handle found for room ${roomId}`)
return
}
const doc = handle.doc()
if (!doc) {
console.log(`No document found for room ${roomId}`)
return
}
// Generate hash of current document state
const currentHash = this.generateDocHash(doc)
const lastHash = this.lastPersistedState.get(roomId)
// Skip save if document hasn't changed
if (currentHash === lastHash) {
return
}
try {
const response = await fetch(`${this.workerUrl}/room/${roomId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(doc),
})
if (!response.ok) {
throw new Error(`Failed to save to Cloudflare: ${response.statusText}`)
}
// Update last persisted state only after successful save
this.lastPersistedState.set(roomId, currentHash)
} catch (error) {
console.error('Error saving to Cloudflare:', error)
}
}
async loadFromCloudflare(roomId: string): Promise<TLStoreSnapshot | null> {
try {
// Add retry logic for connection issues
let response: Response;
let retries = 3;
while (retries > 0) {
try {
response = await fetch(`${this.workerUrl}/room/${roomId}`)
break;
} catch (error) {
retries--;
if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
} else {
throw error;
}
}
}
if (!response!.ok) {
if (response!.status === 404) {
return null // Room doesn't exist yet
}
console.error(`Failed to load from Cloudflare: ${response!.status} ${response!.statusText}`)
throw new Error(`Failed to load from Cloudflare: ${response!.statusText}`)
}
const doc = await response!.json() as TLStoreSnapshot
console.log(`Successfully loaded document from Cloudflare for room ${roomId}:`, {
hasStore: !!doc.store,
storeKeys: doc.store ? Object.keys(doc.store).length : 0
})
// Initialize the last persisted state with the loaded document
if (doc) {
const docHash = this.generateDocHash(doc)
this.lastPersistedState.set(roomId, docHash)
}
return doc
} catch (error) {
console.error('Error loading from Cloudflare:', error)
return null
}
}
}
export class CloudflareNetworkAdapter extends NetworkAdapter {
private workerUrl: string
private websocket: WebSocket | null = null
private roomId: string | null = null
public peerId: PeerId | undefined = undefined
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()
this.workerUrl = workerUrl
this.roomId = roomId || 'default-room'
this.readyPromise = new Promise((resolve) => {
this.readyResolve = resolve
})
}
isReady(): boolean {
return this.websocket?.readyState === WebSocket.OPEN
}
whenReady(): Promise<void> {
return this.readyPromise
}
connect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
if (this.isConnecting) {
console.log('🔌 CloudflareAdapter: Connection already in progress, skipping')
return
}
// Store peerId
this.peerId = peerId
// 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 {
// 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 - convert ArrayBuffer to Uint8Array
// Automerge Repo expects binary sync messages as Uint8Array
const message: Message = {
type: 'sync',
data: new Uint8Array(event.data),
senderId: this.peerId || ('unknown' as PeerId),
targetId: this.peerId || ('unknown' as PeerId)
}
this.emit('message', message)
} else if (event.data instanceof Blob) {
// Handle Blob messages (convert to Uint8Array)
event.data.arrayBuffer().then((buffer) => {
console.log('🔌 CloudflareAdapter: Received Blob message, converted to Uint8Array')
const message: Message = {
type: 'sync',
data: new Uint8Array(buffer),
senderId: this.peerId || ('unknown' as PeerId),
targetId: this.peerId || ('unknown' as PeerId)
}
this.emit('message', message)
})
} else {
// 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
const syncMessage: Message = {
type: 'sync',
senderId: message.senderId || this.peerId || ('unknown' as PeerId),
targetId: message.targetId || this.peerId || ('unknown' as PeerId),
data: message.data
}
this.emit('message', syncMessage)
} else if (message.senderId && message.targetId) {
this.emit('message', message as Message)
}
}
} catch (error) {
console.error('❌ CloudflareAdapter: Error parsing WebSocket message:', error)
}
}
this.websocket.onclose = (event) => {
console.log('Disconnected from Cloudflare WebSocket', {
code: event.code,
reason: event.reason,
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 with exponential backoff
this.scheduleReconnect(peerId, peerMetadata)
}
this.websocket.onerror = (error) => {
console.error('WebSocket error:', error)
console.error('WebSocket readyState:', this.websocket?.readyState)
console.error('WebSocket URL:', wsUrl)
console.error('Error event details:', {
type: error.type,
target: error.target,
isTrusted: error.isTrusted
})
this.isConnecting = false
}
} catch (error) {
console.error('Failed to create WebSocket:', error)
this.isConnecting = false
return
}
}, 100)
}
send(message: Message): void {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
// 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))
}
}
}
broadcast(message: Message): void {
// For WebSocket-based adapters, broadcast is the same as send
// since we're connected to a single server that handles broadcasting
this.send(message)
}
disconnect(): void {
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
}
}
}

View File

@ -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
}

52
src/automerge/README.md Normal file
View File

@ -0,0 +1,52 @@
# Automerge Integration for TLdraw
This directory contains the Automerge-based sync implementation that replaces the TLdraw sync system.
## Files
- `AutomergeToTLStore.ts` - Converts Automerge patches to TLdraw store updates
- `TLStoreToAutomerge.ts` - Converts TLdraw store changes to Automerge document updates
- `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
## Benefits over TLdraw Sync
1. **Better Conflict Resolution**: Automerge's CRDT nature handles concurrent edits more elegantly
2. **Offline-First**: Works seamlessly offline and syncs when reconnected
3. **Smaller Sync Payloads**: Only sends changes (patches) rather than full state
4. **Cross-Session Persistence**: Better handling of data across different devices/sessions
5. **Automatic Merging**: No manual conflict resolution needed
## Usage
Replace the TLdraw sync import:
```typescript
// Old
import { useSync } from "@tldraw/sync"
// New
import { useAutomergeSync } from "@/automerge/useAutomergeSync"
```
The API is identical, so no other changes are needed in your components.
## Cloudflare Integration
The system uses:
- **Durable Objects**: For real-time WebSocket connections and document state management
- **R2 Storage**: For persistent document storage
- **Automerge Network Adapter**: Custom adapter for Cloudflare's infrastructure
## Migration
To switch from TLdraw sync to Automerge sync:
1. Update the Board component to use `useAutomergeSync`
2. Deploy the new worker with Automerge Durable Object
3. Update the URI to use `/automerge/connect/` instead of `/connect/`
The migration is backward compatible - existing TLdraw sync will continue to work while you test the new system.

View File

@ -0,0 +1,482 @@
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 }
// 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.)
// Ensure required top-level fields exist
if (sanitized.typeName === 'shape') {
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 = {}
// 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') {
// 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
}
// 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
}
// 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
}
// 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') {
// 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)) {
(sanitized as any).brush = { x: 0, y: 0, w: 0, h: 0 }
}
if ('zoomBrush' in sanitized && (sanitized.zoomBrush === null || sanitized.zoomBrush === undefined)) {
(sanitized as any).zoomBrush = { x: 0, y: 0, w: 0, h: 0 }
}
if ('insets' in sanitized && (sanitized.insets === undefined || !Array.isArray(sanitized.insets))) {
(sanitized as any).insets = [false, false, false, false]
}
if ('scribbles' in sanitized && (sanitized.scribbles === undefined || !Array.isArray(sanitized.scribbles))) {
(sanitized as any).scribbles = []
}
if ('duplicateProps' in sanitized && (sanitized.duplicateProps === undefined || typeof sanitized.duplicateProps !== 'object')) {
(sanitized as any).duplicateProps = {
shapeIds: [],
offset: { x: 0, y: 0 }
}
}
}
return sanitized
}
export function applyTLStoreChangesToAutomerge(
doc: any,
changes: RecordsDiff<TLRecord>
) {
// Ensure doc.store exists
if (!doc.store) {
doc.store = {}
}
// Handle added records
if (changes.added) {
Object.values(changes.added).forEach((record) => {
// Sanitize record before saving to ensure all required fields are present
const sanitizedRecord = sanitizeRecord(record)
// 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)
// 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
})
}
// Handle removed records
if (changes.removed) {
Object.values(changes.removed).forEach((record) => {
delete doc.store[record.id]
})
}
}
// Removed deepCompareAndUpdate - we now replace entire records and let Automerge handle merging
// This simplifies the code and leverages Automerge's built-in conflict resolution

View File

@ -0,0 +1,122 @@
export const DEFAULT_STORE = {
store: {
"document:document": {
gridSize: 10,
name: "",
meta: {},
id: "document:document",
typeName: "document",
},
"pointer:pointer": {
id: "pointer:pointer",
typeName: "pointer",
x: 0,
y: 0,
lastActivityTimestamp: 0,
meta: {},
},
"page:page": {
meta: {},
id: "page:page",
name: "Page 1",
index: "a1",
typeName: "page",
},
"camera:page:page": {
x: 0,
y: 0,
z: 1,
meta: {},
id: "camera:page:page",
typeName: "camera",
},
"instance_page_state:page:page": {
editingShapeId: null,
croppingShapeId: null,
selectedShapeIds: [],
hoveredShapeId: null,
erasingShapeIds: [],
hintingShapeIds: [],
focusedGroupId: null,
meta: {},
id: "instance_page_state:page:page",
pageId: "page:page",
typeName: "instance_page_state",
},
"instance:instance": {
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,
meta: {},
id: "instance:instance",
currentPageId: "page:page",
typeName: "instance",
},
},
schema: {
schemaVersion: 2,
sequences: {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 4,
"com.tldraw.asset.video": 4,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.note": 7,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.image": 3,
"com.tldraw.shape.video": 2,
"com.tldraw.shape.container": 0,
"com.tldraw.shape.element": 0,
"com.tldraw.binding.arrow": 0,
"com.tldraw.binding.layout": 0,
"obsidian_vault": 1
}
},
}

11
src/automerge/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { TLStoreSnapshot } from "@tldraw/tldraw"
import { DEFAULT_STORE } from "./default_store"
/* a similar pattern to other automerge init functions */
export function init(doc: TLStoreSnapshot) {
Object.assign(doc, DEFAULT_STORE)
}
// Export the V2 implementation
export * from "./useAutomergeStoreV2"
export * from "./useAutomergeSync"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,231 @@
import { useMemo, useEffect, useState, useCallback } from "react"
import { TLStoreSnapshot } from "@tldraw/tldraw"
import { CloudflareAdapter } from "./CloudflareAdapter"
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
import { TLStoreWithStatus } from "@tldraw/tldraw"
interface AutomergeSyncConfig {
uri: string
assets?: any
shapeUtils?: any[]
bindingUtils?: any[]
user?: {
id: string
name: string
}
}
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")
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 [adapter] = useState(() => new CloudflareAdapter(workerUrl, roomId))
const [handle, setHandle] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
// Initialize Automerge document handle
useEffect(() => {
let mounted = true
const initializeHandle = async () => {
// Add a small delay to ensure the server is ready
await new Promise(resolve => setTimeout(resolve, 500));
try {
// Try to load existing document from Cloudflare
const existingDoc = await adapter.loadFromCloudflare(roomId)
if (mounted) {
const handle = await adapter.getHandle(roomId)
// If we loaded an existing document, properly initialize it
if (existingDoc) {
console.log("Initializing Automerge document with existing data:", {
hasStore: !!existingDoc.store,
storeKeys: existingDoc.store ? Object.keys(existingDoc.store).length : 0,
sampleKeys: existingDoc.store ? Object.keys(existingDoc.store).slice(0, 5) : []
})
handle.change((doc) => {
// Always load R2 data if it exists and has content
const r2StoreKeys = existingDoc.store ? Object.keys(existingDoc.store).length : 0
console.log("Loading R2 data:", {
r2StoreKeys,
hasR2Data: r2StoreKeys > 0,
sampleStoreKeys: existingDoc.store ? Object.keys(existingDoc.store).slice(0, 5) : []
})
if (r2StoreKeys > 0) {
console.log("Loading R2 data into Automerge document")
if (existingDoc.store) {
doc.store = existingDoc.store
console.log("Loaded store data into Automerge document:", {
loadedStoreKeys: Object.keys(doc.store).length,
sampleLoadedKeys: Object.keys(doc.store).slice(0, 5)
})
}
if (existingDoc.schema) {
doc.schema = existingDoc.schema
}
} else {
console.log("No R2 data to load")
}
})
} else {
console.log("No existing document found, loading snapshot data")
// Load snapshot data for new rooms
try {
const snapshotResponse = await fetch('/src/snapshot.json')
if (snapshotResponse.ok) {
const snapshotData = await snapshotResponse.json() as TLStoreSnapshot
console.log("Loaded snapshot data:", {
hasStore: !!snapshotData.store,
storeKeys: snapshotData.store ? Object.keys(snapshotData.store).length : 0,
shapeCount: snapshotData.store ? Object.values(snapshotData.store).filter((r: any) => r.typeName === 'shape').length : 0
})
handle.change((doc) => {
if (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,
sampleKeys: Object.keys(doc.store).slice(0, 5)
})
}
if (snapshotData.schema) {
doc.schema = snapshotData.schema
}
})
}
} catch (error) {
console.error('Error loading snapshot data:', error)
}
}
// Wait a bit more to ensure the handle is fully ready with data
await new Promise(resolve => setTimeout(resolve, 500))
setHandle(handle)
setIsLoading(false)
console.log("Automerge handle initialized and loading completed")
}
} catch (error) {
console.error('Error initializing Automerge handle:', error)
if (mounted) {
setIsLoading(false)
}
}
}
initializeHandle()
return () => {
mounted = false
}
}, [adapter, 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 {
await adapter.saveToCloudflare(roomId)
} catch (error) {
console.error('Error in change-triggered save:', error)
}
}, 500)
}
// Listen for changes to the Automerge document
const changeHandler = (_payload: any) => {
scheduleSave()
}
handle.on('change', changeHandler)
return () => {
handle.off('change', changeHandler)
if (saveTimeout) clearTimeout(saveTimeout)
}
}, [handle, adapter, roomId])
// Use the Automerge store (only when handle is ready and not loading)
const store = useAutomergeStoreV2({
handle: !isLoading && handle ? handle : null,
userId: user?.id || 'anonymous',
})
// Set up presence if user is provided (always call hooks, but handle null internally)
useAutomergePresence({
handle,
store,
userMetadata: {
userId: user?.id || 'anonymous',
name: user?.name || 'Anonymous',
color: '#000000', // Default color
},
})
// Return loading state while initializing
if (isLoading || !handle) {
return { ...store, handle: null }
}
return { ...store, handle }
}

View File

@ -0,0 +1,181 @@
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: 'loading' as const
}
}
return {
status: 'synced-remote' as const,
connectionStatus: 'online' as const,
store
}
}, [store, isLoading])
// Get presence data (only when handle is ready)
const userMetadata: { userId: string; name: string; color: string } = (() => {
if (user && 'userId' in user) {
return {
userId: (user as { userId: string; name: string; color?: string }).userId,
name: (user as { userId: string; name: string; color?: string }).name,
color: (user as { userId: string; name: string; color?: string }).color || '#000000'
}
}
return {
userId: user?.id || 'anonymous',
name: user?.name || 'Anonymous',
color: '#000000'
}
})()
const presence = useAutomergePresence({
handle: handle || null,
store: store || null,
userMetadata
})
return {
...storeWithStatus,
presence
} as TLStoreWithStatus & { presence: typeof presence }
}

View File

@ -0,0 +1,59 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#dc3545',
background: '#f8d7da',
border: '1px solid #f5c6cb',
borderRadius: '4px',
margin: '20px'
}}>
<h2>Something went wrong</h2>
<p>An error occurred while loading the application.</p>
<button
onClick={() => this.setState({ hasError: false, error: undefined })}
style={{
padding: '8px 16px',
background: '#007acc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,46 @@
import React from 'react'
import { Editor } from 'tldraw'
interface ObsidianToolbarButtonProps {
editor: Editor
className?: string
}
export const ObsidianToolbarButton: React.FC<ObsidianToolbarButtonProps> = ({
editor: _editor,
className = ''
}) => {
const handleOpenBrowser = () => {
// Dispatch event to open the centralized vault browser in CustomToolbar
const event = new CustomEvent('open-obsidian-browser')
window.dispatchEvent(event)
}
return (
<button
onClick={handleOpenBrowser}
className={`obsidian-toolbar-button ${className}`}
title="Import from Obsidian Vault"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5Z"
stroke="currentColor"
strokeWidth="2"
fill="none"
/>
<path
d="M8 8H16M8 12H16M8 16H12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="18" cy="6" r="2" fill="currentColor" />
</svg>
<span>Obsidian</span>
</button>
)
}
export default ObsidianToolbarButton

File diff suppressed because it is too large Load Diff

View File

@ -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>
)
}

View File

@ -1,13 +1,45 @@
import React from 'react'; import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { clearSession } from '../../lib/init';
interface ProfileProps { interface ProfileProps {
onLogout?: () => void; onLogout?: () => void;
onOpenVaultBrowser?: () => void;
} }
export const Profile: React.FC<ProfileProps> = ({ onLogout }) => { export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }) => {
const { session, updateSession } = useAuth(); const { session, updateSession, clearSession } = useAuth();
const [vaultPath, setVaultPath] = useState(session.obsidianVaultPath || '');
const [isEditingVault, setIsEditingVault] = useState(false);
const handleVaultPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setVaultPath(e.target.value);
};
const handleSaveVaultPath = () => {
updateSession({ obsidianVaultPath: vaultPath });
setIsEditingVault(false);
};
const handleCancelVaultEdit = () => {
setVaultPath(session.obsidianVaultPath || '');
setIsEditingVault(false);
};
const handleDisconnectVault = () => {
setVaultPath('');
updateSession({
obsidianVaultPath: undefined,
obsidianVaultName: undefined
});
setIsEditingVault(false);
console.log('🔧 Vault disconnected from profile');
};
const handleChangeVault = () => {
if (onOpenVaultBrowser) {
onOpenVaultBrowser();
}
};
const handleLogout = () => { const handleLogout = () => {
// Clear the session // Clear the session
@ -34,6 +66,88 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout }) => {
<h3>Welcome, {session.username}!</h3> <h3>Welcome, {session.username}!</h3>
</div> </div>
<div className="profile-settings">
<h4>Obsidian Vault</h4>
{/* Current Vault Display */}
<div className="current-vault-section">
{session.obsidianVaultName ? (
<div className="vault-info">
<div className="vault-name">
<span className="vault-label">Current Vault:</span>
<span className="vault-name-text">{session.obsidianVaultName}</span>
</div>
<div className="vault-path-info">
{session.obsidianVaultPath === 'folder-selected'
? 'Folder selected (path not available)'
: session.obsidianVaultPath}
</div>
</div>
) : (
<div className="no-vault-info">
<span className="no-vault-text">No Obsidian vault configured</span>
</div>
)}
</div>
{/* Change Vault Button */}
<div className="vault-actions-section">
<button onClick={handleChangeVault} className="change-vault-button">
{session.obsidianVaultName ? 'Change Obsidian Vault' : 'Set Obsidian Vault'}
</button>
{session.obsidianVaultPath && (
<button onClick={handleDisconnectVault} className="disconnect-vault-button">
🔌 Disconnect Vault
</button>
)}
</div>
{/* Advanced Settings (Collapsible) */}
<details className="advanced-vault-settings">
<summary>Advanced Settings</summary>
<div className="vault-settings">
{isEditingVault ? (
<div className="vault-edit-form">
<input
type="text"
value={vaultPath}
onChange={handleVaultPathChange}
placeholder="Enter Obsidian vault path..."
className="vault-path-input"
/>
<div className="vault-edit-actions">
<button onClick={handleSaveVaultPath} className="save-button">
Save
</button>
<button onClick={handleCancelVaultEdit} className="cancel-button">
Cancel
</button>
</div>
</div>
) : (
<div className="vault-display">
<div className="vault-path-display">
{session.obsidianVaultPath ? (
<span className="vault-path-text" title={session.obsidianVaultPath}>
{session.obsidianVaultPath === 'folder-selected'
? 'Folder selected (path not available)'
: session.obsidianVaultPath}
</span>
) : (
<span className="no-vault-text">No vault configured</span>
)}
</div>
<div className="vault-actions">
<button onClick={() => setIsEditingVault(true)} className="edit-button">
Edit Path
</button>
</div>
</div>
)}
</div>
</details>
</div>
<div className="profile-actions"> <div className="profile-actions">
<button onClick={handleLogout} className="logout-button"> <button onClick={handleLogout} className="logout-button">
Sign Out Sign Out

View File

@ -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>
)
}

View File

@ -0,0 +1,267 @@
"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>
)
}

View File

@ -0,0 +1,238 @@
"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: '&copy; <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>
)
}

View File

@ -0,0 +1,45 @@
import {
TLUiDialogProps,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
} from "tldraw"
import React from "react"
import { ShareLocation } from "./ShareLocation"
export function LocationShareDialog({ onClose: _onClose }: TLUiDialogProps) {
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Share Location</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 800, maxHeight: "90vh", overflow: "auto" }}>
<ShareLocation />
</TldrawUiDialogBody>
</>
)
}

View File

@ -0,0 +1,180 @@
"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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,147 @@
"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>
)
}

155
src/config/quartzSync.ts Normal file
View File

@ -0,0 +1,155 @@
/**
* Quartz Sync Configuration
* Centralized configuration for all Quartz sync methods
*/
export interface QuartzSyncSettings {
// GitHub Integration
github: {
enabled: boolean
token?: string
repository?: string
branch?: string
autoCommit?: boolean
commitMessage?: string
}
// Cloudflare Integration
cloudflare: {
enabled: boolean
apiKey?: string
accountId?: string
r2Bucket?: string
durableObjectId?: string
}
// Direct Quartz API
quartzApi: {
enabled: boolean
baseUrl?: string
apiKey?: string
}
// Webhook Integration
webhook: {
enabled: boolean
url?: string
secret?: string
}
// Fallback Options
fallback: {
localStorage: boolean
download: boolean
console: boolean
}
}
export const defaultQuartzSyncSettings: QuartzSyncSettings = {
github: {
enabled: true,
repository: 'Jeff-Emmett/quartz',
branch: 'main',
autoCommit: true,
commitMessage: 'Update note: {title}'
},
cloudflare: {
enabled: false // Disabled by default, enable if needed
},
quartzApi: {
enabled: false
},
webhook: {
enabled: false
},
fallback: {
localStorage: true,
download: true,
console: true
}
}
/**
* Get Quartz sync settings from environment variables and localStorage
*/
export function getQuartzSyncSettings(): QuartzSyncSettings {
const settings = { ...defaultQuartzSyncSettings }
// GitHub settings
if (process.env.NEXT_PUBLIC_GITHUB_TOKEN) {
settings.github.token = process.env.NEXT_PUBLIC_GITHUB_TOKEN
}
if (process.env.NEXT_PUBLIC_QUARTZ_REPO) {
settings.github.repository = process.env.NEXT_PUBLIC_QUARTZ_REPO
}
// Cloudflare settings
if (process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY) {
settings.cloudflare.apiKey = process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY
}
if (process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID) {
settings.cloudflare.accountId = process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID
}
if (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET) {
settings.cloudflare.r2Bucket = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET
}
// Quartz API settings
if (process.env.NEXT_PUBLIC_QUARTZ_API_URL) {
settings.quartzApi.baseUrl = process.env.NEXT_PUBLIC_QUARTZ_API_URL
settings.quartzApi.enabled = true
}
if (process.env.NEXT_PUBLIC_QUARTZ_API_KEY) {
settings.quartzApi.apiKey = process.env.NEXT_PUBLIC_QUARTZ_API_KEY
}
// Webhook settings
if (process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL) {
settings.webhook.url = process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL
settings.webhook.enabled = true
}
if (process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET) {
settings.webhook.secret = process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET
}
// Load user preferences from localStorage
try {
const userSettings = localStorage.getItem('quartz_sync_settings')
if (userSettings) {
const parsed = JSON.parse(userSettings)
Object.assign(settings, parsed)
}
} catch (error) {
console.warn('Failed to load user Quartz sync settings:', error)
}
return settings
}
/**
* Save Quartz sync settings to localStorage
*/
export function saveQuartzSyncSettings(settings: Partial<QuartzSyncSettings>): void {
try {
const currentSettings = getQuartzSyncSettings()
const newSettings = { ...currentSettings, ...settings }
localStorage.setItem('quartz_sync_settings', JSON.stringify(newSettings))
console.log('✅ Quartz sync settings saved')
} catch (error) {
console.error('❌ Failed to save Quartz sync settings:', error)
}
}
/**
* Check if any sync methods are available
*/
export function hasAvailableSyncMethods(): boolean {
const settings = getQuartzSyncSettings()
return Boolean(
(settings.github.enabled && settings.github.token && settings.github.repository) ||
(settings.cloudflare.enabled && settings.cloudflare.apiKey && settings.cloudflare.accountId) ||
(settings.quartzApi.enabled && settings.quartzApi.baseUrl) ||
(settings.webhook.enabled && settings.webhook.url)
)
}

View File

@ -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`)

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
import type FileSystem from '@oddjs/odd/fs/index'; import type FileSystem from '@oddjs/odd/fs/index';
import { Session, SessionError } from '../lib/auth/types'; import { Session, SessionError } from '../lib/auth/types';
import { AuthService } from '../lib/auth/authService'; import { AuthService } from '../lib/auth/authService';
@ -21,17 +21,19 @@ const initialSession: Session = {
username: '', username: '',
authed: false, authed: false,
loading: true, loading: true,
backupCreated: null backupCreated: null,
obsidianVaultPath: undefined,
obsidianVaultName: undefined
}; };
const AuthContext = createContext<AuthContextType | undefined>(undefined); export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [session, setSessionState] = useState<Session>(initialSession); const [session, setSessionState] = useState<Session>(initialSession);
const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null); const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null);
// Update session with partial data // Update session with partial data
const setSession = (updatedSession: Partial<Session>) => { const setSession = useCallback((updatedSession: Partial<Session>) => {
setSessionState(prev => { setSessionState(prev => {
const newSession = { ...prev, ...updatedSession }; const newSession = { ...prev, ...updatedSession };
@ -42,92 +44,133 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return newSession; return newSession;
}); });
}; }, []);
// Set file system // Set file system
const setFileSystem = (fs: FileSystem | null) => { const setFileSystem = useCallback((fs: FileSystem | null) => {
setFileSystemState(fs); setFileSystemState(fs);
}; }, []);
/** /**
* Initialize the authentication state * Initialize the authentication state
*/ */
const initialize = async (): Promise<void> => { const initialize = useCallback(async (): Promise<void> => {
setSession({ loading: true }); setSessionState(prev => ({ ...prev, loading: true }));
try { try {
const { session: newSession, fileSystem: newFs } = await AuthService.initialize(); const { session: newSession, fileSystem: newFs } = await AuthService.initialize();
setSession(newSession); setSessionState(newSession);
setFileSystem(newFs); setFileSystemState(newFs);
// Save session to localStorage if authenticated
if (newSession.authed && newSession.username) {
saveSession(newSession);
}
} catch (error) { } catch (error) {
setSession({ console.error('Auth initialization error:', error);
setSessionState(prev => ({
...prev,
loading: false, loading: false,
authed: false, authed: false,
error: error as SessionError error: error as SessionError
}); }));
} }
}; }, []);
/** /**
* Login with a username * Login with a username
*/ */
const login = async (username: string): Promise<boolean> => { const login = useCallback(async (username: string): Promise<boolean> => {
setSession({ loading: true }); setSessionState(prev => ({ ...prev, loading: true }));
const result = await AuthService.login(username); try {
const result = await AuthService.login(username);
if (result.success && result.session && result.fileSystem) { if (result.success && result.session && result.fileSystem) {
setSession(result.session); setSessionState(result.session);
setFileSystem(result.fileSystem); setFileSystemState(result.fileSystem);
return true;
} else { // Save session to localStorage if authenticated
setSession({ if (result.session.authed && result.session.username) {
saveSession(result.session);
}
return true;
} else {
setSessionState(prev => ({
...prev,
loading: false,
error: result.error as SessionError
}));
return false;
}
} catch (error) {
console.error('Login error:', error);
setSessionState(prev => ({
...prev,
loading: false, loading: false,
error: result.error as SessionError error: error as SessionError
}); }));
return false; return false;
} }
}; }, []);
/** /**
* Register a new user * Register a new user
*/ */
const register = async (username: string): Promise<boolean> => { const register = useCallback(async (username: string): Promise<boolean> => {
setSession({ loading: true }); setSessionState(prev => ({ ...prev, loading: true }));
const result = await AuthService.register(username); try {
const result = await AuthService.register(username);
if (result.success && result.session && result.fileSystem) { if (result.success && result.session && result.fileSystem) {
setSession(result.session); setSessionState(result.session);
setFileSystem(result.fileSystem); setFileSystemState(result.fileSystem);
return true;
} else { // Save session to localStorage if authenticated
setSession({ if (result.session.authed && result.session.username) {
saveSession(result.session);
}
return true;
} else {
setSessionState(prev => ({
...prev,
loading: false,
error: result.error as SessionError
}));
return false;
}
} catch (error) {
console.error('Register error:', error);
setSessionState(prev => ({
...prev,
loading: false, loading: false,
error: result.error as SessionError error: error as SessionError
}); }));
return false; return false;
} }
}; }, []);
/** /**
* Clear the current session * Clear the current session
*/ */
const clearSession = (): void => { const clearSession = useCallback((): void => {
clearStoredSession(); clearStoredSession();
setSession({ setSessionState({
username: '', username: '',
authed: false, authed: false,
loading: false, loading: false,
backupCreated: null backupCreated: null,
obsidianVaultPath: undefined,
obsidianVaultName: undefined
}); });
setFileSystem(null); setFileSystemState(null);
}; }, []);
/** /**
* Logout the current user * Logout the current user
*/ */
const logout = async (): Promise<void> => { const logout = useCallback(async (): Promise<void> => {
try { try {
await AuthService.logout(); await AuthService.logout();
clearSession(); clearSession();
@ -135,14 +178,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
console.error('Logout error:', error); console.error('Logout error:', error);
throw error; throw error;
} }
}; }, [clearSession]);
// Initialize on mount // Initialize on mount
useEffect(() => { useEffect(() => {
initialize(); try {
}, []); initialize();
} catch (error) {
console.error('Auth initialization error in useEffect:', error);
// Set a safe fallback state
setSessionState(prev => ({
...prev,
loading: false,
authed: false
}));
}
}, []); // Empty dependency array - only run once on mount
const contextValue: AuthContextType = { const contextValue: AuthContextType = useMemo(() => ({
session, session,
setSession, setSession,
updateSession: setSession, updateSession: setSession,
@ -153,7 +206,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
login, login,
register, register,
logout logout
}; }), [session, setSession, clearSession, fileSystem, setFileSystem, initialize, login, register, logout]);
return ( return (
<AuthContext.Provider value={contextValue}> <AuthContext.Provider value={contextValue}>

View File

@ -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
}

422
src/css/location.css Normal file
View File

@ -0,0 +1,422 @@
/* 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;
}
}

1239
src/css/obsidian-browser.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,68 @@
/* Obsidian Toolbar Button Styles */
.obsidian-toolbar-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: #333;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.obsidian-toolbar-button:hover {
background: #f8f9fa;
border-color: #007acc;
color: #007acc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.obsidian-toolbar-button:active {
background: #e3f2fd;
border-color: #005a9e;
color: #005a9e;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.obsidian-toolbar-button svg {
flex-shrink: 0;
width: 16px;
height: 16px;
}
.obsidian-toolbar-button span {
white-space: nowrap;
}
/* Integration with TLDraw toolbar */
.tlui-toolbar .obsidian-toolbar-button {
margin: 0 2px;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.obsidian-toolbar-button {
background: #2d2d2d;
border-color: #404040;
color: #e0e0e0;
}
.obsidian-toolbar-button:hover {
background: #3d3d3d;
border-color: #007acc;
color: #007acc;
}
.obsidian-toolbar-button:active {
background: #1a3a5c;
border-color: #005a9e;
color: #005a9e;
}
}

View File

@ -391,6 +391,19 @@ p:has(+ ol) {
-webkit-tap-highlight-color: transparent; -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 { .tl-background {
background-color: transparent; background-color: transparent;
} }

View File

@ -66,6 +66,322 @@
} }
} }
/* Profile Container Styles */
.profile-container {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-width: 500px;
margin: 0 auto;
}
.profile-header h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
.profile-settings {
margin-bottom: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.profile-settings h4 {
margin: 0 0 16px 0;
color: #495057;
font-size: 18px;
font-weight: 600;
}
/* Current Vault Section */
.current-vault-section {
margin-bottom: 20px;
padding: 16px;
background: white;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.vault-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.vault-name {
display: flex;
align-items: center;
gap: 8px;
}
.vault-label {
font-weight: 600;
color: #495057;
font-size: 14px;
}
.vault-name-text {
font-weight: 700;
color: #007acc;
font-size: 16px;
background: #e3f2fd;
padding: 4px 8px;
border-radius: 4px;
}
.vault-path-info {
font-size: 12px;
color: #6c757d;
font-family: monospace;
word-break: break-all;
background: #f8f9fa;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #e9ecef;
}
.no-vault-info {
text-align: center;
padding: 20px;
}
.no-vault-text {
color: #6c757d;
font-style: italic;
font-size: 14px;
}
/* Vault Actions Section */
.vault-actions-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.change-vault-button {
background: #007acc;
color: white;
border: none;
padding: 12px 20px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 122, 204, 0.2);
}
.change-vault-button:hover {
background: #005a9e;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 122, 204, 0.3);
}
.disconnect-vault-button {
background: #dc3545;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.disconnect-vault-button:hover {
background: #c82333;
transform: translateY(-1px);
}
/* Advanced Settings */
.advanced-vault-settings {
margin-top: 16px;
border: 1px solid #e9ecef;
border-radius: 6px;
background: #f8f9fa;
}
.advanced-vault-settings summary {
padding: 12px 16px;
cursor: pointer;
font-weight: 500;
color: #495057;
background: #e9ecef;
border-radius: 6px 6px 0 0;
user-select: none;
}
.advanced-vault-settings summary:hover {
background: #dee2e6;
}
.advanced-vault-settings[open] summary {
border-radius: 6px 6px 0 0;
}
.advanced-vault-settings .vault-settings {
padding: 16px;
background: white;
border-radius: 0 0 6px 6px;
}
.vault-settings {
display: flex;
flex-direction: column;
gap: 12px;
}
.vault-edit-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.vault-path-input {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.vault-path-input:focus {
outline: none;
border-color: #007acc;
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
}
.vault-edit-actions {
display: flex;
gap: 8px;
}
.vault-display {
display: flex;
flex-direction: column;
gap: 8px;
}
.vault-path-display {
min-height: 32px;
display: flex;
align-items: center;
}
.vault-path-text {
color: #495057;
font-size: 14px;
word-break: break-all;
background: white;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #e9ecef;
flex: 1;
}
.no-vault-text {
color: #6c757d;
font-style: italic;
font-size: 14px;
}
.vault-actions {
display: flex;
gap: 8px;
}
.edit-button, .save-button, .cancel-button, .clear-button {
padding: 6px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
background: white;
color: #495057;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.edit-button:hover, .save-button:hover {
background: #007acc;
color: white;
border-color: #007acc;
}
.save-button {
background: #28a745;
color: white;
border-color: #28a745;
}
.save-button:hover {
background: #218838;
border-color: #218838;
}
.cancel-button {
background: #6c757d;
color: white;
border-color: #6c757d;
}
.cancel-button:hover {
background: #5a6268;
border-color: #5a6268;
}
.clear-button {
background: #dc3545;
color: white;
border-color: #dc3545;
}
.clear-button:hover {
background: #c82333;
border-color: #c82333;
}
.profile-actions {
margin-bottom: 16px;
}
.logout-button {
background: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
.logout-button:hover {
background: #c82333;
}
.backup-reminder {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 12px;
color: #856404;
font-size: 14px;
}
.backup-reminder p {
margin: 0;
}
/* Responsive design */ /* Responsive design */
@media (max-width: 768px) { @media (max-width: 768px) {
.custom-user-profile { .custom-user-profile {
@ -74,4 +390,13 @@
padding: 6px 10px; padding: 6px 10px;
font-size: 12px; font-size: 12px;
} }
.profile-container {
padding: 16px;
margin: 0 16px;
}
.vault-edit-actions, .vault-actions {
flex-direction: column;
}
} }

View File

@ -158,10 +158,22 @@ export const DEFAULT_GESTURES: Gesture[] = [
type: "geo", type: "geo",
x: center?.x! - w / 2, x: center?.x! - w / 2,
y: center?.y! - h / 2, y: center?.y! - h / 2,
isLocked: false,
props: { props: {
fill: "solid", fill: "solid",
w: w, w: w,
h: h, h: h,
geo: "rectangle",
dash: "draw",
size: "m",
font: "draw",
align: "middle",
verticalAlign: "middle",
growY: 0,
url: "",
scale: 1,
labelColor: "black",
richText: [] as any
}, },
}) })
}, },

View File

@ -120,6 +120,10 @@ export class GraphLayoutCollection extends BaseCollection {
type: "geo", type: "geo",
x: node.x - x, x: node.x - x,
y: node.y - y, y: node.y - y,
props: {
...shape.props,
richText: (shape.props as any)?.richText || [] as any, // Ensure richText exists
},
}); });
} }
}; };

View File

@ -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

View File

@ -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 {
readonly length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
readonly length: number
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
readonly isFinal: boolean
}
interface SpeechRecognitionAlternative {
readonly transcript: string
readonly 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) => {
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)
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

View File

@ -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

View File

@ -0,0 +1,455 @@
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 {
const subscribeResult = 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')
}
})
// Handle Promise if subscribe returns one
if (subscribeResult instanceof Promise) {
subscribeResult.then((result: any) => {
unsubscribe = result?.unsubscribe || undefined
console.log(`✅ Subscribe called successfully for ${lens}`)
}).catch((err) => {
console.error(`❌ Error in subscribe promise for ${lens}:`, err)
})
} else if (subscribeResult && typeof subscribeResult === 'object' && subscribeResult !== null) {
const result = subscribeResult as { unsubscribe?: () => void }
unsubscribe = result?.unsubscribe || undefined
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)

View File

@ -35,7 +35,9 @@ export class AuthService {
username: storedSession.username, username: storedSession.username,
authed: true, authed: true,
loading: false, loading: false,
backupCreated: backupStatus.created backupCreated: backupStatus.created,
obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName
}; };
} else { } else {
// ODD session not available, but we have crypto auth // ODD session not available, but we have crypto auth
@ -43,7 +45,9 @@ export class AuthService {
username: storedSession.username, username: storedSession.username,
authed: true, authed: true,
loading: false, loading: false,
backupCreated: storedSession.backupCreated backupCreated: storedSession.backupCreated,
obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName
}; };
} }
} catch (oddError) { } catch (oddError) {
@ -52,7 +56,9 @@ export class AuthService {
username: storedSession.username, username: storedSession.username,
authed: true, authed: true,
loading: false, loading: false,
backupCreated: storedSession.backupCreated backupCreated: storedSession.backupCreated,
obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName
}; };
} }
} else { } else {

View File

@ -9,6 +9,8 @@ export interface StoredSession {
authed: boolean; authed: boolean;
timestamp: number; timestamp: number;
backupCreated: boolean | null; backupCreated: boolean | null;
obsidianVaultPath?: string;
obsidianVaultName?: string;
} }
/** /**
@ -22,12 +24,15 @@ export const saveSession = (session: Session): boolean => {
username: session.username, username: session.username,
authed: session.authed, authed: session.authed,
timestamp: Date.now(), timestamp: Date.now(),
backupCreated: session.backupCreated backupCreated: session.backupCreated,
obsidianVaultPath: session.obsidianVaultPath,
obsidianVaultName: session.obsidianVaultName
}; };
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession)); localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession));
return true; return true;
} catch (error) { } catch (error) {
console.error('🔧 Error saving session:', error);
return false; return false;
} }
}; };
@ -40,7 +45,9 @@ export const loadSession = (): StoredSession | null => {
try { try {
const stored = localStorage.getItem(SESSION_STORAGE_KEY); const stored = localStorage.getItem(SESSION_STORAGE_KEY);
if (!stored) return null; if (!stored) {
return null;
}
const parsed = JSON.parse(stored) as StoredSession; const parsed = JSON.parse(stored) as StoredSession;
@ -50,9 +57,9 @@ export const loadSession = (): StoredSession | null => {
localStorage.removeItem(SESSION_STORAGE_KEY); localStorage.removeItem(SESSION_STORAGE_KEY);
return null; return null;
} }
return parsed; return parsed;
} catch (error) { } catch (error) {
console.error('🔧 Error loading session:', error);
return null; return null;
} }
}; };

View File

@ -3,6 +3,8 @@ export interface Session {
authed: boolean; authed: boolean;
loading: boolean; loading: boolean;
backupCreated: boolean | null; backupCreated: boolean | null;
obsidianVaultPath?: string;
obsidianVaultName?: string;
error?: string; error?: string;
} }

193
src/lib/clientConfig.ts Normal file
View File

@ -0,0 +1,193 @@
/**
* Client-side configuration utility
* Handles environment variables in browser environment
*/
export interface ClientConfig {
githubToken?: string
quartzRepo?: string
quartzBranch?: string
cloudflareApiKey?: string
cloudflareAccountId?: string
quartzApiUrl?: string
quartzApiKey?: string
webhookUrl?: string
webhookSecret?: string
openaiApiKey?: string
}
/**
* Get client-side configuration
* This works in both browser and server environments
*/
export function getClientConfig(): ClientConfig {
// In Vite, environment variables are available via import.meta.env
// In Next.js, NEXT_PUBLIC_ variables are available at build time
if (typeof window !== 'undefined') {
// Browser environment - check for Vite first, then Next.js
if (typeof import.meta !== 'undefined' && import.meta.env) {
// Vite environment
return {
githubToken: import.meta.env.VITE_GITHUB_TOKEN || import.meta.env.NEXT_PUBLIC_GITHUB_TOKEN,
quartzRepo: import.meta.env.VITE_QUARTZ_REPO || import.meta.env.NEXT_PUBLIC_QUARTZ_REPO,
quartzBranch: import.meta.env.VITE_QUARTZ_BRANCH || import.meta.env.NEXT_PUBLIC_QUARTZ_BRANCH,
cloudflareApiKey: import.meta.env.VITE_CLOUDFLARE_API_KEY || import.meta.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY,
cloudflareAccountId: import.meta.env.VITE_CLOUDFLARE_ACCOUNT_ID || import.meta.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID,
quartzApiUrl: import.meta.env.VITE_QUARTZ_API_URL || import.meta.env.NEXT_PUBLIC_QUARTZ_API_URL,
quartzApiKey: import.meta.env.VITE_QUARTZ_API_KEY || import.meta.env.NEXT_PUBLIC_QUARTZ_API_KEY,
webhookUrl: import.meta.env.VITE_QUARTZ_WEBHOOK_URL || import.meta.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL,
webhookSecret: import.meta.env.VITE_QUARTZ_WEBHOOK_SECRET || import.meta.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET,
openaiApiKey: import.meta.env.VITE_OPENAI_API_KEY || import.meta.env.NEXT_PUBLIC_OPENAI_API_KEY,
}
} else {
// Next.js environment
return {
githubToken: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_GITHUB_TOKEN,
quartzRepo: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_REPO,
quartzBranch: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_BRANCH,
cloudflareApiKey: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_CLOUDFLARE_API_KEY,
cloudflareAccountId: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID,
quartzApiUrl: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_API_URL,
quartzApiKey: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_API_KEY,
webhookUrl: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL,
webhookSecret: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET,
openaiApiKey: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_OPENAI_API_KEY,
}
}
} else {
// Server environment
return {
githubToken: process.env.VITE_GITHUB_TOKEN || process.env.NEXT_PUBLIC_GITHUB_TOKEN,
quartzRepo: process.env.VITE_QUARTZ_REPO || process.env.NEXT_PUBLIC_QUARTZ_REPO,
quartzBranch: process.env.VITE_QUARTZ_BRANCH || process.env.NEXT_PUBLIC_QUARTZ_BRANCH,
cloudflareApiKey: process.env.VITE_CLOUDFLARE_API_KEY || process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY,
cloudflareAccountId: process.env.VITE_CLOUDFLARE_ACCOUNT_ID || process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID,
quartzApiUrl: process.env.VITE_QUARTZ_API_URL || process.env.NEXT_PUBLIC_QUARTZ_API_URL,
quartzApiKey: process.env.VITE_QUARTZ_API_KEY || process.env.NEXT_PUBLIC_QUARTZ_API_KEY,
webhookUrl: process.env.VITE_QUARTZ_WEBHOOK_URL || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL,
webhookSecret: process.env.VITE_QUARTZ_WEBHOOK_SECRET || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET,
}
}
}
/**
* Check if GitHub integration is configured
*/
export function isGitHubConfigured(): boolean {
const config = getClientConfig()
return !!(config.githubToken && config.quartzRepo)
}
/**
* Get GitHub configuration for API calls
*/
export function getGitHubConfig(): { token: string; repo: string; branch: string } | null {
const config = getClientConfig()
if (!config.githubToken || !config.quartzRepo) {
return null
}
const [owner, repo] = config.quartzRepo.split('/')
if (!owner || !repo) {
return null
}
return {
token: config.githubToken,
repo: config.quartzRepo,
branch: config.quartzBranch || 'main'
}
}
/**
* Check if OpenAI integration is configured
* Reads from user profile settings (localStorage) instead of environment variables
*/
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) {
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
} catch (e) {
return false
}
}
/**
* Get OpenAI API key for API calls
* Reads from user profile settings (localStorage) instead of environment variables
*/
export function getOpenAIConfig(): { apiKey: string } | null {
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() !== '') {
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
}
}

View File

@ -0,0 +1,353 @@
/**
* GitHub Quartz Reader
* Reads Quartz content directly from GitHub repository using the GitHub API
*/
export interface GitHubQuartzConfig {
token: string
owner: string
repo: string
branch?: string
contentPath?: string
}
export interface GitHubFile {
name: string
path: string
sha: string
size: number
url: string
html_url: string
git_url: string
download_url: string
type: 'file' | 'dir'
content?: string
encoding?: string
}
export interface QuartzNoteFromGitHub {
id: string
title: string
content: string
tags: string[]
frontmatter: Record<string, any>
filePath: string
lastModified: string
htmlUrl: string
rawUrl: string
}
export class GitHubQuartzReader {
private config: GitHubQuartzConfig
constructor(config: GitHubQuartzConfig) {
this.config = {
branch: 'main',
contentPath: 'content',
...config
}
}
/**
* Get all Markdown files from the Quartz repository
*/
async getAllNotes(): Promise<QuartzNoteFromGitHub[]> {
try {
// Get the content directory
const contentFiles = await this.getDirectoryContents(this.config.contentPath || '')
// Filter for 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 {
// Get the actual file contents (not just metadata)
const fileWithContent = await this.getFileContents(file.path)
const note = await this.getNoteFromFile(fileWithContent)
if (note) {
notes.push(note)
}
} catch (error) {
console.warn(`Failed to process file ${file.path}:`, error)
}
}
return notes
} catch (error) {
console.error('❌ Failed to fetch notes from GitHub:', error)
throw error
}
}
/**
* Get a specific note by file path
*/
async getNoteByPath(filePath: string): Promise<QuartzNoteFromGitHub | null> {
try {
const fullPath = filePath.startsWith(this.config.contentPath || '')
? filePath
: `${this.config.contentPath}/${filePath}`
const file = await this.getFileContents(fullPath)
return this.getNoteFromFile(file)
} catch (error) {
console.error(`Failed to get note ${filePath}:`, error)
return null
}
}
/**
* Search notes by query
*/
async searchNotes(query: string): Promise<QuartzNoteFromGitHub[]> {
const allNotes = await this.getAllNotes()
const searchTerm = query.toLowerCase()
return allNotes.filter(note =>
note.title.toLowerCase().includes(searchTerm) ||
note.content.toLowerCase().includes(searchTerm) ||
note.tags.some(tag => tag.toLowerCase().includes(searchTerm))
)
}
/**
* Get directory contents from GitHub
*/
private async getDirectoryContents(path: string): Promise<GitHubFile[]> {
const url = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/contents/${path}?ref=${this.config.branch}`
const response = await fetch(url, {
headers: {
'Authorization': `token ${this.config.token}`,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Canvas-Website-Quartz-Reader'
}
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
const files: GitHubFile[] = await response.json()
return files
}
/**
* Get file contents from GitHub
*/
private async getFileContents(filePath: string): Promise<GitHubFile> {
const url = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/contents/${filePath}?ref=${this.config.branch}`
const response = await fetch(url, {
headers: {
'Authorization': `token ${this.config.token}`,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Canvas-Website-Quartz-Reader'
}
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
/**
* Convert GitHub file to Quartz note
*/
private async getNoteFromFile(file: GitHubFile): Promise<QuartzNoteFromGitHub | null> {
try {
// Validate file object
if (!file || !file.path) {
return null
}
// Decode base64 content
let content = ''
if (file.content) {
try {
// Handle different encoding types
if (file.encoding === 'base64') {
content = atob(file.content)
} else {
// Try direct decoding if not base64
content = file.content
}
} catch (decodeError) {
// Try alternative decoding methods
try {
content = decodeURIComponent(escape(atob(file.content)))
} catch (altError) {
console.error(`Failed to decode content for ${file.path}:`, altError)
return null
}
}
}
// Parse frontmatter and content
const { frontmatter, content: markdownContent } = this.parseMarkdownWithFrontmatter(content)
// Extract title
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)
// Generate note ID
const id = this.generateNoteId(file.path, title)
return {
id,
title,
content: markdownContent,
tags,
frontmatter,
filePath: file.path,
lastModified: file.sha, // Using SHA as last modified indicator
htmlUrl: file.html_url,
rawUrl: file.download_url || file.git_url
}
} catch (error) {
console.error(`Failed to parse file ${file.path}:`, error)
return null
}
}
/**
* Parse Markdown content with frontmatter
*/
private parseMarkdownWithFrontmatter(content: string): { frontmatter: Record<string, any>, content: string } {
// 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)
if (match) {
const frontmatterText = match[1]
const markdownContent = match[2].trim() // Remove leading/trailing whitespace
// Parse YAML frontmatter (simplified but more robust)
const frontmatter: Record<string, any> = {}
const lines = frontmatterText.split(/\r?\n/)
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine || trimmedLine.startsWith('#')) continue // Skip empty lines and comments
const colonIndex = trimmedLine.indexOf(':')
if (colonIndex > 0) {
const key = trimmedLine.substring(0, colonIndex).trim()
let value = trimmedLine.substring(colonIndex + 1).trim()
// Remove quotes
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
// Parse arrays
if (value.startsWith('[') && value.endsWith(']')) {
const arrayValue = value.slice(1, -1).split(',').map(item =>
item.trim().replace(/^["']|["']$/g, '')
)
frontmatter[key] = arrayValue
continue
}
// Parse boolean values
if (value.toLowerCase() === 'true') {
frontmatter[key] = true
} else if (value.toLowerCase() === 'false') {
frontmatter[key] = false
} else {
frontmatter[key] = value
}
}
}
return { frontmatter, content: markdownContent }
}
return { frontmatter: {}, content: content.trim() }
}
/**
* Extract title from file path
*/
private extractTitleFromPath(fileName: string): string {
if (!fileName) {
return 'Untitled'
}
return fileName
.replace(/\.(md|markdown)$/i, '')
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
}
/**
* Extract tags from frontmatter and content
*/
private extractTags(frontmatter: Record<string, any>, content: string): string[] {
const tags: string[] = []
// From frontmatter
if (frontmatter.tags) {
if (Array.isArray(frontmatter.tags)) {
tags.push(...frontmatter.tags)
} else if (typeof frontmatter.tags === 'string') {
tags.push(frontmatter.tags)
}
}
// From content (hashtags)
const hashtagMatches = content.match(/#[\w-]+/g)
if (hashtagMatches) {
tags.push(...hashtagMatches.map(tag => tag.substring(1)))
}
return [...new Set(tags)] // Remove duplicates
}
/**
* Generate note ID
*/
private generateNoteId(filePath: string, title: string): string {
// Use filePath as primary identifier, with title as fallback for uniqueness
const baseId = filePath || title
return baseId
.replace(/[^a-zA-Z0-9]/g, '_')
.toLowerCase()
}
/**
* Validate GitHub configuration
*/
static validateConfig(config: Partial<GitHubQuartzConfig>): { isValid: boolean; errors: string[] } {
const errors: string[] = []
if (!config.token) {
errors.push('GitHub token is required')
}
if (!config.owner) {
errors.push('Repository owner is required')
}
if (!config.repo) {
errors.push('Repository name is required')
}
return {
isValid: errors.length === 0,
errors
}
}
}

View File

@ -0,0 +1,127 @@
/**
* GitHub Setup Validator
* Helps users validate their GitHub integration setup
*/
import { getClientConfig } from './clientConfig'
export interface GitHubSetupStatus {
isValid: boolean
issues: string[]
warnings: string[]
suggestions: string[]
}
export function validateGitHubSetup(): GitHubSetupStatus {
const issues: string[] = []
const warnings: string[] = []
const suggestions: string[] = []
// Check for required environment variables using client config
const config = getClientConfig()
const githubToken = config.githubToken
const quartzRepo = config.quartzRepo
if (!githubToken) {
issues.push('NEXT_PUBLIC_GITHUB_TOKEN is not set')
suggestions.push('Create a GitHub Personal Access Token and add it to your .env.local file')
} else if (githubToken === 'your_github_token_here') {
issues.push('NEXT_PUBLIC_GITHUB_TOKEN is still set to placeholder value')
suggestions.push('Replace the placeholder with your actual GitHub token')
}
if (!quartzRepo) {
issues.push('NEXT_PUBLIC_QUARTZ_REPO is not set')
suggestions.push('Add your Quartz repository name (format: username/repo-name) to .env.local')
} else if (quartzRepo === 'your_username/your-quartz-repo') {
issues.push('NEXT_PUBLIC_QUARTZ_REPO is still set to placeholder value')
suggestions.push('Replace the placeholder with your actual repository name')
} else if (!quartzRepo.includes('/')) {
issues.push('NEXT_PUBLIC_QUARTZ_REPO format is invalid')
suggestions.push('Use format: username/repository-name')
}
// Check for optional but recommended settings
const quartzBranch = config.quartzBranch
if (!quartzBranch) {
warnings.push('NEXT_PUBLIC_QUARTZ_BRANCH not set, defaulting to "main"')
}
// Validate GitHub token format (basic check)
if (githubToken && githubToken !== 'your_github_token_here') {
if (!githubToken.startsWith('ghp_') && !githubToken.startsWith('github_pat_')) {
warnings.push('GitHub token format looks unusual')
suggestions.push('Make sure you copied the token correctly from GitHub')
}
}
// Validate repository name format
if (quartzRepo && quartzRepo !== 'your_username/your-quartz-repo' && quartzRepo.includes('/')) {
const [owner, repo] = quartzRepo.split('/')
if (!owner || !repo) {
issues.push('Invalid repository name format')
suggestions.push('Use format: username/repository-name')
}
}
return {
isValid: issues.length === 0,
issues,
warnings,
suggestions
}
}
export function getGitHubSetupInstructions(): string[] {
return [
'1. Create a GitHub Personal Access Token:',
' - Go to https://github.com/settings/tokens',
' - Click "Generate new token" → "Generate new token (classic)"',
' - Select "repo" and "workflow" scopes',
' - Copy the token immediately',
'',
'2. Set up your Quartz repository:',
' - Create a new repository or use an existing one',
' - Set up Quartz in that repository',
' - Enable GitHub Pages in repository settings',
'',
'3. Configure environment variables:',
' - Create a .env.local file in your project root',
' - Add NEXT_PUBLIC_GITHUB_TOKEN=your_token_here',
' - Add NEXT_PUBLIC_QUARTZ_REPO=username/repo-name',
'',
'4. Test the integration:',
' - Start your development server',
' - Import or create notes',
' - Edit a note and click "Sync Updates"',
' - Check your GitHub repository for changes'
]
}
export function logGitHubSetupStatus(): void {
const status = validateGitHubSetup()
console.log('🔧 GitHub Integration Setup Status:')
if (status.isValid) {
console.log('✅ GitHub integration is properly configured!')
} else {
console.log('❌ GitHub integration has issues:')
status.issues.forEach(issue => console.log(` - ${issue}`))
}
if (status.warnings.length > 0) {
console.log('⚠️ Warnings:')
status.warnings.forEach(warning => console.log(` - ${warning}`))
}
if (status.suggestions.length > 0) {
console.log('💡 Suggestions:')
status.suggestions.forEach(suggestion => console.log(` - ${suggestion}`))
}
if (!status.isValid) {
console.log('\n📋 Setup Instructions:')
getGitHubSetupInstructions().forEach(instruction => console.log(instruction))
}
}

View File

@ -0,0 +1,302 @@
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 fs = this.fs as any;
const exists = await fs.exists(dirPath);
if (!exists) {
await fs.mkdir(dirPath);
}
} 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 as any).file(...this.locationsPath, `${location.id}.json`);
const content = new TextEncoder().encode(JSON.stringify(location, null, 2));
const fs = this.fs as any;
await fs.write(filePath, content);
await 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 as any).file(...this.locationsPath, `${locationId}.json`);
const fs = this.fs as any;
const exists = await fs.exists(filePath);
if (!exists) {
return null;
}
const content = await fs.read(filePath);
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 as any).file(...this.sharesPath, `${share.id}.json`);
const shareContent = new TextEncoder().encode(JSON.stringify(share, null, 2));
const fs = this.fs as any;
await fs.write(sharePath, shareContent);
// Create public reference file for share validation (only token, not full data)
const publicSharePath = (odd.path as any).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 fs.write(publicSharePath, publicContent);
await 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 as any).file(...this.publicSharesPath, `${shareToken}.json`);
const fs = this.fs as any;
const publicExists = await fs.exists(publicSharePath);
if (!publicExists) {
return null;
}
const publicContent = await fs.read(publicSharePath);
const publicText = new TextDecoder().decode(publicContent as Uint8Array);
const publicRef = JSON.parse(publicText);
// Now get full share from private directory
const sharePath = (odd.path as any).file(...this.sharesPath, `${publicRef.shareId}.json`);
const shareExists = await fs.exists(sharePath);
if (!shareExists) {
return null;
}
const shareContent = await fs.read(sharePath);
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 fs = this.fs as any;
const exists = await fs.exists(dirPath);
if (!exists) {
return [];
}
const files = await fs.ls(dirPath);
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 as any).file(...this.sharesPath, `${shareId}.json`);
const fs = this.fs as any;
const exists = await fs.exists(sharePath);
if (!exists) {
return null;
}
const content = await fs.read(sharePath);
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('');
}

52
src/lib/location/types.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* 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;
}

1246
src/lib/obsidianImporter.ts Normal file

File diff suppressed because it is too large Load Diff

327
src/lib/quartzSync.ts Normal file
View File

@ -0,0 +1,327 @@
/**
* Quartz Sync Integration
* Provides multiple approaches for syncing notes back to Quartz sites
*/
export interface QuartzSyncConfig {
githubToken?: string
githubRepo?: string
quartzUrl?: string
cloudflareApiKey?: string
cloudflareAccountId?: string
}
export interface QuartzNote {
id: string
title: string
content: string
tags: string[]
frontmatter: Record<string, any>
filePath: string
lastModified: Date
}
export class QuartzSync {
private config: QuartzSyncConfig
constructor(config: QuartzSyncConfig) {
this.config = config
}
/**
* Approach 1: GitHub API Integration
* Sync directly to the GitHub repository that powers the Quartz site
*/
async syncToGitHub(note: QuartzNote): Promise<boolean> {
if (!this.config.githubToken || !this.config.githubRepo) {
throw new Error('GitHub token and repository required for GitHub sync')
}
try {
const { githubToken, githubRepo } = this.config
const [owner, repo] = githubRepo.split('/')
console.log('🔧 GitHub sync details:', {
owner,
repo,
noteTitle: note.title,
noteFilePath: note.filePath
})
// Get the current file content to check if it exists
const filePath = `content/${note.filePath}`
let sha: string | undefined
console.log('🔍 Checking for existing file:', filePath)
try {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`
console.log('🌐 Making API call to:', apiUrl)
const existingFile = await fetch(apiUrl, {
headers: {
'Authorization': `token ${githubToken}`,
'Accept': 'application/vnd.github.v3+json'
}
})
console.log('📡 API response status:', existingFile.status)
if (existingFile.ok) {
const fileData = await existingFile.json() as { sha: string }
sha = fileData.sha
console.log('✅ File exists, will update with SHA:', sha)
} else {
console.log(' File does not exist, will create new one')
}
} catch (error) {
// File doesn't exist, that's okay
console.log(' File does not exist, will create new one:', error)
}
// Create the markdown content
const frontmatter = Object.entries(note.frontmatter)
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
.join('\n')
const content = `---
${frontmatter}
---
${note.content}`
// Encode content to base64
const encodedContent = btoa(unescape(encodeURIComponent(content)))
// Create or update the file
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
{
method: 'PUT',
headers: {
'Authorization': `token ${githubToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: `Update note: ${note.title}`,
content: encodedContent,
...(sha && { sha }) // Include SHA if updating existing file
})
}
)
if (response.ok) {
const result = await response.json() as { commit: { sha: string } }
console.log('✅ Successfully synced note to GitHub:', note.title)
console.log('📁 File path:', filePath)
console.log('🔗 Commit SHA:', result.commit.sha)
return true
} else {
const error = await response.text()
let errorMessage = `GitHub API error: ${response.status}`
try {
const errorData = JSON.parse(error)
if (errorData.message) {
errorMessage += ` - ${errorData.message}`
}
} catch (e) {
errorMessage += ` - ${error}`
}
throw new Error(errorMessage)
}
} catch (error) {
console.error('❌ Failed to sync to GitHub:', error)
throw error
}
}
/**
* Approach 2: Cloudflare R2 + Durable Objects
* Use the existing Cloudflare infrastructure for persistent storage
*/
async syncToCloudflare(note: QuartzNote): Promise<boolean> {
if (!this.config.cloudflareApiKey || !this.config.cloudflareAccountId) {
throw new Error('Cloudflare credentials required for Cloudflare sync')
}
try {
// Store in Cloudflare R2
const response = await fetch('/api/quartz/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.cloudflareApiKey}`
},
body: JSON.stringify({
note,
accountId: this.config.cloudflareAccountId
})
})
if (response.ok) {
console.log('✅ Successfully synced note to Cloudflare:', note.title)
return true
} else {
throw new Error(`Cloudflare sync failed: ${response.statusText}`)
}
} catch (error) {
console.error('❌ Failed to sync to Cloudflare:', error)
throw error
}
}
/**
* Approach 3: Direct Quartz API (if available)
* Some Quartz sites may expose APIs for content updates
*/
async syncToQuartzAPI(note: QuartzNote): Promise<boolean> {
if (!this.config.quartzUrl) {
throw new Error('Quartz URL required for API sync')
}
try {
const response = await fetch(`${this.config.quartzUrl}/api/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(note)
})
if (response.ok) {
console.log('✅ Successfully synced note to Quartz API:', note.title)
return true
} else {
throw new Error(`Quartz API error: ${response.statusText}`)
}
} catch (error) {
console.error('❌ Failed to sync to Quartz API:', error)
throw error
}
}
/**
* Approach 4: Webhook Integration
* Send updates to a webhook that can process and sync to Quartz
*/
async syncViaWebhook(note: QuartzNote, webhookUrl: string): Promise<boolean> {
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'note_update',
note,
timestamp: new Date().toISOString()
})
})
if (response.ok) {
console.log('✅ Successfully sent note to webhook:', note.title)
return true
} else {
throw new Error(`Webhook error: ${response.statusText}`)
}
} catch (error) {
console.error('❌ Failed to sync via webhook:', error)
throw error
}
}
/**
* Smart sync - tries multiple approaches in order of preference
* Prioritizes GitHub integration for Quartz sites
*/
async smartSync(note: QuartzNote): Promise<boolean> {
console.log('🔄 Starting smart sync for note:', note.title)
console.log('🔧 Sync config available:', {
hasGitHubToken: !!this.config.githubToken,
hasGitHubRepo: !!this.config.githubRepo,
hasCloudflareApiKey: !!this.config.cloudflareApiKey,
hasCloudflareAccountId: !!this.config.cloudflareAccountId,
hasQuartzUrl: !!this.config.quartzUrl
})
// Check if GitHub integration is available and preferred
if (this.config.githubToken && this.config.githubRepo) {
try {
console.log('🔄 Attempting GitHub sync (preferred method)')
const result = await this.syncToGitHub(note)
if (result) {
console.log('✅ GitHub sync successful!')
return true
}
} catch (error) {
console.warn('⚠️ GitHub sync failed, trying other methods:', error)
console.warn('⚠️ GitHub sync error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : 'No stack trace'
})
}
} else {
console.log('⚠️ GitHub sync not available - missing token or repo')
}
// Fallback to other methods
const fallbackMethods = [
() => this.syncToCloudflare(note),
() => this.syncToQuartzAPI(note)
]
for (const syncMethod of fallbackMethods) {
try {
const result = await syncMethod()
if (result) return true
} catch (error) {
console.warn('Sync method failed, trying next:', error)
continue
}
}
throw new Error('All sync methods failed')
}
}
/**
* Utility function to create a Quartz note from an ObsNote shape
*/
export function createQuartzNoteFromShape(shape: any): QuartzNote {
const title = shape.props.title || 'Untitled'
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,
content,
tags: tags.map((tag: string) => tag.replace('#', '')),
frontmatter: {
title: title,
tags: tags.map((tag: string) => tag.replace('#', '')),
created: new Date().toISOString(),
modified: new Date().toISOString()
},
filePath: filePath,
lastModified: new Date()
}
}

View File

@ -14,7 +14,6 @@ export const generateCanvasScreenshot = async (editor: Editor): Promise<string |
try { try {
// Get all shapes on the current page // Get all shapes on the current page
const shapes = editor.getCurrentPageShapes(); const shapes = editor.getCurrentPageShapes();
console.log('Found shapes:', shapes.length);
if (shapes.length === 0) { if (shapes.length === 0) {
console.log('No shapes found, no screenshot generated'); console.log('No shapes found, no screenshot generated');
@ -23,11 +22,9 @@ export const generateCanvasScreenshot = async (editor: Editor): Promise<string |
// Get all shape IDs for export // Get all shape IDs for export
const allShapeIds = shapes.map(shape => shape.id); const allShapeIds = shapes.map(shape => shape.id);
console.log('Exporting all shapes:', allShapeIds.length);
// Calculate bounds of all shapes to fit everything in view // Calculate bounds of all shapes to fit everything in view
const bounds = editor.getCurrentPageBounds(); const bounds = editor.getCurrentPageBounds();
console.log('Canvas bounds:', bounds);
// Use Tldraw's export functionality to get a blob with all content // Use Tldraw's export functionality to get a blob with all content
const blob = await exportToBlob({ const blob = await exportToBlob({
@ -78,8 +75,6 @@ export const generateCanvasScreenshot = async (editor: Editor): Promise<string |
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
}); });
console.log('Successfully exported board to data URL');
console.log('Screenshot data URL:', dataUrl);
return dataUrl; return dataUrl;
} catch (error) { } catch (error) {
console.error('Error generating screenshot:', error); console.error('Error generating screenshot:', error);
@ -144,12 +139,9 @@ export const hasBoardScreenshot = (slug: string): boolean => {
* This should be called when the board content changes significantly * This should be called when the board content changes significantly
*/ */
export const captureBoardScreenshot = async (editor: Editor, slug: string): Promise<void> => { export const captureBoardScreenshot = async (editor: Editor, slug: string): Promise<void> => {
console.log('Starting screenshot capture for:', slug);
const dataUrl = await generateCanvasScreenshot(editor); const dataUrl = await generateCanvasScreenshot(editor);
if (dataUrl) { if (dataUrl) {
console.log('Screenshot generated successfully for:', slug);
storeBoardScreenshot(slug, dataUrl); storeBoardScreenshot(slug, dataUrl);
console.log('Screenshot stored for:', slug);
} else { } else {
console.warn('Failed to generate screenshot for:', slug); console.warn('Failed to generate screenshot for:', slug);
} }

View File

@ -1,5 +1,5 @@
import { atom } from 'tldraw' import { atom } from 'tldraw'
import { SYSTEM_PROMPT } from '@/prompt' import { SYSTEM_PROMPT, CONSTANCE_SYSTEM_PROMPT } from '@/prompt'
export const PROVIDERS = [ export const PROVIDERS = [
{ {
@ -13,8 +13,8 @@ export const PROVIDERS = [
id: 'anthropic', id: 'anthropic',
name: 'Anthropic', name: 'Anthropic',
models: [ models: [
'claude-3-5-sonnet-20241022', 'claude-sonnet-4-5-20250929',
'claude-3-5-sonnet-20240620', 'claude-sonnet-4-20250522',
'claude-3-opus-20240229', 'claude-3-opus-20240229',
'claude-3-sonnet-20240229', 'claude-3-sonnet-20240229',
'claude-3-haiku-20240307', 'claude-3-haiku-20240307',
@ -25,6 +25,21 @@ export const PROVIDERS = [
// { id: 'google', name: 'Google', model: 'Gemeni 1.5 Flash', validate: (key: string) => true }, // { 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', { export const makeRealSettings = atom('make real settings', {
provider: 'openai' as (typeof PROVIDERS)[number]['id'] | 'all', provider: 'openai' as (typeof PROVIDERS)[number]['id'] | 'all',
models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])), models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])),
@ -33,6 +48,7 @@ export const makeRealSettings = atom('make real settings', {
anthropic: '', anthropic: '',
google: '', google: '',
}, },
personality: 'web-developer' as (typeof AI_PERSONALITIES)[number]['id'],
prompts: { prompts: {
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
}, },
@ -50,6 +66,7 @@ export function applySettingsMigrations(settings: any) {
google: '', google: '',
...keys, ...keys,
}, },
personality: 'web-developer' as (typeof AI_PERSONALITIES)[number]['id'],
prompts: { prompts: {
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
...prompts, ...prompts,

View File

@ -0,0 +1,35 @@
/**
* Test client configuration
* This file can be used to test if the client config is working properly
*/
import { getClientConfig, isGitHubConfigured, getGitHubConfig } from './clientConfig'
export function testClientConfig() {
console.log('🧪 Testing client configuration...')
const config = getClientConfig()
console.log('📋 Client config:', {
hasGithubToken: !!config.githubToken,
hasQuartzRepo: !!config.quartzRepo,
githubTokenLength: config.githubToken?.length || 0,
quartzRepo: config.quartzRepo
})
const isConfigured = isGitHubConfigured()
console.log('✅ GitHub configured:', isConfigured)
const githubConfig = getGitHubConfig()
console.log('🔧 GitHub config:', githubConfig)
return {
config,
isConfigured,
githubConfig
}
}
// Auto-run test in browser
if (typeof window !== 'undefined') {
testClientConfig()
}

57
src/lib/testHolon.ts Normal file
View File

@ -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()
}

View File

@ -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.` 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 = export const USER_PROMPT =
'Here are the latest wireframes. Please reply with a high-fidelity working prototype as a single HTML file.' 'Here are the latest wireframes. Please reply with a high-fidelity working prototype as a single HTML file.'

View File

@ -1,4 +1,5 @@
import { useSync } from "@tldraw/sync" import { useAutomergeSync } from "@/automerge/useAutomergeSync"
import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext"
import { useMemo, useEffect, useState } from "react" import { useMemo, useEffect, useState } from "react"
import { Tldraw, Editor, TLShapeId } from "tldraw" import { Tldraw, Editor, TLShapeId } from "tldraw"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
@ -31,6 +32,19 @@ import { PromptShapeTool } from "@/tools/PromptShapeTool"
import { PromptShape } from "@/shapes/PromptShapeUtil" import { PromptShape } from "@/shapes/PromptShapeUtil"
import { SharedPianoTool } from "@/tools/SharedPianoTool" import { SharedPianoTool } from "@/tools/SharedPianoTool"
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil" import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
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 { import {
lockElement, lockElement,
unlockElement, unlockElement,
@ -46,16 +60,14 @@ import { CmdK } from "@/CmdK"
import "react-cmdk/dist/cmdk.css" import "react-cmdk/dist/cmdk.css"
import "@/css/style.css" import "@/css/style.css"
import "@/css/obsidian-browser.css"
const collections: Collection[] = [GraphLayoutCollection] const collections: Collection[] = [GraphLayoutCollection]
import { useAuth } from "../context/AuthContext" import { useAuth } from "../context/AuthContext"
import { updateLastVisited } from "../lib/starredBoards" import { updateLastVisited } from "../lib/starredBoards"
import { captureBoardScreenshot } from "../lib/screenshotService" import { captureBoardScreenshot } from "../lib/screenshotService"
// Automatically switch between production and local dev based on environment import { WORKER_URL } from "../constants/workerUrl"
export const WORKER_URL = import.meta.env.DEV
? "http://localhost:5172"
: "https://jeffemmett-canvas.jeffemmett.workers.dev"
const customShapeUtils = [ const customShapeUtils = [
ChatBoxShape, ChatBoxShape,
@ -66,6 +78,14 @@ const customShapeUtils = [
MarkdownShape, MarkdownShape,
PromptShape, PromptShape,
SharedPianoShape, SharedPianoShape,
ObsNoteShape,
TranscriptionShape,
FathomTranscriptShape,
HolonShape,
HolonBrowserShape,
ObsidianBrowserShape,
FathomMeetingsBrowserShape,
LocationShareShape,
] ]
const customTools = [ const customTools = [
ChatBoxTool, ChatBoxTool,
@ -77,20 +97,102 @@ const customTools = [
PromptShapeTool, PromptShapeTool,
SharedPianoTool, SharedPianoTool,
GestureTool, GestureTool,
ObsNoteTool,
TranscriptionTool,
FathomTranscriptTool,
HolonTool,
FathomMeetingsTool,
] ]
export function Board() { export function Board() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const roomId = slug || "default-room"
// 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() const { session } = useAuth()
// Store roomId in localStorage for VideoChatShapeUtil to access
useEffect(() => {
localStorage.setItem('currentRoomId', roomId)
// One-time migration: clear old video chat storage entries
const oldStorageKeys = [
'videoChat_room_page_page',
'videoChat_room_page:page',
'videoChat_room_board_page_page'
];
oldStorageKeys.forEach(key => {
if (localStorage.getItem(key)) {
console.log(`Migrating: clearing old video chat storage entry: ${key}`);
localStorage.removeItem(key);
localStorage.removeItem(`${key}_token`);
}
});
}, [roomId])
const storeConfig = useMemo( const storeConfig = useMemo(
() => ({ () => ({
uri: `${WORKER_URL}/connect/${roomId}`, uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssetStore, assets: multiplayerAssetStore,
shapeUtils: [...defaultShapeUtils, ...customShapeUtils], shapeUtils: [...defaultShapeUtils, ...customShapeUtils],
bindingUtils: [...defaultBindingUtils], bindingUtils: [...defaultBindingUtils],
// Add user information to the presence system
user: session.authed ? { user: session.authed ? {
id: session.username, id: session.username,
name: session.username, name: session.username,
@ -99,8 +201,15 @@ export function Board() {
[roomId, session.authed, session.username], [roomId, session.authed, session.username],
) )
// Using TLdraw sync - fixed version compatibility issue // Use Automerge sync for all environments
const store = useSync(storeConfig) const storeWithHandle = useAutomergeSync(storeConfig)
const store = {
store: storeWithHandle.store,
status: storeWithHandle.status,
...('connectionStatus' in storeWithHandle ? { connectionStatus: storeWithHandle.connectionStatus } : {}),
error: storeWithHandle.error
}
const automergeHandle = storeWithHandle.handle
const [editor, setEditor] = useState<Editor | null>(null) const [editor, setEditor] = useState<Editor | null>(null)
useEffect(() => { useEffect(() => {
@ -121,13 +230,85 @@ export function Board() {
if (!editor) return if (!editor) return
initLockIndicators(editor) initLockIndicators(editor)
watchForLockedShapes(editor) watchForLockedShapes(editor)
// Debug: Check what shapes the editor can see
if (editor) {
const editorShapes = editor.getRenderingShapes()
console.log(`📊 Board: Editor can see ${editorShapes.length} shapes for rendering`)
// Debug: Check all shapes in the store vs what editor can see
const storeShapes = store.store?.allRecords().filter(r => r.typeName === 'shape') || []
console.log(`📊 Board: Store has ${storeShapes.length} shapes, editor sees ${editorShapes.length}`)
if (editorShapes.length > 0 && editor) {
const shape = editor.getShape(editorShapes[0].id)
console.log("📊 Board: Sample editor shape:", {
id: editorShapes[0].id,
type: shape?.type,
x: shape?.x,
y: shape?.y
})
}
// Debug: Check current page and page IDs
const currentPageId = editor.getCurrentPageId()
console.log(`📊 Board: Current page ID: ${currentPageId}`)
const pageRecords = store.store?.allRecords().filter(r => r.typeName === 'page') || []
console.log(`📊 Board: Available pages:`, pageRecords.map(p => ({
id: p.id,
name: (p as any).name
})))
// 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
})))
// 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 => ({
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]) }, [editor])
// Update presence when session changes // Update presence when session changes
useEffect(() => { useEffect(() => {
if (!editor || !session.authed || !session.username) return if (!editor || !session.authed || !session.username) return
// The presence should automatically update through the useSync configuration // The presence should automatically update through the useAutomergeSync configuration
// when the session changes, but we can also try to force an update // when the session changes, but we can also try to force an update
}, [editor, session.authed, session.username]) }, [editor, session.authed, session.username])
@ -210,11 +391,59 @@ export function Board() {
}; };
}, [editor, roomId, store.store]); }, [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 instanceof HTMLElement && target.isContentEditable)
)) || (activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
(activeElement instanceof HTMLElement && 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 ( return (
<div style={{ position: "fixed", inset: 0 }}> <AutomergeHandleProvider handle={automergeHandle}>
<Tldraw <div style={{ position: "fixed", inset: 0 }}>
<Tldraw
store={store.store} store={store.store}
shapeUtils={customShapeUtils} shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
tools={customTools} tools={customTools}
components={components} components={components}
overrides={{ overrides={{
@ -281,12 +510,13 @@ export function Board() {
} }
} }
initializeGlobalCollections(editor, collections) initializeGlobalCollections(editor, collections)
// Note: User presence is configured through the useSync hook above // Note: User presence is configured through the useAutomergeSync hook above
// The authenticated username should appear in the people section // The authenticated username should appear in the people section
}} }}
> >
<CmdK /> <CmdK />
</Tldraw> </Tldraw>
</div> </div>
</AutomergeHandleProvider>
) )
} }

View File

@ -38,11 +38,14 @@ export function Inbox() {
type: "geo", type: "geo",
x: shapeWidth * (i % 5) + spacing * (i % 5), x: shapeWidth * (i % 5) + spacing * (i % 5),
y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5), y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5),
isLocked: false,
props: { props: {
w: shapeWidth, w: shapeWidth,
h: shapeHeight, h: shapeHeight,
fill: "solid", fill: "solid",
color: "white", color: "white",
geo: "rectangle",
richText: [] as any
}, },
meta: { meta: {
id: messageId, id: messageId,

View File

@ -0,0 +1,34 @@
import React from 'react';
import { LocationDashboard } from '@/components/location/LocationDashboard';
export const LocationDashboardRoute: React.FC = () => {
return <LocationDashboard />;
};

View File

@ -0,0 +1,34 @@
import React from 'react';
import { ShareLocation } from '@/components/location/ShareLocation';
export const LocationShareCreate: React.FC = () => {
return <ShareLocation />;
};

View File

@ -0,0 +1,48 @@
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} />;
};

View File

@ -18,7 +18,7 @@ export function Presentations() {
</div> </div>
<div className="presentations-grid"> <div className="presentations-grid">
<div className="presentation-card"> <div id="osmotic-governance" className="presentation-card">
<h3>Osmotic Governance</h3> <h3>Osmotic Governance</h3>
<p>Exploring the intersection of mycelium and emancipatory technologies</p> <p>Exploring the intersection of mycelium and emancipatory technologies</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -43,7 +43,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="exploring-mycofi" className="presentation-card">
<h3>Exploring MycoFi</h3> <h3>Exploring MycoFi</h3>
<p>Mycelial design patterns for Web3 and beyond</p> <p>Mycelial design patterns for Web3 and beyond</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -68,7 +68,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="mycofi-cofi-gathering" className="presentation-card">
<h3>MycoFi talk at CoFi gathering</h3> <h3>MycoFi talk at CoFi gathering</h3>
<p>Mycelial design patterns for Web3 and beyond</p> <p>Mycelial design patterns for Web3 and beyond</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -95,7 +95,7 @@ export function Presentations() {
<div className="presentation-card"> <div id="myco-mutualism" className="presentation-card">
<h3>Myco-Mutualism</h3> <h3>Myco-Mutualism</h3>
<p>Exploring mutualistic relationships in mycelial networks and their applications to human systems</p> <p>Exploring mutualistic relationships in mycelial networks and their applications to human systems</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -118,7 +118,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="psilocybernetics" className="presentation-card">
<h3>Psilocybernetics: The Emergence of Institutional Neuroplasticity</h3> <h3>Psilocybernetics: The Emergence of Institutional Neuroplasticity</h3>
<p>Exploring the intersection of mycelium and cybernetic institutional design</p> <p>Exploring the intersection of mycelium and cybernetic institutional design</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -141,7 +141,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="move-slow-fix-things" className="presentation-card">
<h3>Move Slow & Fix Things: The Commons Stack Design Pattern</h3> <h3>Move Slow & Fix Things: The Commons Stack Design Pattern</h3>
<p>Design patterns for sustainable commons infrastructure</p> <p>Design patterns for sustainable commons infrastructure</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -166,7 +166,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="commons-stack-launch" className="presentation-card">
<h3>Commons Stack Launch & Open Sourcing cadCAD</h3> <h3>Commons Stack Launch & Open Sourcing cadCAD</h3>
<p>The launch of Commons Stack and the open sourcing of cadCAD for token engineering</p> <p>The launch of Commons Stack and the open sourcing of cadCAD for token engineering</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -191,7 +191,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="conviction-voting" className="presentation-card">
<h3>New Tools for Dynamic Collective Intelligence: Conviction Voting & Variations</h3> <h3>New Tools for Dynamic Collective Intelligence: Conviction Voting & Variations</h3>
<p>Exploring innovative voting mechanisms for collective decision-making in decentralized systems</p> <p>Exploring innovative voting mechanisms for collective decision-making in decentralized systems</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -214,7 +214,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="polycentric-governance" className="presentation-card">
<h3>Exploring Polycentric Governance in Web3 Ecosystems</h3> <h3>Exploring Polycentric Governance in Web3 Ecosystems</h3>
<p>Understanding multi-level governance structures in decentralized networks</p> <p>Understanding multi-level governance structures in decentralized networks</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -239,7 +239,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="mycofi-myco-munnities" className="presentation-card">
<h3>MycoFi for Myco-munnities</h3> <h3>MycoFi for Myco-munnities</h3>
<p>Exploring mycelial financial systems for community-based organizations</p> <p>Exploring mycelial financial systems for community-based organizations</p>
<div className="presentation-embed"> <div className="presentation-embed">
@ -262,7 +262,7 @@ export function Presentations() {
</div> </div>
</div> </div>
<div className="presentation-card"> <div id="community-resilience" className="presentation-card">
<h3>Building Community Resilience in an Age of Crisis</h3> <h3>Building Community Resilience in an Age of Crisis</h3>
<p>Internet outages during crises, such as wars or environmental disasters, can disrupt communication, education, and access to vital information. Preparing for such disruptions is essential for communities and organizations operating in challenging environments.</p> <p>Internet outages during crises, such as wars or environmental disasters, can disrupt communication, education, and access to vital information. Preparing for such disruptions is essential for communities and organizations operating in challenging environments.</p>
<div className="presentation-embed"> <div className="presentation-embed">

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react" 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< export type IChatBoxShape = TLBaseShape<
"ChatBox", "ChatBox",
@ -17,24 +18,53 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
getDefaultProps(): IChatBoxShape["props"] { getDefaultProps(): IChatBoxShape["props"] {
return { return {
roomId: "default-room", roomId: "default-room",
w: 100, w: 400,
h: 100, h: 500,
userName: "", userName: "",
} }
} }
// ChatBox theme color: Orange (Rainbow)
static readonly PRIMARY_COLOR = "#f97316"
indicator(shape: IChatBoxShape) { indicator(shape: IChatBoxShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} /> return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
} }
component(shape: IChatBoxShape) { 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 ( return (
<ChatBox <HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
roomId={shape.props.roomId} <StandardizedToolWrapper
w={shape.props.w} title="Chat"
h={shape.props.h} primaryColor={ChatBoxShape.PRIMARY_COLOR}
userName="" 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>
) )
} }
} }
@ -49,8 +79,8 @@ interface Message {
// Update the ChatBox component to accept userName // Update the ChatBox component to accept userName
export const ChatBox: React.FC<IChatBoxShape["props"]> = ({ export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
roomId, roomId,
w, w: _w,
h, h: _h,
userName, userName,
}) => { }) => {
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
@ -114,10 +144,12 @@ export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
className="chat-container" className="chat-container"
style={{ style={{
pointerEvents: "all", pointerEvents: "all",
width: `${w}px`, width: '100%',
height: `${h}px`, height: '100%',
overflow: "auto", overflow: "hidden",
touchAction: "auto", touchAction: "auto",
display: "flex",
flexDirection: "column",
}} }}
> >
<div className="messages-container"> <div className="messages-container">

View File

@ -173,9 +173,14 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
} }
component(shape: 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 isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [inputUrl, setInputUrl] = useState(shape.props.url || "") const [inputUrl, setInputUrl] = useState(url)
const [error, setError] = useState("") const [error, setError] = useState("")
const [copyStatus, setCopyStatus] = useState(false) const [copyStatus, setCopyStatus] = useState(false)

View File

@ -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} />
}
}

View File

@ -0,0 +1,369 @@
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')}`
}
const buttonStyle: React.CSSProperties = {
padding: '4px 8px',
fontSize: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
}
// 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 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} />
}
}

View File

@ -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} />
}
}

View File

@ -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} />
}
}

View File

@ -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} />
}
}

View File

@ -27,29 +27,8 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const markdownRef = React.useRef<HTMLDivElement>(null) const markdownRef = React.useRef<HTMLDivElement>(null)
// Single useEffect hook that handles checkbox interactivity // Handler function defined before useEffect
React.useEffect(() => { const handleCheckboxClick = React.useCallback((event: Event) => {
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) => {
event.stopPropagation() event.stopPropagation()
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
const checked = target.checked const checked = target.checked
@ -73,7 +52,28 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
text: newText, 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 = { const wrapperStyle: React.CSSProperties = {
width: '100%', width: '100%',

File diff suppressed because it is too large Load Diff

View File

@ -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} />
}
}

View File

@ -1,13 +1,18 @@
import { import {
BaseBoxShapeUtil, BaseBoxShapeUtil,
Geometry2d,
HTMLContainer, HTMLContainer,
Rectangle2d,
TLBaseShape, TLBaseShape,
TLGeoShape, TLGeoShape,
TLShape, TLShape,
createShapeId,
} from "tldraw" } from "tldraw"
import { getEdge } from "@/propagators/tlgraph" import { getEdge } from "@/propagators/tlgraph"
import { llm, getApiKey } from "@/utils/llmUtils" import { llm, getApiKey } from "@/utils/llmUtils"
import { AI_PERSONALITIES } from "@/lib/settings"
import { isShapeOfType } from "@/propagators/utils" import { isShapeOfType } from "@/propagators/utils"
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
import React, { useState } from "react" import React, { useState } from "react"
type IPrompt = TLBaseShape< type IPrompt = TLBaseShape<
@ -18,6 +23,8 @@ type IPrompt = TLBaseShape<
prompt: string prompt: string
value: string value: string
agentBinding: string | null agentBinding: string | null
personality?: string
error?: string | null
} }
> >
@ -44,13 +51,22 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
getDefaultProps(): IPrompt["props"] { getDefaultProps(): IPrompt["props"] {
return { return {
w: 300, w: 300,
h: 50, h: this.FIXED_HEIGHT,
prompt: "", prompt: "",
value: "", value: "",
agentBinding: null, 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> = ( // override onResize: TLResizeHandle<IPrompt> = (
// shape, // shape,
// { scaleX, initialShape }, // { scaleX, initialShape },
@ -69,6 +85,12 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
// } // }
component(shape: 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( const arrowBindings = this.editor.getBindingsInvolvingShape(
shape.id, shape.id,
"arrow", "arrow",
@ -91,6 +113,15 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
const generateText = async (prompt: string) => { const generateText = async (prompt: string) => {
console.log("🎯 generateText called with prompt:", prompt); 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 conversationHistory = shape.props.value ? shape.props.value + '\n' : ''
const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const userMessage = `{"role": "user", "content": "${escapedPrompt}"}` const userMessage = `{"role": "user", "content": "${escapedPrompt}"}`
@ -104,7 +135,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
type: "Prompt", type: "Prompt",
props: { props: {
value: conversationHistory + userMessage, value: conversationHistory + userMessage,
agentBinding: "someone" agentBinding: "someone",
error: null
}, },
}) })
@ -132,7 +164,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
type: "Prompt", type: "Prompt",
props: { props: {
value: conversationHistory + userMessage + '\n' + assistantMessage, 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) console.error('❌ Invalid JSON message:', error)
} }
} }
}) }, shape.props.personality)
console.log("✅ LLM function completed successfully"); console.log("✅ LLM function completed successfully");
} catch (error) { } 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 // Ensure the final message is saved after streaming is complete
@ -161,7 +213,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
type: "Prompt", type: "Prompt",
props: { props: {
value: conversationHistory + userMessage + '\n' + assistantMessage, value: conversationHistory + userMessage + '\n' + assistantMessage,
agentBinding: null agentBinding: null,
error: null // Clear any errors on success
}, },
}) })
console.log("✅ Final response saved successfully"); console.log("✅ Final response saved successfully");
@ -196,7 +249,7 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
} }
// Add state for copy button text // 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 // In the component function, add state for tracking copy success
const [isCopied, setIsCopied] = React.useState(false) const [isCopied, setIsCopied] = React.useState(false)
@ -233,24 +286,72 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
.map(line => { .map(line => {
try { try {
const parsed = JSON.parse(line); const parsed = JSON.parse(line);
return `${parsed.role}: ${parsed.content}`; return `**${parsed.role === 'user' ? 'User' : 'Assistant'}:**\n${parsed.content}`;
} catch { } catch {
return null; return null;
} }
}) })
.filter(Boolean) .filter(Boolean)
.join('\n\n'); .join('\n\n---\n\n');
await navigator.clipboard.writeText(messages); // Format the conversation as markdown content
setCopyButtonText("Copied!"); 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(() => { setTimeout(() => {
setCopyButtonText("Copy Conversation"); setCopyButtonText("Copy Conversation to Knowledge Object");
}, 2000); }, 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy text:', err); console.error('Failed to create knowledge object:', err);
setCopyButtonText("Failed to copy"); setCopyButtonText("Failed to create");
setTimeout(() => { setTimeout(() => {
setCopyButtonText("Copy Conversation"); setCopyButtonText("Copy Conversation to Knowledge Object");
}, 2000); }, 2000);
} }
}; };
@ -304,6 +405,45 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
pointerEvents: isSelected || isHovering ? "all" : "none", 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 ? (
shape.props.value.split('\n').map((message, index) => { shape.props.value.split('\n').map((message, index) => {
if (!message.trim()) return null; if (!message.trim()) return null;
@ -392,9 +532,54 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
marginTop: "auto", marginTop: "auto",
pointerEvents: isSelected || isHovering ? "all" : "none", pointerEvents: isSelected || isHovering ? "all" : "none",
}}> }}>
{/* AI Personality Selector */}
<div style={{ <div style={{
display: "flex", 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 <input
style={{ style={{
@ -405,6 +590,10 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
border: "1px solid rgba(0, 0, 0, 0.05)", border: "1px solid rgba(0, 0, 0, 0.05)",
borderRadius: 6 - this.PADDING, borderRadius: 6 - this.PADDING,
fontSize: 16, fontSize: 16,
padding: "0 8px",
position: "relative",
zIndex: 1000,
pointerEvents: "all",
}} }}
type="text" type="text"
placeholder="Enter prompt..." placeholder="Enter prompt..."
@ -417,23 +606,54 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
}) })
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() 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 <button
style={{ style={{
width: 100, width: 100,
height: "40px", height: "40px",
pointerEvents: "all", 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) => { onPointerDown={(e) => {
e.stopPropagation() 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" type="button"
onClick={handlePrompt}
> >
Prompt Prompt
</button> </button>
@ -460,13 +680,14 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
) )
} }
// Override the default indicator behavior // Override the default indicator behavior to match the actual rendered size
// TODO: FIX SECOND INDICATOR UX GLITCH
override indicator(shape: IPrompt) { 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 ( return (
<rect <rect
width={shape.props.w} width={shape.props.w}
height={this.FIXED_HEIGHT} height={Math.max(shape.props.h, this.FIXED_HEIGHT)}
rx={6} rx={6}
/> />
) )

View File

@ -73,7 +73,7 @@ export class SlideShape extends BaseBoxShapeUtil<ISlideShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const slides = useSlides() 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 // eslint-disable-next-line react-hooks/rules-of-hooks
const handleLabelPointerDown = useCallback( const handleLabelPointerDown = useCallback(

View File

@ -0,0 +1,777 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
} from "tldraw"
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",
{
w: number
h: number
text: string
isEditing?: boolean
editingContent?: string
isTranscribing?: boolean
isPaused?: boolean
fixedHeight?: boolean // New property to control resizing
}
>
// Auto-resizing textarea component (similar to ObsNoteShape)
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(() => {
// Focus the textarea when it mounts
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 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: 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 [isHovering, setIsHovering] = useState(false)
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)
// Local Whisper model is always available (no API key needed)
const isLocalWhisperAvailable = true
// Memoize the hook options to prevent unnecessary re-renders
const hookOptions = useMemo(() => ({
onTranscriptUpdate: (newText: string) => {
// 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: 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: {
...shape.props,
isTranscribing: false
}
})
},
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 = () => {
const currentText = text || ''
setEditingContent(currentText)
this.editor.updateShape<ITranscription>({
id: shape.id,
type: "Transcription",
props: {
...shape.props,
isEditing: true,
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: currentShape.id,
type: "Transcription",
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: cleanProps,
})
}
const handleTextChange = (newText: string) => {
setEditingContent(newText)
}
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) => {
// 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 (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 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',
props: {
...shape.props,
isTranscribing: true,
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('❌ Pause toggle error:', error)
}
}, [isPaused, useWebSpeech, pauseRecording, startRecording, stopRecording, shape.id, shape.props])
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handleClose = () => {
this.editor.deleteShape(shape.id)
}
const contentStyle: React.CSSProperties = {
padding: '12px',
flex: 1,
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%',
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 editControlsStyle: React.CSSProperties = {
display: 'flex',
gap: '8px',
padding: '8px 12px',
backgroundColor: '#f8f9fa',
borderTop: '1px solid #e0e0e0',
position: 'relative',
zIndex: 1000,
pointerEvents: 'auto',
}
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', // Ensure button can receive clicks
}
// 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 || isLiveEditing ? (
<AutoResizeTextarea
value={editingContent}
onChange={handleTextChange}
onBlur={handleSaveEdit}
onKeyDown={handleKeyDown}
style={textareaStyle}
placeholder=""
onPointerDown={(e) => e.stopPropagation()}
onWheel={handleWheel}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
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"
>
{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>
)
}
indicator(shape: ITranscription) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

View File

@ -1,6 +1,7 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw" import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { WORKER_URL } from "../routes/Board" import { WORKER_URL } from "../constants/workerUrl"
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
interface DailyApiResponse { interface DailyApiResponse {
url: string; url: string;
@ -20,13 +21,6 @@ export type IVideoChatShape = TLBaseShape<
allowMicrophone: boolean allowMicrophone: boolean
enableRecording: boolean enableRecording: boolean
recordingId: string | null // Track active recording recordingId: string | null // Track active recording
enableTranscription: boolean
isTranscribing: boolean
transcriptionHistory: Array<{
sender: string
message: string
id: string
}>
meetingToken: string | null meetingToken: string | null
isOwner: boolean isOwner: boolean
} }
@ -35,8 +29,11 @@ export type IVideoChatShape = TLBaseShape<
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> { export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = "VideoChat" static override type = "VideoChat"
indicator(_shape: IVideoChatShape) { // VideoChat theme color: Red (Rainbow)
return null static readonly PRIMARY_COLOR = "#ef4444"
indicator(shape: IVideoChatShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
} }
getDefaultProps(): IVideoChatShape["props"] { getDefaultProps(): IVideoChatShape["props"] {
@ -48,9 +45,6 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
allowMicrophone: false, allowMicrophone: false,
enableRecording: true, enableRecording: true,
recordingId: null, recordingId: null,
enableTranscription: true,
isTranscribing: false,
transcriptionHistory: [],
meetingToken: null, meetingToken: null,
isOwner: false isOwner: false
}; };
@ -77,36 +71,74 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
} }
async ensureRoomExists(shape: IVideoChatShape) { async ensureRoomExists(shape: IVideoChatShape) {
const boardId = this.editor.getCurrentPageId(); // Try to get the actual room ID from the URL or use a fallback
if (!boardId) { let roomId = 'default-room';
throw new Error('Board ID is undefined');
// Try to extract room ID from the current URL
const currentUrl = window.location.pathname;
const roomMatch = currentUrl.match(/\/board\/([^\/]+)/);
if (roomMatch) {
roomId = roomMatch[1];
} else {
// Fallback: try to get from localStorage or use a default
roomId = localStorage.getItem('currentRoomId') || 'default-room';
} }
console.log('🔧 Using room ID:', roomId);
// Clear old storage entries that use the old boardId format
// This ensures we don't load old rooms with the wrong naming convention
const oldStorageKeys = [
'videoChat_room_page_page',
'videoChat_room_page:page',
'videoChat_room_board_page_page'
];
oldStorageKeys.forEach(key => {
if (localStorage.getItem(key)) {
console.log(`Clearing old storage entry: ${key}`);
localStorage.removeItem(key);
localStorage.removeItem(`${key}_token`);
}
});
// Try to get existing room URL from localStorage first // Try to get existing room URL from localStorage first
const storageKey = `videoChat_room_${boardId}`; const storageKey = `videoChat_room_${roomId}`;
const existingRoomUrl = localStorage.getItem(storageKey); const existingRoomUrl = localStorage.getItem(storageKey);
const existingToken = localStorage.getItem(`${storageKey}_token`); const existingToken = localStorage.getItem(`${storageKey}_token`);
if (existingRoomUrl && existingRoomUrl !== 'undefined' && existingToken) { if (existingRoomUrl && existingRoomUrl !== 'undefined' && existingToken) {
console.log("Using existing room from storage:", existingRoomUrl); // Check if the existing room URL uses the old naming pattern
await this.editor.updateShape<IVideoChatShape>({ if (existingRoomUrl.includes('board_page_page_') || existingRoomUrl.includes('page_page')) {
id: shape.id, console.log("Found old room URL format, clearing and creating new room:", existingRoomUrl);
type: shape.type, localStorage.removeItem(storageKey);
props: { localStorage.removeItem(`${storageKey}_token`);
...shape.props, } else {
roomUrl: existingRoomUrl, console.log("Using existing room from storage:", existingRoomUrl);
meetingToken: existingToken, await this.editor.updateShape<IVideoChatShape>({
isOwner: true, // Assume the creator is the owner id: shape.id,
}, type: shape.type,
}); props: {
return; ...shape.props,
roomUrl: existingRoomUrl,
meetingToken: existingToken,
isOwner: true, // Assume the creator is the owner
},
});
return;
}
} }
if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined' && shape.props.meetingToken) { if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined' && shape.props.meetingToken) {
console.log("Room already exists:", shape.props.roomUrl); // Check if the shape's room URL uses the old naming pattern
localStorage.setItem(storageKey, shape.props.roomUrl); if (shape.props.roomUrl.includes('board_page_page_') || shape.props.roomUrl.includes('page_page')) {
localStorage.setItem(`${storageKey}_token`, shape.props.meetingToken); console.log("Shape has old room URL format, will create new room:", shape.props.roomUrl);
return; } else {
console.log("Room already exists:", shape.props.roomUrl);
localStorage.setItem(storageKey, shape.props.roomUrl);
localStorage.setItem(`${storageKey}_token`, shape.props.meetingToken);
return;
}
} }
try { try {
@ -127,16 +159,28 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
throw new Error('Worker URL is not configured'); throw new Error('Worker URL is not configured');
} }
// Create room name based on board ID and timestamp // Create a simple, clean room name
// Sanitize boardId to only use valid Daily.co characters (A-Z, a-z, 0-9, '-', '_') // Use a short hash of the room ID to keep URLs readable
const sanitizedBoardId = boardId.replace(/[^A-Za-z0-9\-_]/g, '_'); const shortId = roomId.length > 8 ? roomId.substring(0, 8) : roomId;
const roomName = `board_${sanitizedBoardId}_${Date.now()}`; const cleanId = shortId.replace(/[^A-Za-z0-9]/g, '');
const roomName = `canvas-${cleanId}`;
console.log('🔧 Room name generation:'); console.log('🔧 Room name generation:');
console.log('Original boardId:', boardId); console.log('Original roomId:', roomId);
console.log('Sanitized boardId:', sanitizedBoardId); console.log('Short ID:', shortId);
console.log('Clean ID:', cleanId);
console.log('Final roomName:', roomName); console.log('Final roomName:', roomName);
console.log('🔧 Creating Daily.co room with:', {
name: roomName,
properties: {
enable_chat: true,
enable_screenshare: true,
start_video_off: true,
start_audio_off: true
}
});
const response = await fetch(`${workerUrl}/daily/rooms`, { const response = await fetch(`${workerUrl}/daily/rooms`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -154,15 +198,25 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
}) })
}); });
console.log('🔧 Daily.co API response status:', response.status);
console.log('🔧 Daily.co API response ok:', response.ok);
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json()
console.error('🔧 Daily.co API error:', error);
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`) throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`)
} }
const data = (await response.json()) as DailyApiResponse; const data = (await response.json()) as DailyApiResponse;
console.log('🔧 Daily.co API response data:', data);
const url = data.url; const url = data.url;
if (!url) throw new Error("Room URL is missing") if (!url) {
console.error('🔧 Room URL is missing from API response:', data);
throw new Error("Room URL is missing")
}
console.log('🔧 Room URL from API:', url);
// Generate meeting token for the owner // Generate meeting token for the owner
// First ensure the room exists, then generate token // First ensure the room exists, then generate token
@ -272,156 +326,28 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
} }
} }
async startTranscription(shape: IVideoChatShape) {
console.log('🎤 startTranscription method called');
console.log('Shape props:', shape.props);
console.log('Room URL:', shape.props.roomUrl);
console.log('Is owner:', shape.props.isOwner);
if (!shape.props.roomUrl || !shape.props.isOwner) {
console.log('❌ Early return - missing roomUrl or not owner');
console.log('roomUrl exists:', !!shape.props.roomUrl);
console.log('isOwner:', shape.props.isOwner);
return;
}
try {
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
console.log('🔧 Environment variables:');
console.log('Worker URL:', workerUrl);
console.log('API Key exists:', !!apiKey);
// Extract room name from URL
const roomName = shape.props.roomUrl.split('/').pop();
console.log('📝 Extracted room name:', roomName);
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
console.log('🌐 Making API request to start transcription...');
console.log('Request URL:', `${workerUrl}/daily/rooms/${roomName}/start-transcription`);
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/start-transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
});
console.log('📡 Response status:', response.status);
console.log('📡 Response ok:', response.ok);
if (!response.ok) {
const error = await response.json();
console.error('❌ API error response:', error);
throw new Error(`Failed to start transcription: ${JSON.stringify(error)}`);
}
console.log('✅ API call successful, updating shape...');
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: true,
}
});
console.log('✅ Shape updated with isTranscribing: true');
} catch (error) {
console.error('❌ Error starting transcription:', error);
throw error;
}
}
async stopTranscription(shape: IVideoChatShape) {
console.log('🛑 stopTranscription method called');
console.log('Shape props:', shape.props);
if (!shape.props.roomUrl || !shape.props.isOwner) {
console.log('❌ Early return - missing roomUrl or not owner');
return;
}
try {
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
// Extract room name from URL
const roomName = shape.props.roomUrl.split('/').pop();
console.log('📝 Extracted room name:', roomName);
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
console.log('🌐 Making API request to stop transcription...');
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/stop-transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
});
console.log('📡 Response status:', response.status);
if (!response.ok) {
const error = await response.json();
console.error('❌ API error response:', error);
throw new Error(`Failed to stop transcription: ${JSON.stringify(error)}`);
}
console.log('✅ API call successful, updating shape...');
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: false,
}
});
console.log('✅ Shape updated with isTranscribing: false');
} catch (error) {
console.error('❌ Error stopping transcription:', error);
throw error;
}
}
addTranscriptionMessage(shape: IVideoChatShape, sender: string, message: string) {
console.log('📝 addTranscriptionMessage called');
console.log('Sender:', sender);
console.log('Message:', message);
console.log('Current transcription history length:', shape.props.transcriptionHistory?.length || 0);
const newMessage = {
sender,
message,
id: `${Date.now()}_${Math.random()}`
};
console.log('📝 Adding new message:', newMessage);
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
transcriptionHistory: [...(shape.props.transcriptionHistory || []), newMessage]
}
});
console.log('✅ Transcription message added to shape');
}
component(shape: IVideoChatShape) { component(shape: IVideoChatShape) {
// Ensure shape props exist with defaults
const props = shape.props || {}
const roomUrl = props.roomUrl || ""
const [hasPermissions, setHasPermissions] = useState(false) 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 = () => {
setForceRender(prev => prev + 1)
}
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(true) 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)
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -434,7 +360,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
// Get the updated shape after room creation // Get the updated shape after room creation
const updatedShape = this.editor.getShape(shape.id); const updatedShape = this.editor.getShape(shape.id);
if (mounted && updatedShape) { if (mounted && updatedShape) {
setRoomUrl((updatedShape as IVideoChatShape).props.roomUrl); setCurrentRoomUrl((updatedShape as IVideoChatShape).props.roomUrl);
} }
} catch (err) { } catch (err) {
if (mounted) { if (mounted) {
@ -489,7 +415,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
return <div>Error creating room: {error.message}</div> return <div>Error creating room: {error.message}</div>
} }
if (isLoading || !roomUrl || roomUrl === 'undefined') { if (isLoading || !currentRoomUrl || currentRoomUrl === 'undefined') {
return ( return (
<div <div
style={{ style={{
@ -507,180 +433,265 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
) )
} }
// Construct URL with permission parameters // Validate room URL format
const roomUrlWithParams = new URL(roomUrl) if (!currentRoomUrl || !currentRoomUrl.startsWith('http')) {
roomUrlWithParams.searchParams.set( console.error('Invalid room URL format:', currentRoomUrl);
"allow_camera", return <div>Error: Invalid room URL format</div>;
String(shape.props.allowCamera), }
)
roomUrlWithParams.searchParams.set(
"allow_mic",
String(shape.props.allowMicrophone),
)
console.log(roomUrl) // Check if we're running on a network IP (which can cause WebRTC/CORS issues)
const isNonLocalhost = window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1';
const isNetworkIP = window.location.hostname.startsWith('172.') || window.location.hostname.startsWith('192.168.') || window.location.hostname.startsWith('10.');
// Try the original URL first, then add parameters if needed
let roomUrlWithParams;
try {
roomUrlWithParams = new URL(currentRoomUrl)
roomUrlWithParams.searchParams.set(
"allow_camera",
String(shape.props.allowCamera),
)
roomUrlWithParams.searchParams.set(
"allow_mic",
String(shape.props.allowMicrophone),
)
// Add parameters for better network access
if (isNetworkIP) {
roomUrlWithParams.searchParams.set("embed", "true")
roomUrlWithParams.searchParams.set("iframe", "true")
roomUrlWithParams.searchParams.set("show_leave_button", "false")
roomUrlWithParams.searchParams.set("show_fullscreen_button", "false")
roomUrlWithParams.searchParams.set("show_participants_bar", "true")
roomUrlWithParams.searchParams.set("show_local_video", "true")
roomUrlWithParams.searchParams.set("show_remote_video", "true")
}
// Only add embed parameters if the original URL doesn't work
if (retryCount > 0) {
roomUrlWithParams.searchParams.set("embed", "true")
roomUrlWithParams.searchParams.set("iframe", "true")
}
} catch (e) {
console.error('Error constructing URL:', e);
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 ( return (
<div <HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
style={{ <StandardizedToolWrapper
width: `${shape.props.w}px`, title="Video Chat"
height: `${shape.props.h}px`, primaryColor={VideoChatShape.PRIMARY_COLOR}
position: "relative", isSelected={isSelected}
pointerEvents: "all", width={shape.props.w}
overflow: "hidden", height={shape.props.h + 40} // Include space for URL bubble
}} onClose={handleClose}
> onMinimize={handleMinimize}
<iframe isMinimized={isMinimized}
src={roomUrlWithParams.toString()} editor={this.editor}
width="100%" shapeId={shape.id}
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${
shape.props.allowMicrophone ? "self" : ""
}`}
></iframe>
{/* Recording Button */}
{shape.props.enableRecording && (
<button
onClick={async () => {
try {
if (shape.props.recordingId) {
await this.stopRecording(shape);
} else {
await this.startRecording(shape);
}
} catch (err) {
console.error('Recording error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: "8px",
padding: "4px 8px",
background: shape.props.recordingId ? "#ff4444" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.recordingId ? "Stop Recording" : "Start Recording"}
</button>
)}
{/* Test Button - Always visible for debugging */}
<button
onClick={() => {
console.log('🧪 Test button clicked!');
console.log('Shape props:', shape.props);
alert('Test button clicked! Check console for details.');
}}
style={{
position: "absolute",
top: "8px",
left: "8px",
padding: "4px 8px",
background: "#ffff00",
border: "1px solid #000",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1000,
fontSize: "10px",
}}
> >
TEST
</button>
{/* Transcription Button - Only for owners */}
{(() => {
console.log('🔍 Checking transcription button conditions:');
console.log('enableTranscription:', shape.props.enableTranscription);
console.log('isOwner:', shape.props.isOwner);
console.log('Button should render:', shape.props.enableTranscription && shape.props.isOwner);
return shape.props.enableTranscription && shape.props.isOwner;
})() && (
<button
onClick={async () => {
console.log('🚀 Transcription button clicked!');
console.log('Current transcription state:', shape.props.isTranscribing);
console.log('Shape props:', shape.props);
try {
if (shape.props.isTranscribing) {
console.log('🛑 Stopping transcription...');
await this.stopTranscription(shape);
console.log('✅ Transcription stopped successfully');
} else {
console.log('🎤 Starting transcription...');
await this.startTranscription(shape);
console.log('✅ Transcription started successfully');
}
} catch (err) {
console.error('❌ Transcription error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: shape.props.enableRecording ? "120px" : "8px",
padding: "4px 8px",
background: shape.props.isTranscribing ? "#44ff44" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.isTranscribing ? "Stop Transcription" : "Start Transcription"}
</button>
)}
{/* Transcription History */}
{shape.props.transcriptionHistory && shape.props.transcriptionHistory.length > 0 && (
<div <div
style={{ style={{
position: "absolute", width: '100%',
bottom: "40px", height: '100%',
left: "8px", position: "relative",
right: "8px", pointerEvents: "all",
maxHeight: "200px", display: "flex",
overflowY: "auto", flexDirection: "column",
background: "rgba(255, 255, 255, 0.95)",
borderRadius: "4px",
padding: "8px",
fontSize: "12px",
zIndex: 1,
border: "1px solid #ccc",
}} }}
> >
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
Live Transcription: {/* Video Container */}
</div> <div
{shape.props.transcriptionHistory.slice(-10).map((msg) => ( style={{
<div key={msg.id} style={{ marginBottom: "2px" }}> width: '100%',
<span style={{ fontWeight: "bold", color: "#666" }}> flex: 1,
{msg.sender}: position: "relative",
</span>{" "} overflow: "hidden",
<span>{msg.message}</span> }}
</div> >
))} {!useFallback ? (
<iframe
key={`iframe-${retryCount}`}
src={roomUrlWithParams.toString()}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow={isNetworkIP ? "*" : "camera; microphone; fullscreen; display-capture; autoplay; encrypted-media; geolocation; web-share"}
referrerPolicy={isNetworkIP ? "unsafe-url" : "no-referrer-when-downgrade"}
sandbox={isNetworkIP ? "allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation" : undefined}
title="Daily.co Video Chat"
loading="lazy"
onError={(e) => {
console.error('Iframe loading error:', e);
setIframeError(true);
if (retryCount < 2) {
console.log(`Retrying iframe load (attempt ${retryCount + 1})`);
setTimeout(() => {
setRetryCount(prev => prev + 1);
setIframeError(false);
}, 2000);
} else {
console.log('Switching to fallback iframe configuration');
setUseFallback(true);
setIframeError(false);
setRetryCount(0);
}
}}
onLoad={() => {
console.log('Iframe loaded successfully');
setIframeError(false);
setRetryCount(0);
}}
></iframe>
) : (
<iframe
key={`fallback-iframe-${retryCount}`}
src={currentRoomUrl}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow="*"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"
title="Daily.co Video Chat (Fallback)"
onError={(e) => {
console.error('Fallback iframe loading error:', e);
setIframeError(true);
if (retryCount < 3) {
console.log(`Retrying fallback iframe load (attempt ${retryCount + 1})`);
setTimeout(() => {
setRetryCount(prev => prev + 1);
setIframeError(false);
}, 2000);
} else {
setError(new Error('Failed to load video chat room after multiple attempts'));
}
}}
onLoad={() => {
console.log('Fallback iframe loaded successfully');
setIframeError(false);
setRetryCount(0);
}}
></iframe>
)}
{/* Loading indicator */}
{iframeError && retryCount < 3 && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
zIndex: 10
}}>
Retrying connection... (Attempt {retryCount + 1}/3)
</div> </div>
)} )}
{/* Fallback button if iframe fails */}
{iframeError && retryCount >= 3 && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.9)',
color: 'white',
padding: '20px',
borderRadius: '10px',
textAlign: 'center',
zIndex: 10
}}>
<p>Video chat failed to load in iframe</p>
{isNetworkIP && (
<p style={{fontSize: '12px', margin: '10px 0', color: '#ffc107'}}>
Network access issue detected: Video chat may not work on {window.location.hostname}:5173 due to WebRTC/CORS restrictions. Try accessing via localhost:5173 or use the "Open in New Tab" button below.
</p>
)}
{isNonLocalhost && !isNetworkIP && (
<p style={{fontSize: '12px', margin: '10px 0', color: '#ffc107'}}>
CORS issue detected: Try accessing via localhost:5173 instead of {window.location.hostname}:5173
</p>
)}
<p style={{fontSize: '12px', margin: '10px 0'}}>
URL: {roomUrlWithParams.toString()}
</p>
<button
onClick={() => window.open(roomUrlWithParams.toString(), '_blank')}
style={{
background: '#007bff',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer',
marginTop: '10px'
}}
>
Open in New Tab
</button>
<button
onClick={() => {
setUseFallback(!useFallback);
setRetryCount(0);
setIframeError(false);
}}
style={{
background: '#28a745',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer',
marginTop: '10px',
marginLeft: '10px'
}}
>
Try {useFallback ? 'Normal' : 'Fallback'} Mode
</button>
</div>
)}
</div>
{/* URL Bubble - Below the video iframe */}
<p <p
style={{ style={{
position: "absolute", position: "absolute",
bottom: 0, bottom: "8px",
left: 0, left: "8px",
margin: "8px", margin: "8px",
padding: "4px 8px", padding: "4px 8px",
background: "rgba(255, 255, 255, 0.9)", background: "rgba(255, 255, 255, 0.9)",
@ -690,12 +701,15 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
cursor: "text", cursor: "text",
userSelect: "text", userSelect: "text",
zIndex: 1, zIndex: 1,
top: `${shape.props.h + 50}px`, // Position it below the iframe with proper spacing
}} }}
> >
url: {roomUrl} url: {currentRoomUrl}
{shape.props.isOwner && " (Owner)"} {shape.props.isOwner && " (Owner)"}
</p> </p>
</div> </div>
</StandardizedToolWrapper>
</HTMLContainer>
) )
} }
} }

View File

@ -204,7 +204,8 @@
"com.tldraw.shape.container": 0, "com.tldraw.shape.container": 0,
"com.tldraw.shape.element": 0, "com.tldraw.shape.element": 0,
"com.tldraw.binding.arrow": 0, "com.tldraw.binding.arrow": 0,
"com.tldraw.binding.layout": 0 "com.tldraw.binding.layout": 0,
"obsidian_vault": 1
} }
} }
} }

View File

@ -1,7 +1,11 @@
import { BaseBoxShapeTool } from "tldraw" import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
export class ChatBoxTool extends BaseBoxShapeTool { export class ChatBoxTool extends BaseBoxShapeTool {
static override id = "ChatBox" static override id = "ChatBox"
shapeType = "ChatBox" shapeType = "ChatBox"
override initial = "idle" override initial = "idle"
override onComplete: TLEventHandlers["onComplete"] = () => {
this.editor.setCurrentTool('select')
}
} }

View File

@ -1,7 +1,11 @@
import { BaseBoxShapeTool } from "tldraw" import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
export class EmbedTool extends BaseBoxShapeTool { export class EmbedTool extends BaseBoxShapeTool {
static override id = "Embed" static override id = "Embed"
shapeType = "Embed" shapeType = "Embed"
override initial = "idle" override initial = "idle"
override onComplete: TLEventHandlers["onComplete"] = () => {
this.editor.setCurrentTool('select')
}
} }

View File

@ -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
}
}
}

View File

@ -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)
}
}
}

329
src/tools/HolonTool.ts Normal file
View File

@ -0,0 +1,329 @@
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()
// Clean up event listeners
if ((this as any).cleanup) {
;(this as any).cleanup()
}
}
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 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
}
}

View File

@ -1,7 +1,11 @@
import { BaseBoxShapeTool } from "tldraw" import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
export class MarkdownTool extends BaseBoxShapeTool { export class MarkdownTool extends BaseBoxShapeTool {
static override id = "Markdown" static override id = "Markdown"
shapeType = "Markdown" shapeType = "Markdown"
override initial = "idle" override initial = "idle"
override onComplete: TLEventHandlers["onComplete"] = () => {
this.editor.setCurrentTool('select')
}
} }

Some files were not shown because too many files have changed in this diff Show More