feat: update mulTmux terminal tool and improve shape utilities

Updates to collaborative terminal integration and various shape
improvements across the canvas.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-11-26 04:08:08 -08:00
parent e479413363
commit 8ea3490fb4
32 changed files with 3279 additions and 919 deletions

View File

@ -1,14 +1,25 @@
# Cloudflare Pages redirects and rewrites # Cloudflare Pages redirects and rewrites
# This file handles SPA routing and URL rewrites (replaces vercel.json rewrites) # This file handles SPA routing and URL rewrites (replaces vercel.json rewrites)
# SPA fallback - all routes should serve index.html
/* /index.html 200
# Specific route rewrites (matching vercel.json) # Specific route rewrites (matching vercel.json)
# Handle both with and without trailing slashes
/board/* /index.html 200 /board/* /index.html 200
/board /index.html 200 /board /index.html 200
/board/ /index.html 200
/inbox /index.html 200 /inbox /index.html 200
/inbox/ /index.html 200
/contact /index.html 200 /contact /index.html 200
/contact/ /index.html 200
/presentations /index.html 200 /presentations /index.html 200
/presentations/ /index.html 200
/presentations/* /index.html 200
/dashboard /index.html 200 /dashboard /index.html 200
/dashboard/ /index.html 200
/login /index.html 200
/login/ /index.html 200
/debug /index.html 200
/debug/ /index.html 200
# SPA fallback - all routes should serve index.html (must be last)
/* /index.html 200

View File

@ -51,11 +51,11 @@ export class TerminalHandler {
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
}; };
terminal.onData(onData); const dataListener = terminal.onData(onData);
// Clean up on disconnect // Clean up on disconnect
ws.on('close', () => { ws.on('close', () => {
terminal.off('data', onData); dataListener.dispose();
this.handleDisconnect(clientId); this.handleDisconnect(clientId);
}); });
} }

1382
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import "tldraw/tldraw.css" import "tldraw/tldraw.css"
import "@/css/style.css" import "@/css/style.css"
import { Default } from "@/routes/Default" import { Default } from "@/routes/Default"
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom" import { BrowserRouter, Route, Routes, Navigate, useParams } from "react-router-dom"
import { Contact } from "@/routes/Contact" import { Contact } from "@/routes/Contact"
import { Board } from "./routes/Board" import { Board } from "./routes/Board"
import { Inbox } from "./routes/Inbox" import { Inbox } from "./routes/Inbox"
@ -67,6 +67,14 @@ const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>; return <>{children}</>;
}; };
/**
* Component to redirect board URLs without trailing slashes
*/
const RedirectBoardSlug = () => {
const { slug } = useParams<{ slug: string }>();
return <Navigate to={`/board/${slug}/`} replace />;
};
/** /**
* Main App with context providers * Main App with context providers
*/ */
@ -101,8 +109,18 @@ const AppWithProviders = () => {
<NotificationsDisplay /> <NotificationsDisplay />
<Routes> <Routes>
{/* Redirect routes without trailing slashes to include them */}
<Route path="/login" element={<Navigate to="/login/" replace />} />
<Route path="/contact" element={<Navigate to="/contact/" replace />} />
<Route path="/board/:slug" element={<RedirectBoardSlug />} />
<Route path="/inbox" element={<Navigate to="/inbox/" replace />} />
<Route path="/debug" element={<Navigate to="/debug/" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/" replace />} />
<Route path="/presentations" element={<Navigate to="/presentations/" replace />} />
<Route path="/presentations/resilience" element={<Navigate to="/presentations/resilience/" replace />} />
{/* 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={
@ -110,37 +128,37 @@ const AppWithProviders = () => {
<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>

View File

@ -28,10 +28,11 @@ export function applyAutomergePatchesToTLStore(
const existingRecord = getRecordFromStore(store, id) const existingRecord = getRecordFromStore(store, id)
// CRITICAL: For shapes, get coordinates from store's current state BEFORE any patch processing // CRITICAL: For shapes, get coordinates and parentId from store's current state BEFORE any patch processing
// This ensures we preserve coordinates even if patches don't include them // This ensures we preserve coordinates and parent relationships even if patches don't include them
// This is especially important when patches come back after store.put operations // This is especially important when patches come back after store.put operations
let storeCoordinates: { x?: number; y?: number } = {} let storeCoordinates: { x?: number; y?: number } = {}
let storeParentId: string | undefined = undefined
if (existingRecord && existingRecord.typeName === 'shape') { if (existingRecord && existingRecord.typeName === 'shape') {
const storeX = (existingRecord as any).x const storeX = (existingRecord as any).x
const storeY = (existingRecord as any).y const storeY = (existingRecord as any).y
@ -41,15 +42,21 @@ export function applyAutomergePatchesToTLStore(
if (typeof storeY === 'number' && !isNaN(storeY) && storeY !== null && storeY !== undefined) { if (typeof storeY === 'number' && !isNaN(storeY) && storeY !== null && storeY !== undefined) {
storeCoordinates.y = storeY storeCoordinates.y = storeY
} }
// CRITICAL: Preserve parentId from store (might be a frame or group!)
const existingParentId = (existingRecord as any).parentId
if (existingParentId && typeof existingParentId === 'string') {
storeParentId = existingParentId
}
} }
// CRITICAL: If record doesn't exist in store yet, try to get it from Automerge document // CRITICAL: If record doesn't exist in store yet, try to get it from Automerge document
// This prevents coordinates from defaulting to 0,0 when patches create new records // This prevents coordinates from defaulting to 0,0 when patches create new records
let automergeRecord: any = null let automergeRecord: any = null
let automergeParentId: string | undefined = undefined
if (!existingRecord && automergeDoc && automergeDoc.store && automergeDoc.store[id]) { if (!existingRecord && automergeDoc && automergeDoc.store && automergeDoc.store[id]) {
try { try {
automergeRecord = automergeDoc.store[id] automergeRecord = automergeDoc.store[id]
// Extract coordinates from Automerge record if it's a shape // Extract coordinates and parentId from Automerge record if it's a shape
if (automergeRecord && automergeRecord.typeName === 'shape') { if (automergeRecord && automergeRecord.typeName === 'shape') {
const docX = automergeRecord.x const docX = automergeRecord.x
const docY = automergeRecord.y const docY = automergeRecord.y
@ -59,6 +66,10 @@ export function applyAutomergePatchesToTLStore(
if (typeof docY === 'number' && !isNaN(docY) && docY !== null && docY !== undefined) { if (typeof docY === 'number' && !isNaN(docY) && docY !== null && docY !== undefined) {
storeCoordinates.y = docY storeCoordinates.y = docY
} }
// CRITICAL: Preserve parentId from Automerge document (might be a frame!)
if (automergeRecord.parentId && typeof automergeRecord.parentId === 'string') {
automergeParentId = automergeRecord.parentId
}
} }
} catch (e) { } catch (e) {
// If we can't read from Automerge doc, continue without it // If we can't read from Automerge doc, continue without it
@ -324,6 +335,22 @@ export function applyAutomergePatchesToTLStore(
} as TLRecord } as TLRecord
} }
} }
// CRITICAL: Preserve parentId from store or Automerge document
// This prevents shapes from losing their frame/group parent relationships
// which causes them to reset to (0,0) on the page instead of maintaining their position in the frame
// Priority: store parentId (most reliable), then Automerge parentId, then patch value
const preservedParentId = storeParentId || automergeParentId
if (preservedParentId !== undefined) {
const patchedParentId = (currentRecord as any).parentId
// If patch didn't include parentId, or it's missing/default, use the preserved parentId
if (!patchedParentId || (patchedParentId === 'page:page' && preservedParentId !== 'page:page')) {
updatedObjects[id] = {
...currentRecord,
parentId: preservedParentId
} as TLRecord
}
}
} }
// CRITICAL: Re-check typeName after patch application to ensure it's still correct // CRITICAL: Re-check typeName after patch application to ensure it's still correct
@ -372,6 +399,18 @@ export function applyAutomergePatchesToTLStore(
// Log patch application for debugging // Log patch application for debugging
console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`) console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`)
// DEBUG: Log shape updates being applied to store
toPut.forEach(record => {
if (record.typeName === 'shape' && (record as any).props?.w) {
console.log(`🔧 AutomergeToTLStore: Putting shape ${(record as any).type} ${record.id}:`, {
w: (record as any).props.w,
h: (record as any).props.h,
x: (record as any).x,
y: (record as any).y
})
}
})
if (failedRecords.length > 0) { if (failedRecords.length > 0) {
console.log({ patches, toPut: toPut.length, failed: failedRecords.length }) console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
} }
@ -534,7 +573,13 @@ export function sanitizeRecord(record: any): TLRecord {
// Ensure meta is a mutable copy to preserve all properties (including text for rectangles) // Ensure meta is a mutable copy to preserve all properties (including text for rectangles)
sanitized.meta = { ...sanitized.meta } sanitized.meta = { ...sanitized.meta }
} }
if (!sanitized.index) sanitized.index = 'a1' // CRITICAL: IndexKey must follow tldraw's fractional indexing format
// Valid format: starts with 'a' followed by digits, optionally followed by uppercase letters
// Examples: "a1", "a2", "a10", "a1V" (fractional between a1 and a2)
// Invalid: "c1", "b1", "z999" (must start with 'a')
if (!sanitized.index || typeof sanitized.index !== 'string' || !/^a\d+[A-Z]*$/.test(sanitized.index)) {
sanitized.index = 'a1'
}
if (!sanitized.parentId) sanitized.parentId = 'page:page' if (!sanitized.parentId) sanitized.parentId = 'page:page'
if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {} if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {}

View File

@ -166,6 +166,7 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private websocket: WebSocket | null = null private websocket: WebSocket | null = null
private roomId: string | null = null private roomId: string | null = null
public peerId: PeerId | undefined = undefined public peerId: PeerId | undefined = undefined
public sessionId: string | null = null // Track our session ID
private readyPromise: Promise<void> private readyPromise: Promise<void>
private readyResolve: (() => void) | null = null private readyResolve: (() => void) | null = null
private keepAliveInterval: NodeJS.Timeout | null = null private keepAliveInterval: NodeJS.Timeout | null = null
@ -175,12 +176,19 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private reconnectDelay: number = 1000 private reconnectDelay: number = 1000
private isConnecting: boolean = false private isConnecting: boolean = false
private onJsonSyncData?: (data: any) => void private onJsonSyncData?: (data: any) => void
private onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void
constructor(workerUrl: string, roomId?: string, onJsonSyncData?: (data: any) => void) { constructor(
workerUrl: string,
roomId?: string,
onJsonSyncData?: (data: any) => void,
onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void
) {
super() super()
this.workerUrl = workerUrl this.workerUrl = workerUrl
this.roomId = roomId || 'default-room' this.roomId = roomId || 'default-room'
this.onJsonSyncData = onJsonSyncData this.onJsonSyncData = onJsonSyncData
this.onPresenceUpdate = onPresenceUpdate
this.readyPromise = new Promise((resolve) => { this.readyPromise = new Promise((resolve) => {
this.readyResolve = resolve this.readyResolve = resolve
}) })
@ -209,6 +217,8 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
// Use the room ID from constructor or default // Use the room ID from constructor or default
// Add sessionId as a query parameter as required by AutomergeDurableObject // Add sessionId as a query parameter as required by AutomergeDurableObject
const sessionId = peerId || `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const sessionId = peerId || `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
this.sessionId = sessionId // Store our session ID for filtering echoes
// Convert https:// to wss:// or http:// to ws:// // Convert https:// to wss:// or http:// to ws://
const protocol = this.workerUrl.startsWith('https://') ? 'wss://' : 'ws://' const protocol = this.workerUrl.startsWith('https://') ? 'wss://' : 'ws://'
const baseUrl = this.workerUrl.replace(/^https?:\/\//, '') const baseUrl = this.workerUrl.replace(/^https?:\/\//, '')
@ -274,6 +284,15 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
return return
} }
// Handle presence updates from other clients
if (message.type === 'presence') {
// Pass senderId, userName, and userColor so we can create proper instance_presence records
if (this.onPresenceUpdate && message.userId && message.data) {
this.onPresenceUpdate(message.userId, message.data, message.senderId, message.userName, message.userColor)
}
return
}
// Convert the message to the format expected by Automerge // Convert the message to the format expected by Automerge
if (message.type === 'sync' && message.data) { if (message.type === 'sync' && message.data) {
console.log('🔌 CloudflareAdapter: Received sync message with data:', { console.log('🔌 CloudflareAdapter: Received sync message with data:', {
@ -283,14 +302,20 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
documentIdType: typeof message.documentId documentIdType: typeof message.documentId
}) })
// JSON sync is deprecated - all data flows through Automerge sync protocol // JSON sync for real-time collaboration
// Old format content is converted server-side and saved to R2 in Automerge format // When we receive TLDraw changes from other clients, apply them locally
// Skip JSON sync messages - they should not be sent anymore
const isJsonDocumentData = message.data && typeof message.data === 'object' && message.data.store const isJsonDocumentData = message.data && typeof message.data === 'object' && message.data.store
if (isJsonDocumentData) { if (isJsonDocumentData) {
console.warn('⚠️ CloudflareAdapter: Received JSON sync message (deprecated). Ignoring - all data should flow through Automerge sync protocol.') console.log('📥 CloudflareAdapter: Received JSON sync message with store data')
return // Don't process JSON sync messages
// Call the JSON sync callback to apply changes
if (this.onJsonSyncData) {
this.onJsonSyncData(message.data)
} else {
console.warn('⚠️ No JSON sync callback registered')
}
return // JSON sync handled
} }
// Validate documentId - Automerge requires a valid Automerge URL format // Validate documentId - Automerge requires a valid Automerge URL format
@ -376,6 +401,18 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
} }
send(message: Message): void { send(message: Message): void {
// Only log non-presence messages to reduce console spam
if (message.type !== 'presence') {
console.log('📤 CloudflareAdapter.send() called:', {
messageType: message.type,
dataType: (message as any).data?.constructor?.name || typeof (message as any).data,
dataLength: (message as any).data?.byteLength || (message as any).data?.length,
documentId: (message as any).documentId,
hasTargetId: !!message.targetId,
hasSenderId: !!message.senderId
})
}
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
// Check if this is a binary sync message from Automerge Repo // Check if this is a binary sync message from Automerge Repo
if (message.type === 'sync' && (message as any).data instanceof ArrayBuffer) { if (message.type === 'sync' && (message as any).data instanceof ArrayBuffer) {
@ -396,7 +433,10 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
this.websocket.send((message as any).data.buffer) this.websocket.send((message as any).data.buffer)
} else { } else {
// Handle text-based messages (backward compatibility and control messages) // Handle text-based messages (backward compatibility and control messages)
// Only log non-presence messages
if (message.type !== 'presence') {
console.log('📤 Sending WebSocket message:', message.type) console.log('📤 Sending WebSocket message:', message.type)
}
// Debug: Log patch content if it's a patch message // Debug: Log patch content if it's a patch message
if (message.type === 'patch' && (message as any).patches) { if (message.type === 'patch' && (message as any).patches) {
console.log('🔍 Sending patches:', (message as any).patches.length, 'patches') console.log('🔍 Sending patches:', (message as any).patches.length, 'patches')
@ -411,12 +451,14 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
this.websocket.send(JSON.stringify(message)) this.websocket.send(JSON.stringify(message))
} }
} else { } else {
if (message.type !== 'presence') {
console.warn('⚠️ CloudflareAdapter: Cannot send message - WebSocket not open', { console.warn('⚠️ CloudflareAdapter: Cannot send message - WebSocket not open', {
messageType: message.type, messageType: message.type,
readyState: this.websocket?.readyState readyState: this.websocket?.readyState
}) })
} }
} }
}
broadcast(message: Message): void { broadcast(message: Message): void {
// For WebSocket-based adapters, broadcast is the same as send // For WebSocket-based adapters, broadcast is the same as send

View File

@ -20,8 +20,11 @@ function minimalSanitizeRecord(record: any): any {
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1 if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {} if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {}
// Validate and fix index property - must be a valid IndexKey (like 'a1', 'a2', etc.) // CRITICAL: IndexKey must follow tldraw's fractional indexing format
if (!sanitized.index || typeof sanitized.index !== 'string' || !/^[a-z]\d+$/.test(sanitized.index)) { // Valid format: starts with 'a' followed by digits, optionally followed by uppercase letters
// Examples: "a1", "a2", "a10", "a1V" (fractional between a1 and a2)
// Invalid: "c1", "b1", "z999" (must start with 'a')
if (!sanitized.index || typeof sanitized.index !== 'string' || !/^a\d+[A-Z]*$/.test(sanitized.index)) {
sanitized.index = 'a1' sanitized.index = 'a1'
} }
if (!sanitized.parentId) sanitized.parentId = 'page:page' if (!sanitized.parentId) sanitized.parentId = 'page:page'

View File

@ -6,12 +6,13 @@ import {
RecordsDiff, RecordsDiff,
} from "@tldraw/tldraw" } from "@tldraw/tldraw"
import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema" import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema"
import { useEffect, useState } from "react" import { useEffect, useState, useRef } from "react"
import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo" import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo"
import { import {
useLocalAwareness, useLocalAwareness,
useRemoteAwareness, useRemoteAwareness,
} from "@automerge/automerge-repo-react-hooks" } from "@automerge/automerge-repo-react-hooks"
import throttle from "lodash.throttle"
import { applyAutomergePatchesToTLStore, sanitizeRecord } from "./AutomergeToTLStore.js" import { applyAutomergePatchesToTLStore, sanitizeRecord } from "./AutomergeToTLStore.js"
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js" import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
@ -128,11 +129,13 @@ import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeU
export function useAutomergeStoreV2({ export function useAutomergeStoreV2({
handle, handle,
userId: _userId, userId: _userId,
adapter,
}: { }: {
handle: DocHandle<any> handle: DocHandle<any>
userId: string userId: string
adapter?: any
}): TLStoreWithStatus { }): TLStoreWithStatus {
console.log("useAutomergeStoreV2 called with handle:", !!handle) console.log("useAutomergeStoreV2 called with handle:", !!handle, "adapter:", !!adapter)
// Create a custom schema that includes all the custom shapes // Create a custom schema that includes all the custom shapes
const customSchema = createTLSchema({ const customSchema = createTLSchema({
@ -202,67 +205,46 @@ export function useAutomergeStoreV2({
const unsubs: (() => void)[] = [] const unsubs: (() => void)[] = []
// A hacky workaround to prevent local changes from being applied twice // Track local changes to prevent echoing them back
// once into the automerge doc and then back again. // Simple boolean flag: set to true when making local changes,
// then reset on the NEXT Automerge change event (which is the echo)
let isLocalChange = false let isLocalChange = false
// Helper function to manually trigger sync after document changes // Helper function to broadcast changes via JSON sync
// The Automerge Repo doesn't auto-broadcast because our WebSocket setup doesn't use peer discovery // DISABLED: This causes last-write-wins conflicts
const triggerSync = () => { // Automerge should handle sync automatically via binary protocol
try { // We're keeping this function but disabling all actual broadcasting
console.log('🔄 triggerSync() called') const broadcastJsonSync = (changedRecords: any[]) => {
const repo = (handle as any).repo // TEMPORARY FIX: Manually broadcast changes via WebSocket since Automerge Repo sync isn't working
console.log('🔍 repo:', !!repo, 'handle:', !!handle, 'documentId:', handle?.documentId) // This sends the full changed records as JSON to other clients
// TODO: Fix Automerge Repo's binary sync protocol to work properly
if (repo) { if (!changedRecords || changedRecords.length === 0) {
console.log('🔍 repo.networkSubsystem:', !!repo.networkSubsystem) return
console.log('🔍 repo.networkSubsystem.syncDoc:', typeof repo.networkSubsystem?.syncDoc)
console.log('🔍 repo.networkSubsystem.adapters:', !!repo.networkSubsystem?.adapters)
// Try multiple approaches to trigger sync
// Approach 1: Use networkSubsystem.syncDoc if available
if (repo.networkSubsystem && typeof repo.networkSubsystem.syncDoc === 'function') {
console.log('🔄 Triggering sync via networkSubsystem.syncDoc()')
repo.networkSubsystem.syncDoc(handle.documentId)
} }
// Approach 2: Broadcast to all network adapters directly
else if (repo.networkSubsystem && repo.networkSubsystem.adapters) { console.log(`📤 Broadcasting ${changedRecords.length} changed records via manual JSON sync`)
console.log('🔄 Broadcasting sync to all network adapters')
const adapters = Array.from(repo.networkSubsystem.adapters.values()) if (adapter && typeof (adapter as any).send === 'function') {
console.log('🔍 Found adapters:', adapters.length) // Send changes to other clients via the network adapter
adapters.forEach((adapter: any) => { (adapter as any).send({
console.log('🔍 Adapter has send:', typeof adapter?.send)
if (adapter && typeof adapter.send === 'function') {
// Send a sync message via the adapter
// The adapter should handle converting this to the right format
console.log('📤 Sending sync via adapter')
adapter.send({
type: 'sync', type: 'sync',
documentId: handle.documentId, data: {
data: handle.doc() store: Object.fromEntries(changedRecords.map(r => [r.id, r]))
},
documentId: handle?.documentId,
timestamp: Date.now()
}) })
}
})
}
// Approach 3: Emit an event to trigger sync
else if (repo.emit && typeof repo.emit === 'function') {
console.log('🔄 Emitting document change event')
repo.emit('change', { documentId: handle.documentId, doc: handle.doc() })
}
else {
console.warn('⚠️ No known method to trigger sync broadcast found')
}
} else { } else {
console.warn('⚠️ No repo found on handle') console.warn('⚠️ Cannot broadcast - adapter not available')
}
} catch (error) {
console.error('❌ Error triggering manual sync:', error)
} }
} }
// Listen for changes from Automerge and apply them to TLDraw // Listen for changes from Automerge and apply them to TLDraw
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => { const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
// Skip the immediate echo of our own local changes
// This flag is set when we update Automerge from TLDraw changes
// and gets reset after skipping one change event (the echo)
if (isLocalChange) { if (isLocalChange) {
isLocalChange = false isLocalChange = false
return return
@ -449,7 +431,7 @@ export function useAutomergeStoreV2({
// Throttle position-only updates (x/y changes) to reduce automerge saves during movement // Throttle position-only updates (x/y changes) to reduce automerge saves during movement
let positionUpdateQueue: RecordsDiff<TLRecord> | null = null let positionUpdateQueue: RecordsDiff<TLRecord> | null = null
let positionUpdateTimeout: NodeJS.Timeout | null = null let positionUpdateTimeout: NodeJS.Timeout | null = null
const POSITION_UPDATE_THROTTLE_MS = 1000 // Save position updates every 1 second const POSITION_UPDATE_THROTTLE_MS = 100 // Save position updates every 100ms for real-time feel
const flushPositionUpdates = () => { const flushPositionUpdates = () => {
if (positionUpdateQueue && handle) { if (positionUpdateQueue && handle) {
@ -464,13 +446,14 @@ export function useAutomergeStoreV2({
applyTLStoreChangesToAutomerge(doc, queuedChanges) applyTLStoreChangesToAutomerge(doc, queuedChanges)
}) })
// Trigger sync to broadcast position updates // Trigger sync to broadcast position updates
triggerSync() const changedRecords = [
setTimeout(() => { ...Object.values(queuedChanges.added || {}),
isLocalChange = false ...Object.values(queuedChanges.updated || {}),
}, 100) ...Object.values(queuedChanges.removed || {})
]
broadcastJsonSync(changedRecords)
} catch (error) { } catch (error) {
console.error("Error applying throttled position updates to Automerge:", error) console.error("Error applying throttled position updates to Automerge:", error)
isLocalChange = false
} }
}) })
} }
@ -856,6 +839,13 @@ export function useAutomergeStoreV2({
return return
} }
// CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops
// Only broadcast changes that originated from user interactions (source === 'user')
if (source === 'remote') {
console.log('🔄 Skipping broadcast for remote change to prevent feedback loop')
return
}
// CRITICAL: Filter out x/y coordinate changes for pinned-to-view shapes // CRITICAL: Filter out x/y coordinate changes for pinned-to-view shapes
// When a shape is pinned, its x/y coordinates change to stay in the same screen position, // When a shape is pinned, its x/y coordinates change to stay in the same screen position,
// but we want to keep the original coordinates static in Automerge // but we want to keep the original coordinates static in Automerge
@ -990,6 +980,38 @@ export function useAutomergeStoreV2({
// Check if this is a position-only update that should be throttled // Check if this is a position-only update that should be throttled
const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges) const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges)
// Log what type of change this is for debugging
const changeType = Object.keys(finalFilteredChanges.added || {}).length > 0 ? 'added' :
Object.keys(finalFilteredChanges.removed || {}).length > 0 ? 'removed' :
isPositionOnly ? 'position-only' : 'property-change'
// DEBUG: Log dimension changes for shapes
if (finalFilteredChanges.updated) {
Object.entries(finalFilteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
const oldRecord = isTuple ? recordTuple[0] : null
const newRecord = isTuple ? recordTuple[1] : recordTuple
if (newRecord?.typeName === 'shape') {
const oldProps = oldRecord?.props || {}
const newProps = newRecord?.props || {}
if (oldProps.w !== newProps.w || oldProps.h !== newProps.h) {
console.log(`🔍 Shape dimension change detected for ${newRecord.type} ${id}:`, {
oldDims: { w: oldProps.w, h: oldProps.h },
newDims: { w: newProps.w, h: newProps.h },
source
})
}
}
})
}
console.log(`🔍 Change detected: ${changeType}, will ${isPositionOnly ? 'throttle' : 'broadcast immediately'}`, {
added: Object.keys(finalFilteredChanges.added || {}).length,
updated: Object.keys(finalFilteredChanges.updated || {}).length,
removed: Object.keys(finalFilteredChanges.removed || {}).length,
source
})
if (isPositionOnly && positionUpdateQueue === null) { if (isPositionOnly && positionUpdateQueue === null) {
// Start a new queue for position updates // Start a new queue for position updates
positionUpdateQueue = finalFilteredChanges positionUpdateQueue = finalFilteredChanges
@ -1048,7 +1070,7 @@ export function useAutomergeStoreV2({
} }
// CRITICAL: Don't skip changes - always save them to ensure consistency // CRITICAL: Don't skip changes - always save them to ensure consistency
// The isLocalChange flag is only used to prevent feedback loops from Automerge changes // The local change timestamp is only used to prevent immediate feedback loops
// We should always save TLDraw changes, even if they came from Automerge sync // We should always save TLDraw changes, even if they came from Automerge sync
// This ensures that all shapes (notes, rectangles, etc.) are consistently persisted // This ensures that all shapes (notes, rectangles, etc.) are consistently persisted
@ -1106,13 +1128,14 @@ export function useAutomergeStoreV2({
applyTLStoreChangesToAutomerge(doc, queuedChanges) applyTLStoreChangesToAutomerge(doc, queuedChanges)
}) })
// Trigger sync to broadcast eraser changes // Trigger sync to broadcast eraser changes
triggerSync() const changedRecords = [
setTimeout(() => { ...Object.values(queuedChanges.added || {}),
isLocalChange = false ...Object.values(queuedChanges.updated || {}),
}, 100) ...Object.values(queuedChanges.removed || {})
]
broadcastJsonSync(changedRecords)
} catch (error) { } catch (error) {
console.error('❌ Error applying queued eraser changes:', error) console.error('❌ Error applying queued eraser changes:', error)
isLocalChange = false
} }
} }
}, 50) // Check every 50ms for faster response }, 50) // Check every 50ms for faster response
@ -1143,10 +1166,12 @@ export function useAutomergeStoreV2({
applyTLStoreChangesToAutomerge(doc, mergedChanges) applyTLStoreChangesToAutomerge(doc, mergedChanges)
}) })
// Trigger sync to broadcast merged changes // Trigger sync to broadcast merged changes
triggerSync() const changedRecords = [
setTimeout(() => { ...Object.values(mergedChanges.added || {}),
isLocalChange = false ...Object.values(mergedChanges.updated || {}),
}, 100) ...Object.values(mergedChanges.removed || {})
]
broadcastJsonSync(changedRecords)
}) })
return return
@ -1154,22 +1179,21 @@ export function useAutomergeStoreV2({
// OPTIMIZED: Use requestIdleCallback to defer Automerge changes when browser is idle // OPTIMIZED: Use requestIdleCallback to defer Automerge changes when browser is idle
// This prevents blocking mouse interactions without queuing changes // This prevents blocking mouse interactions without queuing changes
const applyChanges = () => { const applyChanges = () => {
// Set flag to prevent feedback loop when this change comes back from Automerge // Mark to prevent feedback loop when this change comes back from Automerge
isLocalChange = true isLocalChange = true
handle.change((doc) => { handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, finalFilteredChanges) applyTLStoreChangesToAutomerge(doc, finalFilteredChanges)
}) })
// CRITICAL: Manually trigger Automerge Repo to broadcast changes // CRITICAL: Manually trigger JSON sync broadcast to other clients
// Use requestAnimationFrame to defer this slightly so the change is fully processed // Use requestAnimationFrame to defer this slightly so the change is fully processed
requestAnimationFrame(triggerSync) const changedRecords = [
...Object.values(finalFilteredChanges.added || {}),
// Reset flag after a short delay to allow Automerge change handler to process ...Object.values(finalFilteredChanges.updated || {}),
// This prevents feedback loops while ensuring all changes are saved ...Object.values(finalFilteredChanges.removed || {})
setTimeout(() => { ]
isLocalChange = false requestAnimationFrame(() => broadcastJsonSync(changedRecords))
}, 100)
} }
// Use requestIdleCallback if available to apply changes when browser is idle // Use requestIdleCallback if available to apply changes when browser is idle
@ -1192,8 +1216,6 @@ export function useAutomergeStoreV2({
const docAfter = handle.doc() const docAfter = handle.doc()
} catch (error) { } catch (error) {
console.error("Error applying TLDraw changes to Automerge:", error) console.error("Error applying TLDraw changes to Automerge:", error)
// Reset flag on error to prevent getting stuck
isLocalChange = false
} }
} }
}, { }, {
@ -1229,9 +1251,6 @@ export function useAutomergeStoreV2({
handle.change((doc) => { handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, queuedChanges) applyTLStoreChangesToAutomerge(doc, queuedChanges)
}) })
setTimeout(() => {
isLocalChange = false
}, 100)
} }
} }
} }
@ -1403,23 +1422,73 @@ export function useAutomergePresence(params: {
name: string name: string
color: string color: string
} }
adapter?: any
}) { }) {
const { handle, store, userMetadata } = params const { handle, store, userMetadata, adapter } = params
const presenceRef = useRef<Map<string, any>>(new Map())
// Simple presence implementation // Broadcast local presence to other clients
useEffect(() => { useEffect(() => {
if (!handle || !store) return if (!handle || !store || !adapter) {
return
const updatePresence = () => {
// Basic presence update logic
console.log("Updating presence for user:", userMetadata.userId)
} }
updatePresence() // Listen for changes to instance_presence records in the store
}, [handle, store, userMetadata]) // These represent user cursors, selections, etc.
const handleStoreChange = () => {
if (!store) return
const allRecords = store.allRecords()
// Filter for ALL presence-related records
// instance_presence: Contains user cursor, name, color - THIS IS WHAT WE NEED!
// instance_page_state: Contains selections, editing state
// pointer: Contains pointer position
const presenceRecords = allRecords.filter((r: any) => {
const isPresenceType = r.typeName === 'instance_presence' ||
r.typeName === 'instance_page_state' ||
r.typeName === 'pointer'
const hasPresenceId = r.id?.startsWith('instance_presence:') ||
r.id?.startsWith('instance_page_state:') ||
r.id?.startsWith('pointer:')
return isPresenceType || hasPresenceId
})
if (presenceRecords.length > 0) {
// Send presence update via WebSocket
try {
const presenceData: any = {}
presenceRecords.forEach((record: any) => {
presenceData[record.id] = record
})
adapter.send({
type: 'presence',
userId: userMetadata.userId,
userName: userMetadata.name,
userColor: userMetadata.color,
data: presenceData
})
} catch (error) {
console.error('Error broadcasting presence:', error)
}
}
}
// Throttle presence updates to avoid overwhelming the network
const throttledUpdate = throttle(handleStoreChange, 100)
const unsubscribe = store.listen(throttledUpdate, { scope: 'all' })
return () => {
unsubscribe()
}
}, [handle, store, userMetadata, adapter])
return { return {
updatePresence: () => {}, updatePresence: () => {},
presence: {}, presence: presenceRef.current,
} }
} }

View File

@ -1,5 +1,5 @@
import { useMemo, useEffect, useState, useCallback, useRef } from "react" import { useMemo, useEffect, useState, useCallback, useRef } from "react"
import { TLStoreSnapshot } from "@tldraw/tldraw" import { TLStoreSnapshot, InstancePresenceRecordType } from "@tldraw/tldraw"
import { CloudflareNetworkAdapter } from "./CloudflareAdapter" import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2" import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
import { TLStoreWithStatus } from "@tldraw/tldraw" import { TLStoreWithStatus } from "@tldraw/tldraw"
@ -35,6 +35,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const handleRef = useRef<any>(null) const handleRef = useRef<any>(null)
const storeRef = useRef<any>(null) const storeRef = useRef<any>(null)
const adapterRef = useRef<any>(null)
const lastSentHashRef = useRef<string | null>(null) const lastSentHashRef = useRef<string | null>(null)
const isMouseActiveRef = useRef<boolean>(false) const isMouseActiveRef = useRef<boolean>(false)
const pendingSaveRef = useRef<boolean>(false) const pendingSaveRef = useRef<boolean>(false)
@ -91,17 +92,120 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
handleRef.current = handle handleRef.current = handle
}, [handle]) }, [handle])
// JSON sync is deprecated - all data now flows through Automerge sync protocol // JSON sync callback - receives changed records from other clients
// Old format content is converted server-side and saved to R2 in Automerge format // Apply to Automerge document which will emit patches to update the store
// This callback is kept for backwards compatibility but should not be used const applyJsonSyncData = useCallback((data: TLStoreSnapshot) => {
const applyJsonSyncData = useCallback((_data: TLStoreSnapshot) => { const currentHandle = handleRef.current
console.warn('⚠️ JSON sync callback called but JSON sync is deprecated. All data should flow through Automerge sync protocol.') if (!currentHandle || !data?.store) {
// Don't apply JSON sync - let Automerge sync handle everything console.warn('⚠️ Cannot apply JSON sync - no handle or data')
return return
}
const changedRecordCount = Object.keys(data.store).length
console.log(`📥 Applying ${changedRecordCount} changed records from JSON sync to Automerge document`)
// Log shape dimension changes for debugging
Object.entries(data.store).forEach(([id, record]: [string, any]) => {
if (record?.typeName === 'shape' && (record.props?.w || record.props?.h)) {
console.log(`📥 Receiving shape update for ${record.type} ${id}:`, {
w: record.props.w,
h: record.props.h,
x: record.x,
y: record.y
})
}
})
// Apply changes to the Automerge document
// This will trigger patches which will update the TLDraw store
currentHandle.change((doc: any) => {
if (!doc.store) {
doc.store = {}
}
// Merge the changed records into the Automerge document
Object.entries(data.store).forEach(([id, record]) => {
doc.store[id] = record
})
})
console.log(`✅ Applied ${changedRecordCount} records to Automerge document - patches will update store`)
}, [])
// Presence update callback - applies presence from other clients
// Presence is ephemeral (cursors, selections) and goes directly to the store
// Note: This callback is passed to the adapter but accesses storeRef which updates later
const applyPresenceUpdate = useCallback((userId: string, presenceData: any, senderId?: string, userName?: string, userColor?: string) => {
// CRITICAL: Don't apply our own presence back to ourselves (avoid echo)
// Use senderId (sessionId) instead of userId since multiple users can have the same userId
const currentAdapter = adapterRef.current
const ourSessionId = currentAdapter?.sessionId
if (senderId && ourSessionId && senderId === ourSessionId) {
return
}
// Access the CURRENT store ref (not captured in closure)
const currentStore = storeRef.current
if (!currentStore) {
return
}
try {
// CRITICAL: Transform remote user's instance/pointer/page_state into a proper instance_presence record
// TLDraw expects instance_presence records for remote users, not their local instance records
// Extract data from the presence message
const pointerRecord = presenceData['pointer:pointer']
const pageStateRecord = presenceData['instance_page_state:page:page']
const instanceRecord = presenceData['instance:instance']
if (!pointerRecord) {
return
}
// Create a proper instance_presence record for this remote user
// Use senderId to create a unique presence ID for each session
const presenceId = InstancePresenceRecordType.createId(senderId || userId)
const instancePresence = InstancePresenceRecordType.create({
id: presenceId,
currentPageId: pageStateRecord?.pageId || 'page:page', // Default to main page
userId: userId,
userName: userName || userId, // Use provided userName or fall back to userId
color: userColor || '#000000', // Use provided color or default to black
cursor: {
x: pointerRecord.x || 0,
y: pointerRecord.y || 0,
type: pointerRecord.type || 'default',
rotation: pointerRecord.rotation || 0
},
chatMessage: '', // Empty by default
lastActivityTimestamp: Date.now()
})
// Apply the instance_presence record using mergeRemoteChanges for atomic updates
currentStore.mergeRemoteChanges(() => {
currentStore.put([instancePresence])
})
console.log(`✅ Applied instance_presence for remote user ${userId}`)
} catch (error) {
console.error('❌ Error applying presence:', error)
}
}, []) }, [])
const { repo, adapter } = useMemo(() => { const { repo, adapter } = useMemo(() => {
const adapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData) const adapter = new CloudflareNetworkAdapter(
workerUrl,
roomId,
applyJsonSyncData,
applyPresenceUpdate
)
// Store adapter ref for use in callbacks
adapterRef.current = adapter
const repo = new Repo({ const repo = new Repo({
network: [adapter], network: [adapter],
// Enable sharing of all documents with all peers // Enable sharing of all documents with all peers
@ -114,7 +218,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}) })
return { repo, adapter } return { repo, adapter }
}, [workerUrl, roomId, applyJsonSyncData]) }, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate])
// Initialize Automerge document handle // Initialize Automerge document handle
useEffect(() => { useEffect(() => {
@ -184,65 +288,18 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
// Continue anyway - user can still create new content // Continue anyway - user can still create new content
} }
console.log("Found/Created Automerge handle via Repo:", { // Verify final document state
handleId: handle.documentId,
isReady: handle.isReady(),
roomId: roomId
})
// Wait for the handle to be ready
await handle.whenReady()
// Initialize document with default store if it's new/empty
const currentDoc = handle.doc() as any
if (!currentDoc || !currentDoc.store || Object.keys(currentDoc.store).length === 0) {
console.log("📝 Document is new/empty - initializing with default store")
// Try to load initial data from server for new documents
try {
const response = await fetch(`${workerUrl}/room/${roomId}`)
if (response.ok) {
const serverDoc = await response.json() as TLStoreSnapshot
const serverRecordCount = Object.keys(serverDoc.store || {}).length
if (serverDoc.store && serverRecordCount > 0) {
console.log(`📥 Loading ${serverRecordCount} records from server into new document`)
handle.change((doc: any) => {
// Initialize store if it doesn't exist
if (!doc.store) {
doc.store = {}
}
// Copy all records from server document
Object.entries(serverDoc.store).forEach(([id, record]) => {
doc.store[id] = record
})
})
console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`)
} else {
console.log("📥 Server document is empty - document will start empty")
}
} else if (response.status === 404) {
console.log("📥 No document found on server (404) - starting with empty document")
} else {
console.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`)
}
} catch (error) {
console.error("❌ Error loading initial document from server:", error)
// Continue anyway - document will start empty and sync via WebSocket
}
} else {
const existingRecordCount = Object.keys(currentDoc.store || {}).length
console.log(`✅ Document already has ${existingRecordCount} records - ready to sync`)
}
const finalDoc = handle.doc() as any const finalDoc = handle.doc() as any
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
console.log("Automerge handle initialized:", { console.log("✅ Automerge handle initialized and ready:", {
handleId: handle.documentId,
isReady: handle.isReady(),
hasDoc: !!finalDoc, hasDoc: !!finalDoc,
storeKeys: finalStoreKeys, storeKeys: finalStoreKeys,
shapeCount: finalShapeCount shapeCount: finalShapeCount,
roomId: roomId
}) })
setHandle(handle) setHandle(handle)
@ -260,6 +317,10 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return () => { return () => {
mounted = false mounted = false
// Disconnect adapter on unmount to clean up WebSocket connection
if (adapter) {
adapter.disconnect?.()
}
} }
}, [repo, adapter, roomId, workerUrl]) }, [repo, adapter, roomId, workerUrl])
@ -576,26 +637,42 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
} }
}, [handle, roomId, workerUrl, generateDocHash]) }, [handle, roomId, workerUrl, generateDocHash])
// Generate a unique color for each user based on their userId
const generateUserColor = (userId: string): string => {
// Use a simple hash of the userId to generate a consistent color
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash)
}
// Generate a vibrant color using HSL (hue varies, saturation and lightness fixed for visibility)
const hue = hash % 360
return `hsl(${hue}, 70%, 50%)`
}
// Get user metadata for presence // Get user metadata for presence
const userMetadata: { userId: string; name: string; color: string } = (() => { const userMetadata: { userId: string; name: string; color: string } = (() => {
if (user && 'userId' in user) { if (user && 'userId' in user) {
const uid = (user as { userId: string; name: string; color?: string }).userId
return { return {
userId: (user as { userId: string; name: string; color?: string }).userId, userId: uid,
name: (user as { userId: string; name: string; color?: string }).name, name: (user as { userId: string; name: string; color?: string }).name,
color: (user as { userId: string; name: string; color?: string }).color || '#000000' color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(uid)
} }
} }
const uid = user?.id || 'anonymous'
return { return {
userId: user?.id || 'anonymous', userId: uid,
name: user?.name || 'Anonymous', name: user?.name || 'Anonymous',
color: '#000000' color: generateUserColor(uid)
} }
})() })()
// Use useAutomergeStoreV2 to create a proper TLStore instance that syncs with Automerge // Use useAutomergeStoreV2 to create a proper TLStore instance that syncs with Automerge
const storeWithStatus = useAutomergeStoreV2({ const storeWithStatus = useAutomergeStoreV2({
handle: handle || null as any, handle: handle || null as any,
userId: userMetadata.userId userId: userMetadata.userId,
adapter: adapter // Pass adapter for JSON sync broadcasting
}) })
// Update store ref when store is available // Update store ref when store is available
@ -609,7 +686,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const presence = useAutomergePresence({ const presence = useAutomergePresence({
handle: handle || null, handle: handle || null,
store: storeWithStatus.store || null, store: storeWithStatus.store || null,
userMetadata userMetadata,
adapter: adapter // Pass adapter for presence broadcasting
}) })
return { return {

View File

@ -435,7 +435,8 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
cursor: apiKey ? 'pointer' : 'not-allowed', cursor: apiKey ? 'pointer' : 'not-allowed',
position: 'relative', position: 'relative',
zIndex: 10002, zIndex: 10002,
pointerEvents: 'auto' pointerEvents: 'auto',
touchAction: 'manipulation'
}} }}
> >
Save & Load Meetings Save & Load Meetings

View File

@ -169,6 +169,9 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
transition: 'background-color 0.15s ease, color 0.15s ease', transition: 'background-color 0.15s ease, color 0.15s ease',
pointerEvents: 'auto', pointerEvents: 'auto',
flexShrink: 0, flexShrink: 0,
touchAction: 'manipulation', // Prevent double-tap zoom, improve touch responsiveness
padding: '8px', // Increase touch target size without changing visual size
margin: '-8px', // Negative margin to maintain visual spacing
} }
const minimizeButtonStyle: React.CSSProperties = { const minimizeButtonStyle: React.CSSProperties = {
@ -215,12 +218,13 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
minHeight: '32px', minHeight: '32px',
backgroundColor: '#f8f9fa', backgroundColor: '#f8f9fa',
flexShrink: 0, flexShrink: 0,
touchAction: 'manipulation', // Improve touch responsiveness
} }
const tagStyle: React.CSSProperties = { const tagStyle: React.CSSProperties = {
backgroundColor: '#007acc', backgroundColor: '#007acc',
color: 'white', color: 'white',
padding: '2px 6px', padding: '4px 8px', // Increased padding for better touch target
borderRadius: '12px', borderRadius: '12px',
fontSize: '10px', fontSize: '10px',
fontWeight: '500', fontWeight: '500',
@ -228,6 +232,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
alignItems: 'center', alignItems: 'center',
gap: '4px', gap: '4px',
cursor: tagsEditable ? 'pointer' : 'default', cursor: tagsEditable ? 'pointer' : 'default',
touchAction: 'manipulation', // Improve touch responsiveness
minHeight: '24px', // Ensure adequate touch target height
} }
const tagInputStyle: React.CSSProperties = { const tagInputStyle: React.CSSProperties = {
@ -245,13 +251,15 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '12px', borderRadius: '12px',
padding: '2px 8px', padding: '4px 10px', // Increased padding for better touch target
fontSize: '10px', fontSize: '10px',
fontWeight: '500', fontWeight: '500',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '4px', gap: '4px',
touchAction: 'manipulation', // Improve touch responsiveness
minHeight: '24px', // Ensure adequate touch target height
} }
const handleTagClick = (tag: string) => { const handleTagClick = (tag: string) => {
@ -318,6 +326,13 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
const handleButtonClick = (e: React.MouseEvent, action: () => void) => { const handleButtonClick = (e: React.MouseEvent, action: () => void) => {
e.stopPropagation() e.stopPropagation()
e.preventDefault()
action()
}
const handleButtonTouch = (e: React.TouchEvent, action: () => void) => {
e.stopPropagation()
e.preventDefault()
action() action()
} }
@ -380,6 +395,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
onClick={(e) => handleButtonClick(e, onPinToggle)} onClick={(e) => handleButtonClick(e, onPinToggle)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => handleButtonTouch(e, onPinToggle)}
onTouchEnd={(e) => e.stopPropagation()}
title={isPinnedToView ? "Unpin from view" : "Pin to view"} title={isPinnedToView ? "Unpin from view" : "Pin to view"}
aria-label={isPinnedToView ? "Unpin from view" : "Pin to view"} aria-label={isPinnedToView ? "Unpin from view" : "Pin to view"}
> >
@ -398,6 +415,12 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => {
if (onMinimize) {
handleButtonTouch(e, onMinimize)
}
}}
onTouchEnd={(e) => e.stopPropagation()}
title="Minimize" title="Minimize"
aria-label="Minimize" aria-label="Minimize"
disabled={!onMinimize} disabled={!onMinimize}
@ -409,6 +432,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
onClick={(e) => handleButtonClick(e, onClose)} onClick={(e) => handleButtonClick(e, onClose)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => handleButtonTouch(e, onClose)}
onTouchEnd={(e) => e.stopPropagation()}
title="Close" title="Close"
aria-label="Close" aria-label="Close"
> >
@ -432,6 +457,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
<div <div
style={tagsContainerStyle} style={tagsContainerStyle}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
if (tagsEditable && !isEditingTags && e.target === e.currentTarget) { if (tagsEditable && !isEditingTags && e.target === e.currentTarget) {
setIsEditingTags(true) setIsEditingTags(true)
@ -446,6 +472,11 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
e.stopPropagation() e.stopPropagation()
handleTagClick(tag) handleTagClick(tag)
}} }}
onTouchEnd={(e) => {
e.stopPropagation()
e.preventDefault()
handleTagClick(tag)
}}
title={tagsEditable ? "Click to remove tag" : undefined} title={tagsEditable ? "Click to remove tag" : undefined}
> >
{tag.replace('#', '')} {tag.replace('#', '')}
@ -480,6 +511,12 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
setIsEditingTags(true) setIsEditingTags(true)
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => {
e.stopPropagation()
e.preventDefault()
setIsEditingTags(true)
}}
onTouchEnd={(e) => e.stopPropagation()}
title="Add tag" title="Add tag"
> >
+ Add + Add

View File

@ -284,77 +284,76 @@
} }
/* Dark mode support */ /* Dark mode support */
@media (prefers-color-scheme: dark) {
.crypto-login-container { html.dark .crypto-login-container {
background: #2d3748; background: #2d3748;
border-color: #4a5568; border-color: #4a5568;
} }
.crypto-login-container h2 { html.dark .crypto-login-container h2 {
color: #f7fafc; color: #f7fafc;
} }
.crypto-info { html.dark .crypto-info {
background: #4a5568; background: #4a5568;
border-left-color: #63b3ed; border-left-color: #63b3ed;
} }
.crypto-info p { html.dark .crypto-info p {
color: #e2e8f0; color: #e2e8f0;
} }
.form-group label { html.dark .form-group label {
color: #e2e8f0; color: #e2e8f0;
} }
.form-group input { html.dark .form-group input {
background: #4a5568; background: #4a5568;
border-color: #718096; border-color: #718096;
color: #f7fafc; color: #f7fafc;
} }
.form-group input:focus { html.dark .form-group input:focus {
border-color: #63b3ed; border-color: #63b3ed;
box-shadow: 0 0 0 3px rgba(99, 179, 237, 0.1); box-shadow: 0 0 0 3px rgba(99, 179, 237, 0.1);
} }
.form-group input:disabled { html.dark .form-group input:disabled {
background-color: #2d3748; background-color: #2d3748;
color: #a0aec0; color: #a0aec0;
} }
.existing-users { html.dark .existing-users {
background: #4a5568; background: #4a5568;
border-color: #718096; border-color: #718096;
} }
.existing-users h3 { html.dark .existing-users h3 {
color: #e2e8f0; color: #e2e8f0;
} }
.user-option { html.dark .user-option {
background: #2d3748; background: #2d3748;
border-color: #718096; border-color: #718096;
} }
.user-option:hover:not(:disabled) { html.dark .user-option:hover:not(:disabled) {
border-color: #63b3ed; border-color: #63b3ed;
background: #2c5282; background: #2c5282;
} }
.user-option.selected { html.dark .user-option.selected {
border-color: #63b3ed; border-color: #63b3ed;
background: #2c5282; background: #2c5282;
} }
.user-name { html.dark .user-name {
color: #e2e8f0; color: #e2e8f0;
} }
.user-status { html.dark .user-status {
color: #a0aec0; color: #a0aec0;
} }
}
/* Test Component Styles */ /* Test Component Styles */
.crypto-test-container { .crypto-test-container {
@ -558,20 +557,19 @@
} }
/* Dark mode for login button */ /* Dark mode for login button */
@media (prefers-color-scheme: dark) {
.login-button { html.dark .login-button {
background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%); background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%);
} }
.login-button:hover { html.dark .login-button:hover {
background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
} }
.login-modal { html.dark .login-modal {
background: #2d3748; background: #2d3748;
border: 1px solid #4a5568; border: 1px solid #4a5568;
} }
}
/* Debug Component Styles */ /* Debug Component Styles */
.crypto-debug-container { .crypto-debug-container {
@ -637,59 +635,58 @@
} }
/* Dark mode for test component */ /* Dark mode for test component */
@media (prefers-color-scheme: dark) {
.crypto-test-container { html.dark .crypto-test-container {
background: #2d3748; background: #2d3748;
border-color: #4a5568; border-color: #4a5568;
} }
.crypto-test-container h2 { html.dark .crypto-test-container h2 {
color: #f7fafc; color: #f7fafc;
} }
.test-results h3 { html.dark .test-results h3 {
color: #e2e8f0; color: #e2e8f0;
} }
.results-list { html.dark .results-list {
background: #4a5568; background: #4a5568;
border-color: #718096; border-color: #718096;
} }
.result-item { html.dark .result-item {
color: #e2e8f0; color: #e2e8f0;
border-bottom-color: #718096; border-bottom-color: #718096;
} }
.test-info { html.dark .test-info {
background: #2c5282; background: #2c5282;
border-left-color: #63b3ed; border-left-color: #63b3ed;
} }
.test-info h3 { html.dark .test-info h3 {
color: #90cdf4; color: #90cdf4;
} }
.test-info ul { html.dark .test-info ul {
color: #e2e8f0; color: #e2e8f0;
} }
.crypto-debug-container { html.dark .crypto-debug-container {
background: #4a5568; background: #4a5568;
border-color: #718096; border-color: #718096;
} }
.crypto-debug-container h2 { html.dark .crypto-debug-container h2 {
color: #e2e8f0; color: #e2e8f0;
} }
.debug-input { html.dark .debug-input {
background: #2d3748; background: #2d3748;
border-color: #718096; border-color: #718096;
color: #f7fafc; color: #f7fafc;
} }
.debug-results h3 { html.dark .debug-results h3 {
color: #e2e8f0; color: #e2e8f0;
} }
}

View File

@ -1237,3 +1237,33 @@ mark {
background-color: #fafafa; background-color: #fafafa;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
/* Mobile Touch Interaction Improvements for Obsidian Browser */
.obsidian-browser button,
.connect-vault-button,
.close-button,
.view-button,
.disconnect-vault-button,
.select-all-button,
.bulk-import-button,
.tag-button,
.retry-button,
.load-vault-button,
.method-button,
.submit-button,
.back-button,
.folder-picker-button,
.clear-search-button {
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
/* Ensure adequate touch target sizes on mobile */
@media (max-width: 768px) {
.obsidian-browser button,
.view-button,
.tag-button {
min-height: 44px;
padding: 10px 16px;
}
}

View File

@ -47,22 +47,21 @@
} }
/* Dark mode support */ /* Dark mode support */
@media (prefers-color-scheme: dark) {
.obsidian-toolbar-button { html.dark .obsidian-toolbar-button {
background: #2d2d2d; background: #2d2d2d;
border-color: #404040; border-color: #404040;
color: #e0e0e0; color: #e0e0e0;
} }
.obsidian-toolbar-button:hover { html.dark .obsidian-toolbar-button:hover {
background: #3d3d3d; background: #3d3d3d;
border-color: #007acc; border-color: #007acc;
color: #007acc; color: #007acc;
} }
.obsidian-toolbar-button:active { html.dark .obsidian-toolbar-button:active {
background: #1a3a5c; background: #1a3a5c;
border-color: #005a9e; border-color: #005a9e;
color: #005a9e; color: #005a9e;
} }
}

View File

@ -484,15 +484,15 @@
} }
/* Dark mode support */ /* Dark mode support */
@media (prefers-color-scheme: dark) {
.dashboard-container { html.dark .dashboard-container {
background: #1a1a1a; background: #1a1a1a;
} }
.dashboard-header, .dashboard-header,
.starred-boards-section, .starred-boards-section,
.quick-actions-section, .quick-actions-section,
.auth-required { html.dark .auth-required {
background: #2d2d2d; background: #2d2d2d;
color: #e9ecef; color: #e9ecef;
} }
@ -501,53 +501,53 @@
.section-header h2, .section-header h2,
.quick-actions-section h2, .quick-actions-section h2,
.board-title, .board-title,
.action-card h3 { html.dark .action-card h3 {
color: #e9ecef; color: #e9ecef;
} }
.dashboard-header p, .dashboard-header p,
.empty-state, .empty-state,
.board-meta, .board-meta,
.action-card p { html.dark .action-card p {
color: #adb5bd; color: #adb5bd;
} }
.board-card, .board-card,
.action-card { html.dark .action-card {
background: #3a3a3a; background: #3a3a3a;
border-color: #495057; border-color: #495057;
} }
.board-card:hover, .board-card:hover,
.action-card:hover { html.dark .action-card:hover {
border-color: #6c757d; border-color: #6c757d;
} }
.board-slug { html.dark .board-slug {
background: #495057; background: #495057;
color: #adb5bd; color: #adb5bd;
} }
.star-board-button { html.dark .star-board-button {
background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%); background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%);
color: white; color: white;
border: none; border: none;
} }
.star-board-button:hover { html.dark .star-board-button:hover {
background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
color: white; color: white;
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 179, 237, 0.3); box-shadow: 0 2px 8px rgba(99, 179, 237, 0.3);
} }
.star-board-button.starred { html.dark .star-board-button.starred {
background: #6B7280; background: #6B7280;
color: white; color: white;
border: none; border: none;
} }
.star-board-button.starred:hover { html.dark .star-board-button.starred:hover {
background: #4B5563; background: #4B5563;
color: white; color: white;
transform: translateY(-1px); transform: translateY(-1px);
@ -555,33 +555,32 @@
} }
/* Dark mode popup styles */ /* Dark mode popup styles */
.star-popup-success { html.dark .star-popup-success {
background: #1e4d2b; background: #1e4d2b;
color: #d4edda; color: #d4edda;
border: 1px solid #2d5a3d; border: 1px solid #2d5a3d;
} }
.star-popup-error { html.dark .star-popup-error {
background: #4a1e1e; background: #4a1e1e;
color: #f8d7da; color: #f8d7da;
border: 1px solid #5a2d2d; border: 1px solid #5a2d2d;
} }
.star-popup-info { html.dark .star-popup-info {
background: #1e4a4a; background: #1e4a4a;
color: #d1ecf1; color: #d1ecf1;
border: 1px solid #2d5a5a; border: 1px solid #2d5a5a;
} }
.board-screenshot { html.dark .board-screenshot {
background: #495057; background: #495057;
border-bottom-color: #6c757d; border-bottom-color: #6c757d;
} }
.screenshot-image { html.dark .screenshot-image {
background: #495057; background: #495057;
} }
}
/* Responsive design */ /* Responsive design */
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@ -2,6 +2,27 @@
:root { :root {
--border-radius: 10px; --border-radius: 10px;
--bg-color: #ffffff;
--text-color: #24292e;
--border-color: #e1e4e8;
--code-bg: #e4e9ee;
--code-color: #38424c;
--hover-bg: #f6f8fa;
--tool-bg: #f5f5f5;
--tool-text: #333333;
--tool-border: #d0d0d0;
}
html.dark {
--bg-color: #1a1a1a;
--text-color: #e4e4e4;
--border-color: #404040;
--code-bg: #2d2d2d;
--code-color: #e4e4e4;
--hover-bg: #2d2d2d;
--tool-bg: #3a3a3a;
--tool-text: #e0e0e0;
--tool-border: #555555;
} }
html, html,
@ -11,6 +32,9 @@ body {
min-height: 100vh; min-height: 100vh;
min-height: -webkit-fill-available; min-height: -webkit-fill-available;
height: 100%; height: 100%;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
} }
video { video {
@ -28,7 +52,7 @@ main {
font-family: "Recursive"; font-family: "Recursive";
font-variation-settings: "MONO" 1; font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1; font-variation-settings: "CASL" 1;
color: #24292e; color: var(--text-color);
} }
h1 { h1 {
@ -92,9 +116,9 @@ pre>code {
} }
code { code {
background-color: #e4e9ee; background-color: var(--code-bg);
width: 100%; width: 100%;
color: #38424c; color: var(--code-color);
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 4px; border-radius: 4px;
} }
@ -810,3 +834,75 @@ p:has(+ ol) {
padding-left: 0.1em; padding-left: 0.1em;
color: #fc8958; color: #fc8958;
} }
/* Mobile Touch Interaction Improvements */
button,
input[type="button"],
input[type="submit"],
[role="button"],
.clickable {
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
/* Ensure adequate touch target sizes on mobile */
@media (max-width: 768px) {
button,
input[type="button"],
input[type="submit"],
[role="button"] {
min-height: 44px;
min-width: 44px;
}
}
/* ========================================
Tool/Shape Consistent Grey Backgrounds
======================================== */
/* Apply consistent grey background to all custom shapes/tools */
.chat-container,
.embed-container,
.markdown-container,
.prompt-container,
.obs-note-container,
.transcription-container,
.holon-container,
.video-chat-container,
.slide-container,
.fathom-meetings-browser-container,
.obsidian-browser-container,
.holon-browser-container,
.multmux-container {
background-color: var(--tool-bg) !important;
color: var(--tool-text) !important;
border: 1px solid var(--tool-border) !important;
}
/* Input fields within tools */
.chat-container input,
.chat-container textarea,
.prompt-container input,
.prompt-container textarea,
.markdown-container input,
.markdown-container textarea,
.embed-container input {
background-color: var(--bg-color) !important;
color: var(--text-color) !important;
border: 1px solid var(--tool-border) !important;
}
/* Buttons within tools */
.chat-container button,
.prompt-container button,
.embed-container button {
background-color: var(--code-bg) !important;
color: var(--code-color) !important;
border: 1px solid var(--tool-border) !important;
}
.chat-container button:hover,
.prompt-container button:hover,
.embed-container button:hover {
background-color: var(--hover-bg) !important;
}

View File

@ -37,13 +37,12 @@
} }
/* Dark mode support */ /* Dark mode support */
@media (prefers-color-scheme: dark) {
.custom-user-profile { html.dark .custom-user-profile {
background: rgba(45, 45, 45, 0.9); background: rgba(45, 45, 45, 0.9);
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
color: #e9ecef; color: #e9ecef;
} }
}
/* Animations */ /* Animations */
@keyframes profileSlideIn { @keyframes profileSlideIn {

View File

@ -1,14 +1,25 @@
# Cloudflare Pages redirects and rewrites # Cloudflare Pages redirects and rewrites
# This file handles SPA routing and URL rewrites (replaces vercel.json rewrites) # This file handles SPA routing and URL rewrites (replaces vercel.json rewrites)
# SPA fallback - all routes should serve index.html
/* /index.html 200
# Specific route rewrites (matching vercel.json) # Specific route rewrites (matching vercel.json)
# Handle both with and without trailing slashes
/board/* /index.html 200 /board/* /index.html 200
/board /index.html 200 /board /index.html 200
/board/ /index.html 200
/inbox /index.html 200 /inbox /index.html 200
/inbox/ /index.html 200
/contact /index.html 200 /contact /index.html 200
/contact/ /index.html 200
/presentations /index.html 200 /presentations /index.html 200
/presentations/ /index.html 200
/presentations/* /index.html 200
/dashboard /index.html 200 /dashboard /index.html 200
/dashboard/ /index.html 200
/login /index.html 200
/login/ /index.html 200
/debug /index.html 200
/debug/ /index.html 200
# SPA fallback - all routes should serve index.html (must be last)
/* /index.html 200

View File

@ -1,7 +1,7 @@
import { useAutomergeSync } from "@/automerge/useAutomergeSync" import { useAutomergeSync } from "@/automerge/useAutomergeSync"
import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext" import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext"
import { useMemo, useEffect, useState, useRef } from "react" import { useMemo, useEffect, useState, useRef } from "react"
import { Tldraw, Editor, TLShapeId, TLRecord } from "tldraw" import { Tldraw, Editor, TLShapeId, TLRecord, useTldrawUser, TLUserPreferences } from "tldraw"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { ChatBoxTool } from "@/tools/ChatBoxTool" import { ChatBoxTool } from "@/tools/ChatBoxTool"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil" import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
@ -41,6 +41,8 @@ import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool"
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil" import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil" import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil" import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { MultmuxTool } from "@/tools/MultmuxTool"
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
// Location shape removed - no longer needed // Location shape removed - no longer needed
import { import {
lockElement, lockElement,
@ -81,6 +83,7 @@ const customShapeUtils = [
HolonBrowserShape, HolonBrowserShape,
ObsidianBrowserShape, ObsidianBrowserShape,
FathomMeetingsBrowserShape, FathomMeetingsBrowserShape,
MultmuxShape,
] ]
const customTools = [ const customTools = [
ChatBoxTool, ChatBoxTool,
@ -95,6 +98,7 @@ const customTools = [
TranscriptionTool, TranscriptionTool,
HolonTool, HolonTool,
FathomMeetingsTool, FathomMeetingsTool,
MultmuxTool,
] ]
export function Board() { export function Board() {
@ -180,18 +184,95 @@ export function Board() {
}); });
}, [roomId]) }, [roomId])
// Generate a stable user ID that persists across sessions
const uniqueUserId = useMemo(() => {
if (!session.username) return undefined
// Use localStorage to persist user ID across sessions
const storageKey = `tldraw-user-id-${session.username}`
let userId = localStorage.getItem(storageKey)
if (!userId) {
// Create a new user ID if one doesn't exist
userId = `${session.username}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(storageKey, userId)
}
return userId
}, [session.username])
// Generate a unique color for each user based on their userId
const generateUserColor = (userId: string): string => {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash)
}
const hue = hash % 360
return `hsl(${hue}, 70%, 50%)`
}
// Get current dark mode state from DOM
const getColorScheme = (): 'light' | 'dark' => {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
}
// Set up user preferences for TLDraw collaboration
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>(() => ({
id: uniqueUserId || 'anonymous',
name: session.username || 'Anonymous',
color: uniqueUserId ? generateUserColor(uniqueUserId) : '#000000',
colorScheme: getColorScheme(),
}))
// Update user preferences when session changes
useEffect(() => {
if (uniqueUserId) {
setUserPreferences({
id: uniqueUserId,
name: session.username || 'Anonymous',
color: generateUserColor(uniqueUserId),
colorScheme: getColorScheme(),
})
}
}, [uniqueUserId, session.username])
// Listen for dark mode changes and update tldraw color scheme
useEffect(() => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
const newColorScheme = getColorScheme()
setUserPreferences(prev => ({
...prev,
colorScheme: newColorScheme,
}))
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => observer.disconnect()
}, [])
// Create the user object for TLDraw
const user = useTldrawUser({ userPreferences, setUserPreferences })
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],
user: session.authed ? { user: session.authed && uniqueUserId ? {
id: session.username, id: uniqueUserId,
name: session.username, name: session.username, // Display name (can be duplicate)
} : undefined, } : undefined,
}), }),
[roomId, session.authed, session.username], [roomId, session.authed, session.username, uniqueUserId],
) )
// Use Automerge sync for all environments // Use Automerge sync for all environments
@ -414,22 +495,52 @@ export function Board() {
} }
// Also check for shapes on other pages // Also check for shapes on other pages
const shapesOnOtherPages = storeShapes.filter((s: any) => s.parentId && s.parentId !== currentPageId) // CRITICAL: Only count shapes that are DIRECT children of other pages, not frame/group children
const shapesOnOtherPages = storeShapes.filter((s: any) =>
s.parentId &&
s.parentId.startsWith('page:') && // Only page children
s.parentId !== currentPageId
)
if (shapesOnOtherPages.length > 0) { if (shapesOnOtherPages.length > 0) {
console.log(`📊 Board: ${shapesOnOtherPages.length} shapes exist on other pages (not current page ${currentPageId})`) console.log(`📊 Board: ${shapesOnOtherPages.length} shapes exist on other pages (not current page ${currentPageId})`)
// Find which page has the most shapes // Find which page has the most shapes
// CRITICAL: Only count shapes that are DIRECT children of pages, not frame/group children
const pageShapeCounts = new Map<string, number>() const pageShapeCounts = new Map<string, number>()
storeShapes.forEach((s: any) => { storeShapes.forEach((s: any) => {
if (s.parentId) { if (s.parentId && s.parentId.startsWith('page:')) {
pageShapeCounts.set(s.parentId, (pageShapeCounts.get(s.parentId) || 0) + 1) pageShapeCounts.set(s.parentId, (pageShapeCounts.get(s.parentId) || 0) + 1)
} }
}) })
// Also check for shapes with no parentId or invalid parentId // Also check for shapes with no parentId or invalid parentId
const shapesWithInvalidParent = storeShapes.filter((s: any) => !s.parentId || (s.parentId && !allPages.find((p: any) => p.id === s.parentId))) // CRITICAL: Frame and group children have parentId like "frame:..." or "group:...", not page IDs
// Only consider a parentId invalid if:
// 1. It's missing/null/undefined
// 2. It references a page that doesn't exist (starts with "page:" but page not found)
// 3. It references a shape that doesn't exist (starts with "shape:" but shape not found)
// DO NOT consider frame/group parentIds as invalid!
const shapesWithInvalidParent = storeShapes.filter((s: any) => {
if (!s.parentId) return true // Missing parentId
// Check if it's a page reference
if (s.parentId.startsWith('page:')) {
// Only invalid if the page doesn't exist
return !allPages.find((p: any) => p.id === s.parentId)
}
// Check if it's a shape reference (frame, group, etc.)
if (s.parentId.startsWith('shape:')) {
// Check if the parent shape exists in the store
const parentShape = storeShapes.find((shape: any) => shape.id === s.parentId)
return !parentShape // Invalid if parent shape doesn't exist
}
// Any other format is invalid
return true
})
if (shapesWithInvalidParent.length > 0) { if (shapesWithInvalidParent.length > 0) {
console.warn(`📊 Board: ${shapesWithInvalidParent.length} shapes have invalid or missing parentId. Fixing...`) console.warn(`📊 Board: ${shapesWithInvalidParent.length} shapes have truly invalid or missing parentId. Fixing...`)
// Fix shapes with invalid parentId by assigning them to current page // Fix shapes with invalid parentId by assigning them to current page
// CRITICAL: Preserve x and y coordinates when fixing parentId // CRITICAL: Preserve x and y coordinates when fixing parentId
// This prevents coordinates from being reset when patches come back from Automerge // This prevents coordinates from being reset when patches come back from Automerge
@ -771,6 +882,7 @@ export function Board() {
<div style={{ position: "fixed", inset: 0 }}> <div style={{ position: "fixed", inset: 0 }}>
<Tldraw <Tldraw
store={store.store} store={store.store}
user={user}
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]} shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
tools={customTools} tools={customTools}
components={components} components={components}

View File

@ -131,7 +131,7 @@ export function Dashboard() {
<div className="board-card-actions"> <div className="board-card-actions">
<Link <Link
to={`/board/${board.slug}`} to={`/board/${board.slug}/`}
className="open-board-button" className="open-board-button"
> >
Open Board Open Board

View File

@ -3,8 +3,8 @@ export function Default() {
<main> <main>
<header>Jeff Emmett</header> <header>Jeff Emmett</header>
<nav className="main-nav"> <nav className="main-nav">
<a href="/presentations" className="nav-link">Presentations</a> <a href="/presentations/" className="nav-link">Presentations</a>
<a href="/contact" className="nav-link">Contact</a> <a href="/contact/" className="nav-link">Contact</a>
</nav> </nav>
<h2>Hello! 👋🍄</h2> <h2>Hello! 👋🍄</h2>
<p> <p>
@ -44,7 +44,7 @@ export function Default() {
<h2>Talks</h2> <h2>Talks</h2>
<p> <p>
You can find my presentations and slides on the{" "} You can find my presentations and slides on the{" "}
<a href="/presentations">presentations page</a>. <a href="/presentations/">presentations page</a>.
</p> </p>
<ol reversed> <ol reversed>
<li> <li>

View File

@ -13,7 +13,7 @@ export function Presentations() {
</p> </p>
<p> <p>
For more of my work, check out my <a href="/">main page</a> or For more of my work, check out my <a href="/">main page</a> or
<a href="/contact">get in touch</a>. <a href="/contact/">get in touch</a>.
</p> </p>
</div> </div>

View File

@ -125,7 +125,7 @@ export function Resilience() {
<strong>Topic:</strong> Building Community Resilience in an Age of Crisis <strong>Topic:</strong> Building Community Resilience in an Age of Crisis
</p> </p>
<p> <p>
<a href="/presentations"> Back to all presentations</a> <a href="/presentations/"> Back to all presentations</a>
</p> </p>
</div> </div>
</main> </main>

View File

@ -132,10 +132,11 @@ export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
setUsername(newUsername) setUsername(newUsername)
localStorage.setItem("chatUsername", newUsername) localStorage.setItem("chatUsername", newUsername)
} }
fetchMessages(roomId) // DISABLED: Chat polling disabled until Telegram channels integration via Holons
const interval = setInterval(() => fetchMessages(roomId), 2000) // fetchMessages(roomId)
// const interval = setInterval(() => fetchMessages(roomId), 2000)
return () => clearInterval(interval) // return () => clearInterval(interval)
}, [roomId]) }, [roomId])
useEffect(() => { useEffect(() => {

View File

@ -1,9 +1,7 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw" import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
//import Embed from "react-embed" import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
import { usePinnedToView } from "../hooks/usePinnedToView"
//TODO: FIX PEN AND MOBILE INTERACTION WITH EDITING EMBED URL - DEFAULT TO TEXT SELECTED
export type IEmbedShape = TLBaseShape< export type IEmbedShape = TLBaseShape<
"Embed", "Embed",
@ -11,11 +9,11 @@ export type IEmbedShape = TLBaseShape<
w: number w: number
h: number h: number
url: string | null url: string | null
isMinimized?: boolean pinnedToView: boolean
tags: string[]
interactionState?: { interactionState?: {
scrollPosition?: { x: number; y: number } scrollPosition?: { x: number; y: number }
currentTime?: number // for videos currentTime?: number
// other state you want to sync
} }
} }
> >
@ -31,12 +29,10 @@ const transformUrl = (url: string): string => {
// Google Maps // Google Maps
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) { if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
// If it's already an embed URL, return as is
if (url.includes("google.com/maps/embed")) { if (url.includes("google.com/maps/embed")) {
return url return url
} }
// Handle directions
const directionsMatch = url.match(/dir\/([^\/]+)\/([^\/]+)/) const directionsMatch = url.match(/dir\/([^\/]+)\/([^\/]+)/)
if (directionsMatch || url.includes("/dir/")) { if (directionsMatch || url.includes("/dir/")) {
const origin = url.match(/origin=([^&]+)/)?.[1] || directionsMatch?.[1] const origin = url.match(/origin=([^&]+)/)?.[1] || directionsMatch?.[1]
@ -52,13 +48,11 @@ const transformUrl = (url: string): string => {
} }
} }
// Extract place ID
const placeMatch = url.match(/[?&]place_id=([^&]+)/) const placeMatch = url.match(/[?&]place_id=([^&]+)/)
if (placeMatch) { if (placeMatch) {
return `https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2!2d0!3d0!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s${placeMatch[1]}!2s!5e0!3m2!1sen!2s!4v1` return `https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2!2d0!3d0!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s${placeMatch[1]}!2s!5e0!3m2!1sen!2s!4v1`
} }
// For all other map URLs
return `https://www.google.com/maps/embed/v1/place?key=${ return `https://www.google.com/maps/embed/v1/place?key=${
import.meta.env.VITE_GOOGLE_MAPS_API_KEY import.meta.env.VITE_GOOGLE_MAPS_API_KEY
}&q=${encodeURIComponent(url)}` }&q=${encodeURIComponent(url)}`
@ -71,15 +65,13 @@ const transformUrl = (url: string): string => {
if (xMatch) { if (xMatch) {
const [, username, tweetId] = xMatch const [, username, tweetId] = xMatch
if (tweetId) { if (tweetId) {
// For tweets
return `https://platform.x.com/embed/Tweet.html?id=${tweetId}` return `https://platform.x.com/embed/Tweet.html?id=${tweetId}`
} else { } else {
// For profiles, return about:blank and handle display separately
return "about:blank" return "about:blank"
} }
} }
// Medium - return about:blank to prevent iframe loading // Medium - return about:blank
if (url.includes("medium.com")) { if (url.includes("medium.com")) {
return "about:blank" return "about:blank"
} }
@ -93,29 +85,24 @@ const transformUrl = (url: string): string => {
} }
const getDefaultDimensions = (url: string): { w: number; h: number } => { const getDefaultDimensions = (url: string): { w: number; h: number } => {
// YouTube default dimensions (16:9 ratio)
if (url.match(/(?:youtube\.com|youtu\.be)/)) { if (url.match(/(?:youtube\.com|youtu\.be)/)) {
return { w: 800, h: 450 } return { w: 800, h: 450 }
} }
// Twitter/X default dimensions
if (url.match(/(?:twitter\.com|x\.com)/)) { if (url.match(/(?:twitter\.com|x\.com)/)) {
if (url.match(/\/status\/|\/tweets\//)) { if (url.match(/\/status\/|\/tweets\//)) {
return { w: 800, h: 600 } // For individual tweets return { w: 800, h: 600 }
} }
} }
// Google Maps default dimensions
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) { if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
return { w: 800, h: 600 } return { w: 800, h: 600 }
} }
// Gather.town default dimensions
if (url.includes("gather.town")) { if (url.includes("gather.town")) {
return { w: 800, h: 600 } return { w: 800, h: 600 }
} }
// Default dimensions for other embeds
return { w: 800, h: 600 } return { w: 800, h: 600 }
} }
@ -124,14 +111,13 @@ const getFaviconUrl = (url: string): string => {
const urlObj = new URL(url) const urlObj = new URL(url)
return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32` return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`
} catch { } catch {
return '' // Return empty if URL is invalid return ''
} }
} }
const getDisplayTitle = (url: string): string => { const getDisplayTitle = (url: string): string => {
try { try {
const urlObj = new URL(url) const urlObj = new URL(url)
// Handle special cases
if (urlObj.hostname.includes('youtube.com')) { if (urlObj.hostname.includes('youtube.com')) {
return 'YouTube' return 'YouTube'
} }
@ -141,22 +127,25 @@ const getDisplayTitle = (url: string): string => {
if (urlObj.hostname.includes('google.com/maps')) { if (urlObj.hostname.includes('google.com/maps')) {
return 'Google Maps' return 'Google Maps'
} }
// Default: return clean hostname
return urlObj.hostname.replace('www.', '') return urlObj.hostname.replace('www.', '')
} catch { } catch {
return url // Return original URL if parsing fails return url
} }
} }
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> { export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
static override type = "Embed" static override type = "Embed"
// Embed theme color: Yellow (Rainbow)
static readonly PRIMARY_COLOR = "#eab308"
getDefaultProps(): IEmbedShape["props"] { getDefaultProps(): IEmbedShape["props"] {
return { return {
url: null, url: null,
w: 800, w: 800,
h: 600, h: 600,
isMinimized: false, pinnedToView: false,
tags: ['embed'],
} }
} }
@ -166,23 +155,42 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
x={0} x={0}
y={0} y={0}
width={shape.props.w} width={shape.props.w}
height={shape.props.isMinimized ? 40 : shape.props.h} height={shape.props.h}
fill="none" fill="none"
/> />
) )
} }
component(shape: IEmbedShape) { component(shape: IEmbedShape) {
// Ensure shape props exist with defaults
const props = shape.props || {} const props = shape.props || {}
const url = props.url || "" const url = props.url || ""
const isMinimized = props.isMinimized || false const [isMinimized, setIsMinimized] = useState(false)
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [inputUrl, setInputUrl] = useState(url) const [inputUrl, setInputUrl] = useState(url)
const [error, setError] = useState("") const [error, setError] = useState("")
const [copyStatus, setCopyStatus] = useState(false)
// Use the pinning hook
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
const handleClose = () => {
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
@ -192,7 +200,6 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
? inputUrl ? inputUrl
: `https://${inputUrl}` : `https://${inputUrl}`
// Basic URL validation
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//) const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//)
if (!isValidUrl) { if (!isValidUrl) {
setError("Invalid URL") setError("Invalid URL")
@ -222,111 +229,11 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
}) })
} }
const contentStyle = { // Custom header content with URL info
pointerEvents: isSelected ? "none" as const : "all" as const, const headerContent = url ? (
width: "100%", <div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1, overflow: 'hidden' }}>
height: "100%",
border: "1px solid #D3D3D3",
backgroundColor: "#FFFFFF",
display: "flex",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
}
const wrapperStyle = {
position: 'relative' as const,
width: `${shape.props.w}px`,
height: `${shape.props.isMinimized ? 40 : shape.props.h}px`,
backgroundColor: "#F0F0F0",
borderRadius: "4px",
transition: "height 0.3s, width 0.3s",
overflow: "hidden",
}
// Update control button styles
const controlButtonStyle = {
border: "none",
background: "#666666", // Grey background
color: "white", // White text
padding: "4px 12px",
margin: "0 4px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
pointerEvents: "all" as const,
whiteSpace: "nowrap" as const,
transition: "background-color 0.2s",
"&:hover": {
background: "#4D4D4D", // Darker grey on hover
}
}
const controlsContainerStyle = {
position: "absolute" as const,
top: "8px",
right: "8px",
display: "flex",
gap: "8px",
zIndex: 1,
}
const handleToggleMinimize = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: {
...shape.props,
isMinimized: !shape.props.isMinimized,
},
})
}
const controls = (url: string) => (
<div style={controlsContainerStyle}>
<button
onClick={() => navigator.clipboard.writeText(url)}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
Copy Link
</button>
<button
onClick={() => window.open(url, '_blank')}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
Open in Tab
</button>
<button
onClick={handleToggleMinimize}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
{shape.props.isMinimized ? "Maximize" : "Minimize"}
</button>
</div>
)
// For minimized state, show URL and all controls
if (shape.props.url && shape.props.isMinimized) {
return (
<div style={wrapperStyle}>
<div
style={{
...contentStyle,
height: "40px",
alignItems: "center",
padding: "0 15px",
position: "relative",
display: "flex",
gap: "8px",
}}
>
<img <img
src={getFaviconUrl(shape.props.url)} src={getFaviconUrl(url)}
alt="" alt=""
style={{ style={{
width: "16px", width: "16px",
@ -334,57 +241,62 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
flexShrink: 0, flexShrink: 0,
}} }}
onError={(e) => { onError={(e) => {
// Hide broken favicon
(e.target as HTMLImageElement).style.display = 'none' (e.target as HTMLImageElement).style.display = 'none'
}} }}
/> />
<div <span style={{
style={{ overflow: 'hidden',
display: "flex", textOverflow: 'ellipsis',
flexDirection: "column", whiteSpace: 'nowrap',
overflow: "hidden", fontSize: '13px',
flex: 1, fontWeight: 600
}} }}>
> {getDisplayTitle(url)}
<span
style={{
fontWeight: 500,
color: "#333",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{getDisplayTitle(shape.props.url)}
</span>
<span
style={{
fontSize: "11px",
color: "#666",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{shape.props.url}
</span> </span>
</div> </div>
{controls(shape.props.url)} ) : (
</div> <span>Embed</span>
</div>
) )
}
// For empty state // For empty state - URL input form
if (!shape.props.url) { if (!url) {
return ( return (
<div style={wrapperStyle}> <HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
{controls("")} <StandardizedToolWrapper
title="Embed"
primaryColor={EmbedShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: 'Embed',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div <div
style={{ style={{
...contentStyle, display: 'flex',
cursor: 'text', // Add text cursor to indicate clickable flexDirection: 'column',
touchAction: 'none', // Prevent touch scrolling justifyContent: 'center',
alignItems: 'center',
height: '100%',
padding: '20px',
cursor: 'text',
}} }}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
@ -397,11 +309,7 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
onSubmit={handleSubmit} onSubmit={handleSubmit}
style={{ style={{
width: "100%", width: "100%",
height: "100%", maxWidth: "500px",
padding: "10px",
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@ -409,14 +317,14 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
type="text" type="text"
value={inputUrl} value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)} onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL to embed" placeholder="Enter URL to embed..."
style={{ style={{
width: "100%", width: "100%",
padding: "15px", // Increased padding for better touch target padding: "15px",
border: "1px solid #ccc", border: "1px solid #ccc",
borderRadius: "4px", borderRadius: "4px",
fontSize: "16px", // Increased font size for better visibility fontSize: "16px",
touchAction: 'none', touchAction: 'manipulation',
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@ -429,85 +337,133 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
}} }}
/> />
{error && ( {error && (
<div style={{ color: "red", marginTop: "10px" }}>{error}</div> <div style={{ color: "red", marginTop: "10px", textAlign: 'center' }}>{error}</div>
)} )}
</form> </form>
</div> </div>
</div> </StandardizedToolWrapper>
</HTMLContainer>
) )
} }
// For medium.com and twitter profile views // For medium.com and twitter profile views
if (shape.props.url?.includes("medium.com") || if (url.includes("medium.com") ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) { (url && url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) {
return ( return (
<div style={wrapperStyle}> <HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
{controls(shape.props.url)} <StandardizedToolWrapper
title="Embed"
headerContent={headerContent}
primaryColor={EmbedShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: 'Embed',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div <div
style={{ style={{
...contentStyle, display: 'flex',
flexDirection: "column", flexDirection: "column",
gap: "12px", gap: "12px",
padding: "20px", padding: "20px",
textAlign: "center", textAlign: "center",
pointerEvents: "all", height: '100%',
justifyContent: 'center',
alignItems: 'center',
}} }}
> >
<p> <p>
Medium's content policy does not allow for embedding articles in This content cannot be embedded in an iframe.
iframes.
</p> </p>
<a <button
href={shape.props.url} onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
target="_blank"
rel="noopener noreferrer"
style={{ style={{
color: "#1976d2", padding: '10px 20px',
textDecoration: "none", backgroundColor: EmbedShape.PRIMARY_COLOR,
cursor: "pointer", color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
touchAction: 'manipulation',
}} }}
onPointerDown={(e) => e.stopPropagation()}
> >
Open article in new tab Open in new tab
</a> </button>
</div>
</div> </div>
</StandardizedToolWrapper>
</HTMLContainer>
) )
} }
// For normal embed view // For normal embed view
return ( return (
<div style={wrapperStyle}> <HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<div <StandardizedToolWrapper
style={{ title="Embed"
height: "40px", headerContent={headerContent}
position: "relative", primaryColor={EmbedShape.PRIMARY_COLOR}
backgroundColor: "#F0F0F0", isSelected={isSelected}
borderTopLeftRadius: "4px", width={shape.props.w}
borderTopRightRadius: "4px", height={shape.props.h}
display: "flex", onClose={handleClose}
alignItems: "center", onMinimize={handleMinimize}
padding: "0 8px", isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: 'Embed',
props: {
...shape.props,
tags: newTags,
}
})
}} }}
tagsEditable={true}
> >
{controls(shape.props.url)}
</div>
{!shape.props.isMinimized && (
<>
<div style={{ <div style={{
...contentStyle, height: '100%',
height: `${shape.props.h - 80}px`, overflow: 'hidden',
backgroundColor: '#fff',
}}> }}>
<iframe <iframe
src={transformUrl(shape.props.url)} src={transformUrl(url)}
width="100%" width="100%"
height="100%" height="100%"
style={{ border: "none" }} style={{
border: "none",
display: 'block',
}}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onLoad={(e) => { onLoad={(e) => {
// Only add listener if we have a valid iframe
const iframe = e.currentTarget as HTMLIFrameElement const iframe = e.currentTarget as HTMLIFrameElement
if (!iframe) return; if (!iframe) return;
@ -519,55 +475,22 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
window.addEventListener("message", messageHandler) window.addEventListener("message", messageHandler)
// Clean up listener when iframe changes
return () => window.removeEventListener("message", messageHandler) return () => window.removeEventListener("message", messageHandler)
}} }}
/> />
</div> </div>
<div </StandardizedToolWrapper>
style={{ </HTMLContainer>
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px",
height: "40px",
fontSize: "12px",
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: "4px",
position: "absolute",
bottom: 0,
left: 0,
right: 0,
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
marginRight: "8px",
color: "#666",
}}
>
{shape.props.url}
</span>
</div>
</>
)}
</div>
) )
} }
override onDoubleClick = (shape: IEmbedShape) => { override onDoubleClick = (shape: IEmbedShape) => {
// If no URL is set, focus the input field
if (!shape.props.url) { if (!shape.props.url) {
const input = document.querySelector('input') const input = document.querySelector('input')
input?.focus() input?.focus()
return return
} }
// For Medium articles and Twitter profiles that show alternative content
if ( if (
shape.props.url.includes('medium.com') || shape.props.url.includes('medium.com') ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/)) (shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))
@ -576,11 +499,9 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
return return
} }
// For other embeds, enable interaction by temporarily removing pointer-events: none
const iframe = document.querySelector(`[data-shape-id="${shape.id}"] iframe`) as HTMLIFrameElement const iframe = document.querySelector(`[data-shape-id="${shape.id}"] iframe`) as HTMLIFrameElement
if (iframe) { if (iframe) {
iframe.style.pointerEvents = 'all' iframe.style.pointerEvents = 'all'
// Reset pointer-events after interaction
const cleanup = () => { const cleanup = () => {
iframe.style.pointerEvents = 'none' iframe.style.pointerEvents = 'none'
window.removeEventListener('pointerdown', cleanup) window.removeEventListener('pointerdown', cleanup)
@ -589,7 +510,6 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
} }
} }
// Update the pointer down handler
onPointerDown = (shape: IEmbedShape) => { onPointerDown = (shape: IEmbedShape) => {
if (!shape.props.url) { if (!shape.props.url) {
const input = document.querySelector('input') const input = document.querySelector('input')
@ -597,7 +517,6 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
} }
} }
// Add a method to handle URL updates
override onBeforeCreate = (shape: IEmbedShape) => { override onBeforeCreate = (shape: IEmbedShape) => {
if (shape.props.url) { if (shape.props.url) {
const dimensions = getDefaultDimensions(shape.props.url) const dimensions = getDefaultDimensions(shape.props.url)
@ -613,7 +532,6 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
return shape return shape
} }
// Handle URL updates after creation
override onBeforeUpdate = (prev: IEmbedShape, next: IEmbedShape) => { override onBeforeUpdate = (prev: IEmbedShape, next: IEmbedShape) => {
if (next.props.url && prev.props.url !== next.props.url) { if (next.props.url && prev.props.url !== next.props.url) {
const dimensions = getDefaultDimensions(next.props.url) const dimensions = getDefaultDimensions(next.props.url)

View File

@ -1,6 +1,8 @@
import React from 'react' import React, { useState } from 'react'
import MDEditor from '@uiw/react-md-editor' import MDEditor from '@uiw/react-md-editor'
import { BaseBoxShapeUtil, TLBaseShape } from '@tldraw/tldraw' import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from '@tldraw/tldraw'
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
import { usePinnedToView } from '../hooks/usePinnedToView'
export type IMarkdownShape = TLBaseShape< export type IMarkdownShape = TLBaseShape<
'Markdown', 'Markdown',
@ -8,26 +10,55 @@ export type IMarkdownShape = TLBaseShape<
w: number w: number
h: number h: number
text: string text: string
pinnedToView: boolean
tags: string[]
} }
> >
export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> { export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
static type = 'Markdown' as const static type = 'Markdown' as const
// Markdown theme color: Cyan/Teal (Rainbow)
static readonly PRIMARY_COLOR = "#06b6d4"
getDefaultProps(): IMarkdownShape['props'] { getDefaultProps(): IMarkdownShape['props'] {
return { return {
w: 500, w: 500,
h: 400, h: 400,
text: '', text: '',
pinnedToView: false,
tags: ['markdown'],
} }
} }
component(shape: IMarkdownShape) { component(shape: IMarkdownShape) {
// Hooks must be at the top level
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)
const [isMinimized, setIsMinimized] = useState(false)
// Handler function defined before useEffect // Use the pinning hook
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
const handleClose = () => {
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
// Handler function for checkbox interactivity
const handleCheckboxClick = React.useCallback((event: Event) => { const handleCheckboxClick = React.useCallback((event: Event) => {
event.stopPropagation() event.stopPropagation()
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
@ -54,7 +85,7 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
}) })
}, [shape.id, shape.props.text]) }, [shape.id, shape.props.text])
// Single useEffect hook that handles checkbox interactivity // Effect hook that handles checkbox interactivity
React.useEffect(() => { React.useEffect(() => {
if (!isSelected && markdownRef.current) { if (!isSelected && markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]') const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
@ -63,7 +94,6 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
checkbox.addEventListener('click', handleCheckboxClick) checkbox.addEventListener('click', handleCheckboxClick)
}) })
// Cleanup function
return () => { return () => {
if (markdownRef.current) { if (markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]') const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
@ -75,29 +105,43 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
} }
}, [isSelected, shape.props.text, handleCheckboxClick]) }, [isSelected, shape.props.text, handleCheckboxClick])
const wrapperStyle: React.CSSProperties = {
width: '100%',
height: '100%',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden',
}
// Simplified contentStyle - removed padding and center alignment
const contentStyle: React.CSSProperties = {
width: '100%',
height: '100%',
backgroundColor: '#FFFFFF',
cursor: isSelected ? 'text' : 'default',
pointerEvents: 'all',
}
// Show MDEditor when selected // Show MDEditor when selected
if (isSelected) { if (isSelected) {
return ( return (
<div style={wrapperStyle}> <HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<div style={contentStyle}> <StandardizedToolWrapper
title="Markdown"
primaryColor={MarkdownShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div style={{
width: '100%',
height: '100%',
backgroundColor: '#FFFFFF',
pointerEvents: 'all',
overflow: 'hidden',
}}>
<MDEditor <MDEditor
value={shape.props.text} value={shape.props.text}
onChange={(value = '') => { onChange={(value = '') => {
@ -113,8 +157,7 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
preview='live' preview='live'
visibleDragbar={false} visibleDragbar={false}
style={{ style={{
height: 'auto', height: '100%',
minHeight: '100%',
border: 'none', border: 'none',
backgroundColor: 'transparent', backgroundColor: 'transparent',
}} }}
@ -128,8 +171,7 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
style: { style: {
padding: '8px', padding: '8px',
lineHeight: '1.5', lineHeight: '1.5',
height: 'auto', height: '100%',
minHeight: '100%',
resize: 'none', resize: 'none',
backgroundColor: 'transparent', backgroundColor: 'transparent',
} }
@ -139,14 +181,47 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
}} }}
/> />
</div> </div>
</div> </StandardizedToolWrapper>
</HTMLContainer>
) )
} }
// Show rendered markdown when not selected // Show rendered markdown when not selected
return ( return (
<div style={wrapperStyle}> <HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<div style={contentStyle}> <StandardizedToolWrapper
title="Markdown"
primaryColor={MarkdownShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div style={{
width: '100%',
height: '100%',
backgroundColor: '#FFFFFF',
pointerEvents: 'all',
overflow: 'auto',
}}>
<div ref={markdownRef} style={{ width: '100%', height: '100%', padding: '12px' }}> <div ref={markdownRef} style={{ width: '100%', height: '100%', padding: '12px' }}>
{shape.props.text ? ( {shape.props.text ? (
<MDEditor.Markdown source={shape.props.text} /> <MDEditor.Markdown source={shape.props.text} />
@ -155,7 +230,8 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
)} )}
</div> </div>
</div> </div>
</div> </StandardizedToolWrapper>
</HTMLContainer>
) )
} }

View File

@ -0,0 +1,493 @@
import React, { useState, useEffect, useRef } from 'react'
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from '@tldraw/tldraw'
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
import { usePinnedToView } from '../hooks/usePinnedToView'
export type IMultmuxShape = TLBaseShape<
'Multmux',
{
w: number
h: number
sessionId: string
sessionName: string
token: string
serverUrl: string
wsUrl: string
pinnedToView: boolean
tags: string[]
}
>
interface SessionResponse {
id: string
name: string
token: string
}
export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
static type = 'Multmux' as const
// Terminal theme color: Dark purple/violet
static readonly PRIMARY_COLOR = "#8b5cf6"
getDefaultProps(): IMultmuxShape['props'] {
return {
w: 800,
h: 600,
sessionId: '',
sessionName: 'New Terminal',
token: '',
serverUrl: 'http://localhost:3000',
wsUrl: 'ws://localhost:3001',
pinnedToView: false,
tags: ['terminal', 'multmux'],
}
}
component(shape: IMultmuxShape) {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [isMinimized, setIsMinimized] = useState(false)
const [ws, setWs] = useState<WebSocket | null>(null)
const [output, setOutput] = useState<string[]>([])
const [input, setInput] = useState('')
const [connected, setConnected] = useState(false)
const terminalRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Use the pinning hook
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
const handleClose = () => {
if (ws) {
ws.close()
}
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
// WebSocket connection
useEffect(() => {
if (!shape.props.token || !shape.props.wsUrl) {
return
}
const websocket = new WebSocket(`${shape.props.wsUrl}?token=${shape.props.token}`)
websocket.onopen = () => {
setConnected(true)
setOutput(prev => [...prev, '✓ Connected to terminal session'])
}
websocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
switch (message.type) {
case 'output':
setOutput(prev => [...prev, message.data])
break
case 'joined':
setOutput(prev => [...prev, `✓ Joined session: ${message.sessionName}`])
break
case 'presence':
if (message.data.action === 'join') {
setOutput(prev => [...prev, `→ User joined (${message.data.totalClients} total)`])
} else if (message.data.action === 'leave') {
setOutput(prev => [...prev, `← User left (${message.data.totalClients} total)`])
}
break
case 'error':
setOutput(prev => [...prev, `✗ Error: ${message.message}`])
break
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
websocket.onerror = (error) => {
console.error('WebSocket error:', error)
setOutput(prev => [...prev, '✗ Connection error'])
setConnected(false)
}
websocket.onclose = () => {
setConnected(false)
setOutput(prev => [...prev, '✗ Connection closed'])
}
setWs(websocket)
return () => {
websocket.close()
}
}, [shape.props.token, shape.props.wsUrl])
// Auto-scroll terminal output
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight
}
}, [output])
const handleInputSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!input || !ws || !connected) return
// Send input to terminal
ws.send(JSON.stringify({
type: 'input',
data: input + '\n',
timestamp: Date.now(),
}))
setInput('')
}
const handleCreateSession = async () => {
try {
const response = await fetch(`${shape.props.serverUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: shape.props.sessionName || 'Canvas Terminal',
}),
})
if (!response.ok) {
throw new Error('Failed to create session')
}
const session: SessionResponse = await response.json()
// Update shape with session details
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
sessionId: session.id,
sessionName: session.name,
token: session.token,
},
})
setOutput(prev => [...prev, `✓ Created session: ${session.name}`])
} catch (error) {
console.error('Failed to create session:', error)
setOutput(prev => [...prev, `✗ Failed to create session: ${error}`])
}
}
// If no token, show setup UI
if (!shape.props.token) {
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<StandardizedToolWrapper
title="mulTmux Terminal"
primaryColor={MultmuxShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div style={{
width: '100%',
height: '100%',
backgroundColor: '#1e1e2e',
color: '#cdd6f4',
padding: '20px',
fontFamily: 'monospace',
pointerEvents: 'all',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}>
<h3 style={{ margin: 0, color: '#cba6f7' }}>Setup mulTmux Terminal</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label>
Session Name:
<input
type="text"
value={shape.props.sessionName}
onChange={(e) => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
sessionName: e.target.value,
},
})
}}
style={{
width: '100%',
padding: '8px',
marginTop: '4px',
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: '4px',
color: '#cdd6f4',
fontFamily: 'monospace',
}}
placeholder="Canvas Terminal"
/>
</label>
<label>
Server URL:
<input
type="text"
value={shape.props.serverUrl}
onChange={(e) => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
serverUrl: e.target.value,
},
})
}}
style={{
width: '100%',
padding: '8px',
marginTop: '4px',
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: '4px',
color: '#cdd6f4',
fontFamily: 'monospace',
}}
placeholder="http://localhost:3000"
/>
</label>
<label>
WebSocket URL:
<input
type="text"
value={shape.props.wsUrl}
onChange={(e) => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
wsUrl: e.target.value,
},
})
}}
style={{
width: '100%',
padding: '8px',
marginTop: '4px',
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: '4px',
color: '#cdd6f4',
fontFamily: 'monospace',
}}
placeholder="ws://localhost:3001"
/>
</label>
<button
onClick={handleCreateSession}
style={{
padding: '12px',
backgroundColor: '#8b5cf6',
border: 'none',
borderRadius: '4px',
color: 'white',
fontWeight: 'bold',
cursor: 'pointer',
fontFamily: 'monospace',
}}
onPointerDown={(e) => e.stopPropagation()}
>
Create New Session
</button>
<div style={{ marginTop: '16px', fontSize: '12px', opacity: 0.8 }}>
<p>Or paste a session token:</p>
<input
type="text"
placeholder="Paste token here..."
onPaste={(e) => {
const token = e.clipboardData.getData('text')
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
token: token.trim(),
},
})
}}
style={{
width: '100%',
padding: '8px',
marginTop: '4px',
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: '4px',
color: '#cdd6f4',
fontFamily: 'monospace',
}}
/>
</div>
</div>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
// Show terminal UI when connected
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<StandardizedToolWrapper
title={`mulTmux: ${shape.props.sessionName}`}
primaryColor={MultmuxShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div style={{
width: '100%',
height: '100%',
backgroundColor: '#1e1e2e',
color: '#cdd6f4',
fontFamily: 'monospace',
fontSize: '14px',
pointerEvents: 'all',
display: 'flex',
flexDirection: 'column',
}}>
{/* Status bar */}
<div style={{
padding: '8px 12px',
backgroundColor: '#313244',
borderBottom: '1px solid #45475a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span>
{connected ? '🟢 Connected' : '🔴 Disconnected'}
</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>
Session: {shape.props.sessionId.slice(0, 8)}...
</span>
</div>
{/* Terminal output */}
<div
ref={terminalRef}
style={{
flex: 1,
padding: '12px',
overflowY: 'auto',
overflowX: 'hidden',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{output.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
{/* Input area */}
<form onSubmit={handleInputSubmit} style={{ display: 'flex', borderTop: '1px solid #45475a' }}>
<span style={{ padding: '8px 12px', backgroundColor: '#313244', color: '#89b4fa' }}>$</span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={!connected}
placeholder={connected ? "Type command..." : "Not connected"}
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: '#313244',
border: 'none',
color: '#cdd6f4',
fontFamily: 'monospace',
outline: 'none',
}}
onPointerDown={(e) => e.stopPropagation()}
/>
</form>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
indicator(shape: IMultmuxShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
override onDoubleClick = (shape: IMultmuxShape) => {
// Focus input on double click
setTimeout(() => {
const input = document.querySelector(`[data-shape-id="${shape.id}"] input[type="text"]`) as HTMLInputElement
input?.focus()
}, 0)
}
}

View File

@ -14,6 +14,8 @@ import { AI_PERSONALITIES } from "@/lib/settings"
import { isShapeOfType } from "@/propagators/utils" import { isShapeOfType } from "@/propagators/utils"
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils" import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
import React, { useState } from "react" import React, { useState } from "react"
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
import { usePinnedToView } from "../hooks/usePinnedToView"
type IPrompt = TLBaseShape< type IPrompt = TLBaseShape<
"Prompt", "Prompt",
@ -25,6 +27,8 @@ type IPrompt = TLBaseShape<
agentBinding: string | null agentBinding: string | null
personality?: string personality?: string
error?: string | null error?: string | null
pinnedToView: boolean
tags: string[]
} }
> >
@ -44,6 +48,9 @@ const CheckIcon = () => (
export class PromptShape extends BaseBoxShapeUtil<IPrompt> { export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
static override type = "Prompt" as const static override type = "Prompt" as const
// LLM Prompt theme color: Pink/Magenta (Rainbow)
static readonly PRIMARY_COLOR = "#ec4899"
FIXED_HEIGHT = 500 as const FIXED_HEIGHT = 500 as const
MIN_WIDTH = 200 as const MIN_WIDTH = 200 as const
PADDING = 4 as const PADDING = 4 as const
@ -55,6 +62,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
prompt: "", prompt: "",
value: "", value: "",
agentBinding: null, agentBinding: null,
pinnedToView: false,
tags: ['llm', 'prompt'],
} }
} }
@ -358,15 +367,63 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [isHovering, setIsHovering] = useState(false) const [isHovering, setIsHovering] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
// Use the pinning hook
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
const handleClose = () => {
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
return ( return (
<HTMLContainer <HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<StandardizedToolWrapper
title="LLM Prompt"
primaryColor={PromptShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: 'Prompt',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div
style={{ style={{
borderRadius: 6, height: '100%',
border: "1px solid lightgrey", width: '100%',
padding: this.PADDING, padding: this.PADDING,
height: this.FIXED_HEIGHT,
width: shape.props.w,
pointerEvents: isSelected || isHovering ? "all" : "none", pointerEvents: isSelected || isHovering ? "all" : "none",
backgroundColor: "#efefef", backgroundColor: "#efefef",
overflow: "visible", overflow: "visible",
@ -676,6 +733,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
{copyButtonText} {copyButtonText}
</button> </button>
</div> </div>
</div>
</StandardizedToolWrapper>
</HTMLContainer> </HTMLContainer>
) )
} }

11
src/tools/MultmuxTool.ts Normal file
View File

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

View File

@ -17,6 +17,20 @@ import { HolonData } from "../lib/HoloSphereService"
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
// Dark mode utilities
const getDarkMode = (): boolean => {
const stored = localStorage.getItem('darkMode')
if (stored !== null) {
return stored === 'true'
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
const setDarkMode = (isDark: boolean) => {
localStorage.setItem('darkMode', String(isDark))
document.documentElement.classList.toggle('dark', isDark)
}
export function CustomToolbar() { export function CustomToolbar() {
const editor = useEditor() const editor = useEditor()
const tools = useTools() const tools = useTools()
@ -34,6 +48,18 @@ export function CustomToolbar() {
const [fathomApiKeyInput, setFathomApiKeyInput] = useState('') const [fathomApiKeyInput, setFathomApiKeyInput] = useState('')
const [hasFathomApiKey, setHasFathomApiKey] = useState(false) const [hasFathomApiKey, setHasFathomApiKey] = useState(false)
const profilePopupRef = useRef<HTMLDivElement>(null) const profilePopupRef = useRef<HTMLDivElement>(null)
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
// Initialize dark mode on mount
useEffect(() => {
setDarkMode(isDarkMode)
}, [])
const toggleDarkMode = () => {
const newMode = !isDarkMode
setIsDarkMode(newMode)
setDarkMode(newMode)
}
useEffect(() => { useEffect(() => {
if (editor && tools) { if (editor && tools) {
@ -545,6 +571,42 @@ export function CustomToolbar() {
alignItems: "center", alignItems: "center",
}} }}
> >
{/* Dark/Light Mode Toggle */}
<button
onClick={toggleDarkMode}
style={{
padding: "4px 8px",
borderRadius: "4px",
background: "#6B7280",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: "6px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#4B5563"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#6B7280"
}}
title={isDarkMode ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
<span style={{ fontSize: "14px" }}>
{isDarkMode ? "☀️" : "🌙"}
</span>
</button>
<LoginButton className="toolbar-login-button" /> <LoginButton className="toolbar-login-button" />
<StarBoardButton className="toolbar-star-button" /> <StarBoardButton className="toolbar-star-button" />
@ -891,7 +953,7 @@ export function CustomToolbar() {
</div> </div>
<a <a
href="/dashboard" href="/dashboard/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ style={{
@ -1015,7 +1077,7 @@ export function CustomToolbar() {
<TldrawUiMenuItem <TldrawUiMenuItem
{...tools["Prompt"]} {...tools["Prompt"]}
icon="prompt" icon="prompt"
label="Prompt" label="LLM Prompt"
isSelected={tools["Prompt"].id === editor.getCurrentToolId()} isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/> />
)} )}
@ -1051,6 +1113,14 @@ export function CustomToolbar() {
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()} isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
/> />
)} )}
{tools["Multmux"] && (
<TldrawUiMenuItem
{...tools["Multmux"]}
icon="terminal"
label="Terminal"
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
/>
)}
{/* Share Location tool removed for now */} {/* Share Location tool removed for now */}
{/* Refresh All ObsNotes Button */} {/* Refresh All ObsNotes Button */}
{(() => { {(() => {

View File

@ -145,7 +145,7 @@ export const overrides: TLUiOverrides = {
Prompt: { Prompt: {
id: "Prompt", id: "Prompt",
icon: "prompt", icon: "prompt",
label: "Prompt", label: "LLM Prompt",
type: "Prompt", type: "Prompt",
kbd: "alt+l", kbd: "alt+l",
readonlyOk: true, readonlyOk: true,

View File

@ -138,14 +138,25 @@ export class AutomergeDurableObject {
const { documentId } = (await request.json()) as { documentId: string } const { documentId } = (await request.json()) as { documentId: string }
// CRITICAL: Only set the document ID if one doesn't already exist
// This prevents race conditions where multiple clients try to set different document IDs
let actualDocumentId: string = documentId
await this.ctx.blockConcurrencyWhile(async () => { await this.ctx.blockConcurrencyWhile(async () => {
if (!this.automergeDocumentId) {
// No document ID exists yet, use the one provided by the client
await this.ctx.storage.put("automergeDocumentId", documentId) await this.ctx.storage.put("automergeDocumentId", documentId)
this.automergeDocumentId = documentId this.automergeDocumentId = documentId
actualDocumentId = documentId
console.log(`📝 Stored NEW document ID ${documentId} for room ${this.roomId}`)
} else {
// Document ID already exists, return the existing one
actualDocumentId = this.automergeDocumentId
console.log(`⚠️ Document ID already exists for room ${this.roomId}, returning existing: ${actualDocumentId}`)
}
}) })
console.log(`📝 Stored document ID ${documentId} for room ${this.roomId}`) return new Response(JSON.stringify({ success: true, documentId: actualDocumentId }), {
return new Response(JSON.stringify({ success: true, documentId }), {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*", "Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
@ -394,6 +405,18 @@ export class AutomergeDurableObject {
// Handle document state request from worker (for persistence) // Handle document state request from worker (for persistence)
await this.handleDocumentStateRequest(sessionId) await this.handleDocumentStateRequest(sessionId)
break break
case "presence":
// Handle presence updates (cursors, selections)
// Broadcast to all other clients but don't persist
console.log(`📍 Received presence update from ${sessionId}, user: ${message.userId}`)
// Add senderId so clients can filter out echoes
const presenceMessage = {
...message,
senderId: sessionId
}
this.broadcastToOthers(sessionId, presenceMessage)
break
default: default:
console.log("Unknown message type:", message.type) console.log("Unknown message type:", message.type)
} }
@ -477,32 +500,53 @@ export class AutomergeDurableObject {
} }
} }
// Generate a simple hash of the document state for change detection // Generate a fast hash of the document state for change detection
// OPTIMIZED: Instead of JSON.stringify on entire document (expensive for large docs),
// we hash based on record IDs, types, and metadata only
private generateDocHash(doc: any): string { 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 storeData = doc.store || {}
const storeKeys = Object.keys(storeData).sort() const storeKeys = Object.keys(storeData).sort()
// CRITICAL FIX: JSON.stringify's second parameter when it's an array is a replacer // Fast hash: combine record count + sorted key fingerprint + metadata
// that only includes those properties. We need to stringify the entire store object. let hash = storeKeys.length // Start with record count
// To ensure stable ordering, create a new object with sorted keys
const sortedStore: any = {}
for (const key of storeKeys) {
sortedStore[key] = storeData[key]
}
const storeString = JSON.stringify(sortedStore)
// Simple hash function (you could use a more sophisticated one if needed) // Hash the record IDs and key metadata (much faster than stringifying full records)
let hash = 0 for (let i = 0; i < storeKeys.length; i++) {
for (let i = 0; i < storeString.length; i++) { const key = storeKeys[i]
const char = storeString.charCodeAt(i) const record = storeData[key]
// Hash the record ID
for (let j = 0; j < key.length; j++) {
const char = key.charCodeAt(j)
hash = ((hash << 5) - hash) + char hash = ((hash << 5) - hash) + char
hash = hash & hash // Convert to 32-bit integer hash = hash & hash // Convert to 32-bit integer
} }
// Include record type and metadata for better change detection
if (record) {
// Hash typeName if available
if (record.typeName) {
for (let j = 0; j < record.typeName.length; j++) {
hash = ((hash << 5) - hash) + record.typeName.charCodeAt(j)
hash = hash & hash
}
}
// Hash key properties for better collision resistance
// Use index, x/y for shapes, parentId for common records
if (record.index !== undefined) {
hash = ((hash << 5) - hash) + (typeof record.index === 'string' ? record.index.length : record.index)
hash = hash & hash
}
if (record.x !== undefined && record.y !== undefined) {
hash = ((hash << 5) - hash) + Math.floor(record.x + record.y)
hash = hash & hash
}
}
}
const hashString = hash.toString() const hashString = hash.toString()
console.log(`Server generated hash:`, { console.log(`Server generated hash (optimized):`, {
storeStringLength: storeString.length,
hash: hashString, hash: hashString,
storeKeys: storeKeys.length, storeKeys: storeKeys.length,
sampleKeys: storeKeys.slice(0, 3) sampleKeys: storeKeys.slice(0, 3)
@ -1181,12 +1225,17 @@ export class AutomergeDurableObject {
} }
// Ensure other required shape properties exist // Ensure other required shape properties exist
// CRITICAL: Check for undefined, null, or non-number values (including NaN) // CRITICAL: Preserve original coordinates - only reset if truly missing or invalid
// Log when coordinates are being reset to help debug frame children coordinate issues
const originalX = record.x
const originalY = record.y
if (record.x === undefined || record.x === null || typeof record.x !== 'number' || isNaN(record.x)) { if (record.x === undefined || record.x === null || typeof record.x !== 'number' || isNaN(record.x)) {
console.log(`🔧 Server: Resetting X coordinate for shape ${record.id} (type: ${record.type}, parentId: ${record.parentId}). Original value:`, originalX)
record.x = 0 record.x = 0
needsUpdate = true needsUpdate = true
} }
if (record.y === undefined || record.y === null || typeof record.y !== 'number' || isNaN(record.y)) { if (record.y === undefined || record.y === null || typeof record.y !== 'number' || isNaN(record.y)) {
console.log(`🔧 Server: Resetting Y coordinate for shape ${record.id} (type: ${record.type}, parentId: ${record.parentId}). Original value:`, originalY)
record.y = 0 record.y = 0
needsUpdate = true needsUpdate = true
} }
@ -1202,8 +1251,12 @@ export class AutomergeDurableObject {
record.meta = {} record.meta = {}
needsUpdate = true needsUpdate = true
} }
// Validate and fix index property - must be a valid IndexKey (like 'a1', 'a2', etc.) // CRITICAL: IndexKey must follow tldraw's fractional indexing format
if (!record.index || typeof record.index !== 'string' || !/^[a-z]\d+$/.test(record.index)) { // Valid format: starts with 'a' followed by digits, optionally followed by uppercase letters
// Examples: "a1", "a2", "a10", "a1V" (fractional between a1 and a2)
// Invalid: "c1", "b1", "z999" (must start with 'a')
if (!record.index || typeof record.index !== 'string' || !/^a\d+[A-Z]*$/.test(record.index)) {
console.log(`🔧 Server: Fixing invalid index "${record.index}" to "a1" for shape ${record.id}`)
record.index = 'a1' // Required index property for all shapes - must be valid IndexKey format record.index = 'a1' // Required index property for all shapes - must be valid IndexKey format
needsUpdate = true needsUpdate = true
} }