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 1aec51e97b
commit 7eb60ebcf2
32 changed files with 3279 additions and 919 deletions

View File

@ -1,14 +1,25 @@
# Cloudflare Pages redirects and rewrites
# This file handles SPA routing and URL rewrites (replaces vercel.json rewrites)
# SPA fallback - all routes should serve index.html
# 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
/inbox /index.html 200
/inbox/ /index.html 200
/contact /index.html 200
/contact/ /index.html 200
/presentations /index.html 200
/presentations/ /index.html 200
/presentations/* /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
# Specific route rewrites (matching vercel.json)
/board/* /index.html 200
/board /index.html 200
/inbox /index.html 200
/contact /index.html 200
/presentations /index.html 200
/dashboard /index.html 200

View File

@ -51,11 +51,11 @@ export class TerminalHandler {
ws.send(JSON.stringify(message));
};
terminal.onData(onData);
const dataListener = terminal.onData(onData);
// Clean up on disconnect
ws.on('close', () => {
terminal.off('data', onData);
dataListener.dispose();
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 "@/css/style.css"
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 { Board } from "./routes/Board"
import { Inbox } from "./routes/Inbox"
@ -67,6 +67,14 @@ const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
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
*/
@ -101,46 +109,56 @@ const AppWithProviders = () => {
<NotificationsDisplay />
<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 */}
<Route path="/login" element={<AuthPage />} />
<Route path="/login/" element={<AuthPage />} />
{/* Optional auth routes */}
<Route path="/" element={
<OptionalAuthRoute>
<Default />
</OptionalAuthRoute>
} />
<Route path="/contact" element={
<Route path="/contact/" element={
<OptionalAuthRoute>
<Contact />
</OptionalAuthRoute>
} />
<Route path="/board/:slug" element={
<Route path="/board/:slug/" element={
<OptionalAuthRoute>
<Board />
</OptionalAuthRoute>
} />
<Route path="/inbox" element={
<Route path="/inbox/" element={
<OptionalAuthRoute>
<Inbox />
</OptionalAuthRoute>
} />
<Route path="/debug" element={
<Route path="/debug/" element={
<OptionalAuthRoute>
<CryptoDebug />
</OptionalAuthRoute>
} />
<Route path="/dashboard" element={
<Route path="/dashboard/" element={
<OptionalAuthRoute>
<Dashboard />
</OptionalAuthRoute>
} />
<Route path="/presentations" element={
<Route path="/presentations/" element={
<OptionalAuthRoute>
<Presentations />
</OptionalAuthRoute>
} />
<Route path="/presentations/resilience" element={
<Route path="/presentations/resilience/" element={
<OptionalAuthRoute>
<Resilience />
</OptionalAuthRoute>

View File

@ -28,10 +28,11 @@ export function applyAutomergePatchesToTLStore(
const existingRecord = getRecordFromStore(store, id)
// CRITICAL: For shapes, get coordinates from store's current state BEFORE any patch processing
// This ensures we preserve coordinates even if patches don't include them
// CRITICAL: For shapes, get coordinates and parentId from store's current state BEFORE any patch processing
// 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
let storeCoordinates: { x?: number; y?: number } = {}
let storeParentId: string | undefined = undefined
if (existingRecord && existingRecord.typeName === 'shape') {
const storeX = (existingRecord as any).x
const storeY = (existingRecord as any).y
@ -41,15 +42,21 @@ export function applyAutomergePatchesToTLStore(
if (typeof storeY === 'number' && !isNaN(storeY) && storeY !== null && storeY !== undefined) {
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
// This prevents coordinates from defaulting to 0,0 when patches create new records
let automergeRecord: any = null
let automergeParentId: string | undefined = undefined
if (!existingRecord && automergeDoc && automergeDoc.store && automergeDoc.store[id]) {
try {
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') {
const docX = automergeRecord.x
const docY = automergeRecord.y
@ -59,6 +66,10 @@ export function applyAutomergePatchesToTLStore(
if (typeof docY === 'number' && !isNaN(docY) && docY !== null && docY !== undefined) {
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) {
// If we can't read from Automerge doc, continue without it
@ -324,6 +335,22 @@ export function applyAutomergePatchesToTLStore(
} 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
@ -371,6 +398,18 @@ export function applyAutomergePatchesToTLStore(
// put / remove the records in the store
// Log patch application for debugging
console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`)
// 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) {
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)
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.props || typeof sanitized.props !== 'object') sanitized.props = {}

View File

@ -166,6 +166,7 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private websocket: WebSocket | null = null
private roomId: string | null = null
public peerId: PeerId | undefined = undefined
public sessionId: string | null = null // Track our session ID
private readyPromise: Promise<void>
private readyResolve: (() => void) | null = null
private keepAliveInterval: NodeJS.Timeout | null = null
@ -175,12 +176,19 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private reconnectDelay: number = 1000
private isConnecting: boolean = false
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()
this.workerUrl = workerUrl
this.roomId = roomId || 'default-room'
this.onJsonSyncData = onJsonSyncData
this.onPresenceUpdate = onPresenceUpdate
this.readyPromise = new Promise((resolve) => {
this.readyResolve = resolve
})
@ -209,11 +217,13 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
// Use the room ID from constructor or default
// Add sessionId as a query parameter as required by AutomergeDurableObject
const sessionId = peerId || `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
this.sessionId = sessionId // Store our session ID for filtering echoes
// Convert https:// to wss:// or http:// to ws://
const protocol = this.workerUrl.startsWith('https://') ? 'wss://' : 'ws://'
const baseUrl = this.workerUrl.replace(/^https?:\/\//, '')
const wsUrl = `${protocol}${baseUrl}/connect/${this.roomId}?sessionId=${sessionId}`
this.isConnecting = true
// Add a small delay to ensure the server is ready
@ -267,12 +277,21 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
this.sendPong()
return
}
// Handle test messages
if (message.type === 'test') {
console.log('🔌 CloudflareAdapter: Received test message:', message.message)
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
if (message.type === 'sync' && message.data) {
@ -283,14 +302,20 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
documentIdType: typeof message.documentId
})
// JSON sync is deprecated - all data flows through Automerge sync protocol
// Old format content is converted server-side and saved to R2 in Automerge format
// Skip JSON sync messages - they should not be sent anymore
// JSON sync for real-time collaboration
// When we receive TLDraw changes from other clients, apply them locally
const isJsonDocumentData = message.data && typeof message.data === 'object' && message.data.store
if (isJsonDocumentData) {
console.warn('⚠️ CloudflareAdapter: Received JSON sync message (deprecated). Ignoring - all data should flow through Automerge sync protocol.')
return // Don't process JSON sync messages
console.log('📥 CloudflareAdapter: Received JSON sync message with store data')
// 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
@ -376,6 +401,18 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
}
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) {
// Check if this is a binary sync message from Automerge Repo
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)
} else {
// Handle text-based messages (backward compatibility and control messages)
console.log('📤 Sending WebSocket message:', message.type)
// Only log non-presence messages
if (message.type !== 'presence') {
console.log('📤 Sending WebSocket message:', message.type)
}
// Debug: Log patch content if it's a patch message
if (message.type === 'patch' && (message as any).patches) {
console.log('🔍 Sending patches:', (message as any).patches.length, 'patches')
@ -411,10 +451,12 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
this.websocket.send(JSON.stringify(message))
}
} else {
console.warn('⚠️ CloudflareAdapter: Cannot send message - WebSocket not open', {
messageType: message.type,
readyState: this.websocket?.readyState
})
if (message.type !== 'presence') {
console.warn('⚠️ CloudflareAdapter: Cannot send message - WebSocket not open', {
messageType: message.type,
readyState: this.websocket?.readyState
})
}
}
}

View File

@ -20,8 +20,11 @@ function minimalSanitizeRecord(record: any): any {
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {}
// Validate and fix index property - must be a valid IndexKey (like 'a1', 'a2', etc.)
if (!sanitized.index || typeof sanitized.index !== 'string' || !/^[a-z]\d+$/.test(sanitized.index)) {
// 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'

View File

@ -6,12 +6,13 @@ import {
RecordsDiff,
} from "@tldraw/tldraw"
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 {
useLocalAwareness,
useRemoteAwareness,
} from "@automerge/automerge-repo-react-hooks"
import throttle from "lodash.throttle"
import { applyAutomergePatchesToTLStore, sanitizeRecord } from "./AutomergeToTLStore.js"
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
@ -128,11 +129,13 @@ import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeU
export function useAutomergeStoreV2({
handle,
userId: _userId,
adapter,
}: {
handle: DocHandle<any>
userId: string
adapter?: any
}): 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
const customSchema = createTLSchema({
@ -202,67 +205,46 @@ export function useAutomergeStoreV2({
const unsubs: (() => void)[] = []
// A hacky workaround to prevent local changes from being applied twice
// once into the automerge doc and then back again.
// Track local changes to prevent echoing them back
// 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
// Helper function to manually trigger sync after document changes
// The Automerge Repo doesn't auto-broadcast because our WebSocket setup doesn't use peer discovery
const triggerSync = () => {
try {
console.log('🔄 triggerSync() called')
const repo = (handle as any).repo
console.log('🔍 repo:', !!repo, 'handle:', !!handle, 'documentId:', handle?.documentId)
// Helper function to broadcast changes via JSON sync
// DISABLED: This causes last-write-wins conflicts
// Automerge should handle sync automatically via binary protocol
// We're keeping this function but disabling all actual broadcasting
const broadcastJsonSync = (changedRecords: any[]) => {
// TEMPORARY FIX: Manually broadcast changes via WebSocket since Automerge Repo sync isn't working
// This sends the full changed records as JSON to other clients
// TODO: Fix Automerge Repo's binary sync protocol to work properly
if (repo) {
console.log('🔍 repo.networkSubsystem:', !!repo.networkSubsystem)
console.log('🔍 repo.networkSubsystem.syncDoc:', typeof repo.networkSubsystem?.syncDoc)
console.log('🔍 repo.networkSubsystem.adapters:', !!repo.networkSubsystem?.adapters)
if (!changedRecords || changedRecords.length === 0) {
return
}
// Try multiple approaches to trigger sync
console.log(`📤 Broadcasting ${changedRecords.length} changed records via manual JSON 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 sync to all network adapters')
const adapters = Array.from(repo.networkSubsystem.adapters.values())
console.log('🔍 Found adapters:', adapters.length)
adapters.forEach((adapter: any) => {
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',
documentId: handle.documentId,
data: handle.doc()
})
}
})
}
// 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 {
console.warn('⚠️ No repo found on handle')
}
} catch (error) {
console.error('❌ Error triggering manual sync:', error)
if (adapter && typeof (adapter as any).send === 'function') {
// Send changes to other clients via the network adapter
(adapter as any).send({
type: 'sync',
data: {
store: Object.fromEntries(changedRecords.map(r => [r.id, r]))
},
documentId: handle?.documentId,
timestamp: Date.now()
})
} else {
console.warn('⚠️ Cannot broadcast - adapter not available')
}
}
// Listen for changes from Automerge and apply them to TLDraw
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) {
isLocalChange = false
return
@ -449,7 +431,7 @@ export function useAutomergeStoreV2({
// Throttle position-only updates (x/y changes) to reduce automerge saves during movement
let positionUpdateQueue: RecordsDiff<TLRecord> | 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 = () => {
if (positionUpdateQueue && handle) {
@ -464,13 +446,14 @@ export function useAutomergeStoreV2({
applyTLStoreChangesToAutomerge(doc, queuedChanges)
})
// Trigger sync to broadcast position updates
triggerSync()
setTimeout(() => {
isLocalChange = false
}, 100)
const changedRecords = [
...Object.values(queuedChanges.added || {}),
...Object.values(queuedChanges.updated || {}),
...Object.values(queuedChanges.removed || {})
]
broadcastJsonSync(changedRecords)
} catch (error) {
console.error("Error applying throttled position updates to Automerge:", error)
isLocalChange = false
}
})
}
@ -855,7 +838,14 @@ export function useAutomergeStoreV2({
if (filteredTotalChanges === 0) {
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
// 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
@ -989,7 +979,39 @@ export function useAutomergeStoreV2({
// Check if this is a position-only update that should be throttled
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) {
// Start a new queue for position updates
positionUpdateQueue = finalFilteredChanges
@ -1046,9 +1068,9 @@ export function useAutomergeStoreV2({
if (positionUpdateQueue) {
flushPositionUpdates()
}
// 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
// This ensures that all shapes (notes, rectangles, etc.) are consistently persisted
@ -1106,13 +1128,14 @@ export function useAutomergeStoreV2({
applyTLStoreChangesToAutomerge(doc, queuedChanges)
})
// Trigger sync to broadcast eraser changes
triggerSync()
setTimeout(() => {
isLocalChange = false
}, 100)
const changedRecords = [
...Object.values(queuedChanges.added || {}),
...Object.values(queuedChanges.updated || {}),
...Object.values(queuedChanges.removed || {})
]
broadcastJsonSync(changedRecords)
} catch (error) {
console.error('❌ Error applying queued eraser changes:', error)
isLocalChange = false
}
}
}, 50) // Check every 50ms for faster response
@ -1136,17 +1159,19 @@ export function useAutomergeStoreV2({
updated: { ...(queuedChanges.updated || {}), ...(finalFilteredChanges.updated || {}) },
removed: { ...(queuedChanges.removed || {}), ...(finalFilteredChanges.removed || {}) }
}
requestAnimationFrame(() => {
isLocalChange = true
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, mergedChanges)
})
// Trigger sync to broadcast merged changes
triggerSync()
setTimeout(() => {
isLocalChange = false
}, 100)
const changedRecords = [
...Object.values(mergedChanges.added || {}),
...Object.values(mergedChanges.updated || {}),
...Object.values(mergedChanges.removed || {})
]
broadcastJsonSync(changedRecords)
})
return
@ -1154,22 +1179,21 @@ export function useAutomergeStoreV2({
// OPTIMIZED: Use requestIdleCallback to defer Automerge changes when browser is idle
// This prevents blocking mouse interactions without queuing changes
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
handle.change((doc) => {
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
requestAnimationFrame(triggerSync)
// Reset flag after a short delay to allow Automerge change handler to process
// This prevents feedback loops while ensuring all changes are saved
setTimeout(() => {
isLocalChange = false
}, 100)
const changedRecords = [
...Object.values(finalFilteredChanges.added || {}),
...Object.values(finalFilteredChanges.updated || {}),
...Object.values(finalFilteredChanges.removed || {})
]
requestAnimationFrame(() => broadcastJsonSync(changedRecords))
}
// Use requestIdleCallback if available to apply changes when browser is idle
@ -1192,8 +1216,6 @@ export function useAutomergeStoreV2({
const docAfter = handle.doc()
} catch (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) => {
applyTLStoreChangesToAutomerge(doc, queuedChanges)
})
setTimeout(() => {
isLocalChange = false
}, 100)
}
}
}
@ -1403,23 +1422,73 @@ export function useAutomergePresence(params: {
name: string
color: string
}
adapter?: any
}) {
const { handle, store, userMetadata } = params
// Simple presence implementation
useEffect(() => {
if (!handle || !store) return
const { handle, store, userMetadata, adapter } = params
const presenceRef = useRef<Map<string, any>>(new Map())
const updatePresence = () => {
// Basic presence update logic
console.log("Updating presence for user:", userMetadata.userId)
// Broadcast local presence to other clients
useEffect(() => {
if (!handle || !store || !adapter) {
return
}
updatePresence()
}, [handle, store, userMetadata])
// Listen for changes to instance_presence records in the store
// 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 {
updatePresence: () => {},
presence: {},
presence: presenceRef.current,
}
}

View File

@ -1,5 +1,5 @@
import { useMemo, useEffect, useState, useCallback, useRef } from "react"
import { TLStoreSnapshot } from "@tldraw/tldraw"
import { TLStoreSnapshot, InstancePresenceRecordType } from "@tldraw/tldraw"
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
import { TLStoreWithStatus } from "@tldraw/tldraw"
@ -35,6 +35,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const [isLoading, setIsLoading] = useState(true)
const handleRef = useRef<any>(null)
const storeRef = useRef<any>(null)
const adapterRef = useRef<any>(null)
const lastSentHashRef = useRef<string | null>(null)
const isMouseActiveRef = useRef<boolean>(false)
const pendingSaveRef = useRef<boolean>(false)
@ -91,17 +92,120 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
handleRef.current = handle
}, [handle])
// JSON sync is deprecated - all data now flows through Automerge sync protocol
// Old format content is converted server-side and saved to R2 in Automerge format
// This callback is kept for backwards compatibility but should not be used
const applyJsonSyncData = useCallback((_data: TLStoreSnapshot) => {
console.warn('⚠️ JSON sync callback called but JSON sync is deprecated. All data should flow through Automerge sync protocol.')
// Don't apply JSON sync - let Automerge sync handle everything
return
// JSON sync callback - receives changed records from other clients
// Apply to Automerge document which will emit patches to update the store
const applyJsonSyncData = useCallback((data: TLStoreSnapshot) => {
const currentHandle = handleRef.current
if (!currentHandle || !data?.store) {
console.warn('⚠️ Cannot apply JSON sync - no handle or data')
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 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({
network: [adapter],
// Enable sharing of all documents with all peers
@ -114,7 +218,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
})
return { repo, adapter }
}, [workerUrl, roomId, applyJsonSyncData])
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate])
// Initialize Automerge document handle
useEffect(() => {
@ -184,65 +288,18 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
// Continue anyway - user can still create new content
}
console.log("Found/Created Automerge handle via Repo:", {
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`)
}
// Verify final document state
const finalDoc = handle.doc() as any
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
console.log("Automerge handle initialized:", {
console.log("✅ Automerge handle initialized and ready:", {
handleId: handle.documentId,
isReady: handle.isReady(),
hasDoc: !!finalDoc,
storeKeys: finalStoreKeys,
shapeCount: finalShapeCount
shapeCount: finalShapeCount,
roomId: roomId
})
setHandle(handle)
@ -260,6 +317,10 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return () => {
mounted = false
// Disconnect adapter on unmount to clean up WebSocket connection
if (adapter) {
adapter.disconnect?.()
}
}
}, [repo, adapter, roomId, workerUrl])
@ -576,26 +637,42 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}
}, [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
const userMetadata: { userId: string; name: string; color: string } = (() => {
if (user && 'userId' in user) {
const uid = (user as { userId: string; name: string; color?: string }).userId
return {
userId: (user as { userId: string; name: string; color?: string }).userId,
userId: uid,
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 {
userId: user?.id || 'anonymous',
userId: uid,
name: user?.name || 'Anonymous',
color: '#000000'
color: generateUserColor(uid)
}
})()
// Use useAutomergeStoreV2 to create a proper TLStore instance that syncs with Automerge
const storeWithStatus = useAutomergeStoreV2({
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
@ -609,7 +686,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const presence = useAutomergePresence({
handle: handle || null,
store: storeWithStatus.store || null,
userMetadata
userMetadata,
adapter: adapter // Pass adapter for presence broadcasting
})
return {

View File

@ -435,7 +435,8 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
cursor: apiKey ? 'pointer' : 'not-allowed',
position: 'relative',
zIndex: 10002,
pointerEvents: 'auto'
pointerEvents: 'auto',
touchAction: 'manipulation'
}}
>
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',
pointerEvents: 'auto',
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 = {
@ -215,12 +218,13 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
minHeight: '32px',
backgroundColor: '#f8f9fa',
flexShrink: 0,
touchAction: 'manipulation', // Improve touch responsiveness
}
const tagStyle: React.CSSProperties = {
backgroundColor: '#007acc',
color: 'white',
padding: '2px 6px',
padding: '4px 8px', // Increased padding for better touch target
borderRadius: '12px',
fontSize: '10px',
fontWeight: '500',
@ -228,6 +232,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
alignItems: 'center',
gap: '4px',
cursor: tagsEditable ? 'pointer' : 'default',
touchAction: 'manipulation', // Improve touch responsiveness
minHeight: '24px', // Ensure adequate touch target height
}
const tagInputStyle: React.CSSProperties = {
@ -245,13 +251,15 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '2px 8px',
padding: '4px 10px', // Increased padding for better touch target
fontSize: '10px',
fontWeight: '500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
touchAction: 'manipulation', // Improve touch responsiveness
minHeight: '24px', // Ensure adequate touch target height
}
const handleTagClick = (tag: string) => {
@ -318,6 +326,13 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
const handleButtonClick = (e: React.MouseEvent, action: () => void) => {
e.stopPropagation()
e.preventDefault()
action()
}
const handleButtonTouch = (e: React.TouchEvent, action: () => void) => {
e.stopPropagation()
e.preventDefault()
action()
}
@ -380,6 +395,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
onClick={(e) => handleButtonClick(e, onPinToggle)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => handleButtonTouch(e, onPinToggle)}
onTouchEnd={(e) => e.stopPropagation()}
title={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()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => {
if (onMinimize) {
handleButtonTouch(e, onMinimize)
}
}}
onTouchEnd={(e) => e.stopPropagation()}
title="Minimize"
aria-label="Minimize"
disabled={!onMinimize}
@ -409,6 +432,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
onClick={(e) => handleButtonClick(e, onClose)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => handleButtonTouch(e, onClose)}
onTouchEnd={(e) => e.stopPropagation()}
title="Close"
aria-label="Close"
>
@ -429,9 +454,10 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
{/* Tags at the bottom */}
{(tags.length > 0 || (tagsEditable && isSelected)) && (
<div
<div
style={tagsContainerStyle}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onClick={(e) => {
if (tagsEditable && !isEditingTags && e.target === e.currentTarget) {
setIsEditingTags(true)
@ -446,6 +472,11 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
e.stopPropagation()
handleTagClick(tag)
}}
onTouchEnd={(e) => {
e.stopPropagation()
e.preventDefault()
handleTagClick(tag)
}}
title={tagsEditable ? "Click to remove tag" : undefined}
>
{tag.replace('#', '')}
@ -480,6 +511,12 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
setIsEditingTags(true)
}}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => {
e.stopPropagation()
e.preventDefault()
setIsEditingTags(true)
}}
onTouchEnd={(e) => e.stopPropagation()}
title="Add tag"
>
+ Add

View File

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

View File

@ -1237,3 +1237,33 @@ mark {
background-color: #fafafa;
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 */
@media (prefers-color-scheme: dark) {
.obsidian-toolbar-button {
html.dark .obsidian-toolbar-button {
background: #2d2d2d;
border-color: #404040;
color: #e0e0e0;
}
.obsidian-toolbar-button:hover {
html.dark .obsidian-toolbar-button:hover {
background: #3d3d3d;
border-color: #007acc;
color: #007acc;
}
.obsidian-toolbar-button:active {
html.dark .obsidian-toolbar-button:active {
background: #1a3a5c;
border-color: #005a9e;
color: #005a9e;
}
}

View File

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

View File

@ -2,6 +2,27 @@
:root {
--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,
@ -11,6 +32,9 @@ body {
min-height: 100vh;
min-height: -webkit-fill-available;
height: 100%;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
video {
@ -28,7 +52,7 @@ main {
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
color: #24292e;
color: var(--text-color);
}
h1 {
@ -92,9 +116,9 @@ pre>code {
}
code {
background-color: #e4e9ee;
background-color: var(--code-bg);
width: 100%;
color: #38424c;
color: var(--code-color);
padding: 0.2em 0.4em;
border-radius: 4px;
}
@ -809,4 +833,76 @@ p:has(+ ol) {
padding-right: 0.1em;
padding-left: 0.1em;
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 */
@media (prefers-color-scheme: dark) {
.custom-user-profile {
html.dark .custom-user-profile {
background: rgba(45, 45, 45, 0.9);
border-color: rgba(255, 255, 255, 0.1);
color: #e9ecef;
}
}
/* Animations */
@keyframes profileSlideIn {

View File

@ -1,14 +1,25 @@
# Cloudflare Pages redirects and rewrites
# This file handles SPA routing and URL rewrites (replaces vercel.json rewrites)
# SPA fallback - all routes should serve index.html
# 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
/inbox /index.html 200
/inbox/ /index.html 200
/contact /index.html 200
/contact/ /index.html 200
/presentations /index.html 200
/presentations/ /index.html 200
/presentations/* /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
# Specific route rewrites (matching vercel.json)
/board/* /index.html 200
/board /index.html 200
/inbox /index.html 200
/contact /index.html 200
/presentations /index.html 200
/dashboard /index.html 200

View File

@ -1,7 +1,7 @@
import { useAutomergeSync } from "@/automerge/useAutomergeSync"
import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext"
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 { ChatBoxTool } from "@/tools/ChatBoxTool"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
@ -41,6 +41,8 @@ import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool"
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { MultmuxTool } from "@/tools/MultmuxTool"
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
// Location shape removed - no longer needed
import {
lockElement,
@ -81,6 +83,7 @@ const customShapeUtils = [
HolonBrowserShape,
ObsidianBrowserShape,
FathomMeetingsBrowserShape,
MultmuxShape,
]
const customTools = [
ChatBoxTool,
@ -95,6 +98,7 @@ const customTools = [
TranscriptionTool,
HolonTool,
FathomMeetingsTool,
MultmuxTool,
]
export function Board() {
@ -180,18 +184,95 @@ export function Board() {
});
}, [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(
() => ({
uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssetStore,
shapeUtils: [...defaultShapeUtils, ...customShapeUtils],
bindingUtils: [...defaultBindingUtils],
user: session.authed ? {
id: session.username,
name: session.username,
user: session.authed && uniqueUserId ? {
id: uniqueUserId,
name: session.username, // Display name (can be duplicate)
} : undefined,
}),
[roomId, session.authed, session.username],
[roomId, session.authed, session.username, uniqueUserId],
)
// Use Automerge sync for all environments
@ -414,22 +495,52 @@ export function Board() {
}
// 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) {
console.log(`📊 Board: ${shapesOnOtherPages.length} shapes exist on other pages (not current page ${currentPageId})`)
// 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>()
storeShapes.forEach((s: any) => {
if (s.parentId) {
if (s.parentId && s.parentId.startsWith('page:')) {
pageShapeCounts.set(s.parentId, (pageShapeCounts.get(s.parentId) || 0) + 1)
}
})
// 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) {
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
// CRITICAL: Preserve x and y coordinates when fixing parentId
// 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 }}>
<Tldraw
store={store.store}
user={user}
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
tools={customTools}
components={components}

View File

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

View File

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

View File

@ -12,8 +12,8 @@ export function Presentations() {
support collective action and community self-organization.
</p>
<p>
For more of my work, check out my <a href="/">main page</a> or
<a href="/contact">get in touch</a>.
For more of my work, check out my <a href="/">main page</a> or
<a href="/contact/">get in touch</a>.
</p>
</div>

View File

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

View File

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

View File

@ -1,9 +1,7 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
import { useCallback, useState } from "react"
//import Embed from "react-embed"
//TODO: FIX PEN AND MOBILE INTERACTION WITH EDITING EMBED URL - DEFAULT TO TEXT SELECTED
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
import { usePinnedToView } from "../hooks/usePinnedToView"
export type IEmbedShape = TLBaseShape<
"Embed",
@ -11,11 +9,11 @@ export type IEmbedShape = TLBaseShape<
w: number
h: number
url: string | null
isMinimized?: boolean
pinnedToView: boolean
tags: string[]
interactionState?: {
scrollPosition?: { x: number; y: number }
currentTime?: number // for videos
// other state you want to sync
currentTime?: number
}
}
>
@ -31,12 +29,10 @@ const transformUrl = (url: string): string => {
// Google 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")) {
return url
}
// Handle directions
const directionsMatch = url.match(/dir\/([^\/]+)\/([^\/]+)/)
if (directionsMatch || url.includes("/dir/")) {
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=([^&]+)/)
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`
}
// For all other map URLs
return `https://www.google.com/maps/embed/v1/place?key=${
import.meta.env.VITE_GOOGLE_MAPS_API_KEY
}&q=${encodeURIComponent(url)}`
@ -71,15 +65,13 @@ const transformUrl = (url: string): string => {
if (xMatch) {
const [, username, tweetId] = xMatch
if (tweetId) {
// For tweets
return `https://platform.x.com/embed/Tweet.html?id=${tweetId}`
} else {
// For profiles, return about:blank and handle display separately
return "about:blank"
}
}
// Medium - return about:blank to prevent iframe loading
// Medium - return about:blank
if (url.includes("medium.com")) {
return "about:blank"
}
@ -93,29 +85,24 @@ const transformUrl = (url: string): string => {
}
const getDefaultDimensions = (url: string): { w: number; h: number } => {
// YouTube default dimensions (16:9 ratio)
if (url.match(/(?:youtube\.com|youtu\.be)/)) {
return { w: 800, h: 450 }
}
// Twitter/X default dimensions
if (url.match(/(?:twitter\.com|x\.com)/)) {
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")) {
return { w: 800, h: 600 }
}
// Gather.town default dimensions
if (url.includes("gather.town")) {
return { w: 800, h: 600 }
}
// Default dimensions for other embeds
return { w: 800, h: 600 }
}
@ -124,14 +111,13 @@ const getFaviconUrl = (url: string): string => {
const urlObj = new URL(url)
return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`
} catch {
return '' // Return empty if URL is invalid
return ''
}
}
const getDisplayTitle = (url: string): string => {
try {
const urlObj = new URL(url)
// Handle special cases
if (urlObj.hostname.includes('youtube.com')) {
return 'YouTube'
}
@ -141,48 +127,70 @@ const getDisplayTitle = (url: string): string => {
if (urlObj.hostname.includes('google.com/maps')) {
return 'Google Maps'
}
// Default: return clean hostname
return urlObj.hostname.replace('www.', '')
} catch {
return url // Return original URL if parsing fails
return url
}
}
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
static override type = "Embed"
// Embed theme color: Yellow (Rainbow)
static readonly PRIMARY_COLOR = "#eab308"
getDefaultProps(): IEmbedShape["props"] {
return {
url: null,
w: 800,
h: 600,
isMinimized: false,
pinnedToView: false,
tags: ['embed'],
}
}
indicator(shape: IEmbedShape) {
return (
<rect
x={0}
y={0}
width={shape.props.w}
height={shape.props.isMinimized ? 40 : shape.props.h}
<rect
x={0}
y={0}
width={shape.props.w}
height={shape.props.h}
fill="none"
/>
)
}
component(shape: IEmbedShape) {
// Ensure shape props exist with defaults
const props = shape.props || {}
const url = props.url || ""
const isMinimized = props.isMinimized || false
const [isMinimized, setIsMinimized] = useState(false)
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [inputUrl, setInputUrl] = useState(url)
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(
(e: React.FormEvent) => {
@ -192,7 +200,6 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
? inputUrl
: `https://${inputUrl}`
// Basic URL validation
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//)
if (!isValidUrl) {
setError("Invalid URL")
@ -222,352 +229,268 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
})
}
const contentStyle = {
pointerEvents: isSelected ? "none" as const : "all" as const,
width: "100%",
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>
// Custom header content with URL info
const headerContent = url ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1, overflow: 'hidden' }}>
<img
src={getFaviconUrl(url)}
alt=""
style={{
width: "16px",
height: "16px",
flexShrink: 0,
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '13px',
fontWeight: 600
}}>
{getDisplayTitle(url)}
</span>
</div>
) : (
<span>Embed</span>
)
// For minimized state, show URL and all controls
if (shape.props.url && shape.props.isMinimized) {
// For empty state - URL input form
if (!url) {
return (
<div style={wrapperStyle}>
<div
style={{
...contentStyle,
height: "40px",
alignItems: "center",
padding: "0 15px",
position: "relative",
display: "flex",
gap: "8px",
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<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}
>
<img
src={getFaviconUrl(shape.props.url)}
alt=""
style={{
width: "16px",
height: "16px",
flexShrink: 0,
}}
onError={(e) => {
// Hide broken favicon
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
flex: 1,
}}
>
<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>
</div>
{controls(shape.props.url)}
</div>
</div>
)
}
// For empty state
if (!shape.props.url) {
return (
<div style={wrapperStyle}>
{controls("")}
<div
style={{
...contentStyle,
cursor: 'text', // Add text cursor to indicate clickable
touchAction: 'none', // Prevent touch scrolling
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const input = e.currentTarget.querySelector('input')
input?.focus()
}}
>
<form
onSubmit={handleSubmit}
style={{
width: "100%",
height: "100%",
padding: "10px",
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
padding: '20px',
cursor: 'text',
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const input = e.currentTarget.querySelector('input')
input?.focus()
}}
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL to embed"
<form
onSubmit={handleSubmit}
style={{
width: "100%",
padding: "15px", // Increased padding for better touch target
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "16px", // Increased font size for better visibility
touchAction: 'none',
maxWidth: "500px",
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSubmit(e)
}
}}
onPointerDown={(e) => {
e.stopPropagation()
e.currentTarget.focus()
}}
/>
{error && (
<div style={{ color: "red", marginTop: "10px" }}>{error}</div>
)}
</form>
</div>
</div>
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL to embed..."
style={{
width: "100%",
padding: "15px",
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "16px",
touchAction: 'manipulation',
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSubmit(e)
}
}}
onPointerDown={(e) => {
e.stopPropagation()
e.currentTarget.focus()
}}
/>
{error && (
<div style={{ color: "red", marginTop: "10px", textAlign: 'center' }}>{error}</div>
)}
</form>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
// For medium.com and twitter profile views
if (shape.props.url?.includes("medium.com") ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) {
if (url.includes("medium.com") ||
(url && url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) {
return (
<div style={wrapperStyle}>
{controls(shape.props.url)}
<div
style={{
...contentStyle,
flexDirection: "column",
gap: "12px",
padding: "20px",
textAlign: "center",
pointerEvents: "all",
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<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}
>
<p>
Medium's content policy does not allow for embedding articles in
iframes.
</p>
<a
href={shape.props.url}
target="_blank"
rel="noopener noreferrer"
<div
style={{
color: "#1976d2",
textDecoration: "none",
cursor: "pointer",
display: 'flex',
flexDirection: "column",
gap: "12px",
padding: "20px",
textAlign: "center",
height: '100%',
justifyContent: 'center',
alignItems: 'center',
}}
>
Open article in new tab
</a>
</div>
</div>
<p>
This content cannot be embedded in an iframe.
</p>
<button
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
style={{
padding: '10px 20px',
backgroundColor: EmbedShape.PRIMARY_COLOR,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
touchAction: 'manipulation',
}}
onPointerDown={(e) => e.stopPropagation()}
>
Open in new tab
</button>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
// For normal embed view
return (
<div style={wrapperStyle}>
<div
style={{
height: "40px",
position: "relative",
backgroundColor: "#F0F0F0",
borderTopLeftRadius: "4px",
borderTopRightRadius: "4px",
display: "flex",
alignItems: "center",
padding: "0 8px",
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<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}
>
{controls(shape.props.url)}
</div>
{!shape.props.isMinimized && (
<>
<div style={{
...contentStyle,
height: `${shape.props.h - 80}px`,
}}>
<iframe
src={transformUrl(shape.props.url)}
width="100%"
height="100%"
style={{ border: "none" }}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer"
onLoad={(e) => {
// Only add listener if we have a valid iframe
const iframe = e.currentTarget as HTMLIFrameElement
if (!iframe) return;
const messageHandler = (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
handleIframeInteraction(event.data)
}
}
window.addEventListener("message", messageHandler)
// Clean up listener when iframe changes
return () => window.removeEventListener("message", messageHandler)
}}
/>
</div>
<div
<div style={{
height: '100%',
overflow: 'hidden',
backgroundColor: '#fff',
}}>
<iframe
src={transformUrl(url)}
width="100%"
height="100%"
style={{
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,
border: "none",
display: 'block',
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
marginRight: "8px",
color: "#666",
}}
>
{shape.props.url}
</span>
</div>
</>
)}
</div>
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer"
onLoad={(e) => {
const iframe = e.currentTarget as HTMLIFrameElement
if (!iframe) return;
const messageHandler = (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
handleIframeInteraction(event.data)
}
}
window.addEventListener("message", messageHandler)
return () => window.removeEventListener("message", messageHandler)
}}
/>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
override onDoubleClick = (shape: IEmbedShape) => {
// If no URL is set, focus the input field
if (!shape.props.url) {
const input = document.querySelector('input')
input?.focus()
return
}
// For Medium articles and Twitter profiles that show alternative content
if (
shape.props.url.includes('medium.com') ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))
@ -576,11 +499,9 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
return
}
// For other embeds, enable interaction by temporarily removing pointer-events: none
const iframe = document.querySelector(`[data-shape-id="${shape.id}"] iframe`) as HTMLIFrameElement
if (iframe) {
iframe.style.pointerEvents = 'all'
// Reset pointer-events after interaction
const cleanup = () => {
iframe.style.pointerEvents = 'none'
window.removeEventListener('pointerdown', cleanup)
@ -589,7 +510,6 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
}
}
// Update the pointer down handler
onPointerDown = (shape: IEmbedShape) => {
if (!shape.props.url) {
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) => {
if (shape.props.url) {
const dimensions = getDefaultDimensions(shape.props.url)
@ -613,7 +532,6 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
return shape
}
// Handle URL updates after creation
override onBeforeUpdate = (prev: IEmbedShape, next: IEmbedShape) => {
if (next.props.url && prev.props.url !== 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 { 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<
'Markdown',
@ -8,35 +10,64 @@ export type IMarkdownShape = TLBaseShape<
w: number
h: number
text: string
pinnedToView: boolean
tags: string[]
}
>
export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
static type = 'Markdown' as const
// Markdown theme color: Cyan/Teal (Rainbow)
static readonly PRIMARY_COLOR = "#06b6d4"
getDefaultProps(): IMarkdownShape['props'] {
return {
w: 500,
h: 400,
text: '',
pinnedToView: false,
tags: ['markdown'],
}
}
component(shape: IMarkdownShape) {
// Hooks must be at the top level
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const markdownRef = React.useRef<HTMLDivElement>(null)
// Handler function defined before useEffect
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<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) => {
event.stopPropagation()
const target = event.target as HTMLInputElement
const checked = target.checked
const text = shape.props.text
const lines = text.split('\n')
const checkboxRegex = /^\s*[-*+]\s+\[([ x])\]/
const newText = lines.map(line => {
if (line.includes(target.parentElement?.textContent || '')) {
return line.replace(checkboxRegex, `- [${checked ? 'x' : ' '}]`)
@ -53,8 +84,8 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
},
})
}, [shape.id, shape.props.text])
// Single useEffect hook that handles checkbox interactivity
// Effect hook that handles checkbox interactivity
React.useEffect(() => {
if (!isSelected && markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
@ -62,8 +93,7 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
checkbox.removeAttribute('disabled')
checkbox.addEventListener('click', handleCheckboxClick)
})
// Cleanup function
return () => {
if (markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
@ -75,87 +105,133 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
}
}, [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
if (isSelected) {
return (
<div style={wrapperStyle}>
<div style={contentStyle}>
<MDEditor
value={shape.props.text}
onChange={(value = '') => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
text: value,
},
})
}}
preview='live'
visibleDragbar={false}
style={{
height: 'auto',
minHeight: '100%',
border: 'none',
backgroundColor: 'transparent',
}}
previewOptions={{
style: {
padding: '8px',
backgroundColor: 'transparent',
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<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,
}
}}
textareaProps={{
style: {
padding: '8px',
lineHeight: '1.5',
height: 'auto',
minHeight: '100%',
resize: 'none',
})
}}
tagsEditable={true}
>
<div style={{
width: '100%',
height: '100%',
backgroundColor: '#FFFFFF',
pointerEvents: 'all',
overflow: 'hidden',
}}>
<MDEditor
value={shape.props.text}
onChange={(value = '') => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
text: value,
},
})
}}
preview='live'
visibleDragbar={false}
style={{
height: '100%',
border: 'none',
backgroundColor: 'transparent',
}
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
/>
</div>
</div>
}}
previewOptions={{
style: {
padding: '8px',
backgroundColor: 'transparent',
}
}}
textareaProps={{
style: {
padding: '8px',
lineHeight: '1.5',
height: '100%',
resize: 'none',
backgroundColor: 'transparent',
}
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
/>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
// Show rendered markdown when not selected
return (
<div style={wrapperStyle}>
<div style={contentStyle}>
<div ref={markdownRef} style={{ width: '100%', height: '100%', padding: '12px' }}>
{shape.props.text ? (
<MDEditor.Markdown source={shape.props.text} />
) : (
<span style={{ opacity: 0.5 }}>Click to edit markdown...</span>
)}
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<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' }}>
{shape.props.text ? (
<MDEditor.Markdown source={shape.props.text} />
) : (
<span style={{ opacity: 0.5 }}>Click to edit markdown...</span>
)}
</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 { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
import React, { useState } from "react"
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
import { usePinnedToView } from "../hooks/usePinnedToView"
type IPrompt = TLBaseShape<
"Prompt",
@ -25,6 +27,8 @@ type IPrompt = TLBaseShape<
agentBinding: string | null
personality?: string
error?: string | null
pinnedToView: boolean
tags: string[]
}
>
@ -44,6 +48,9 @@ const CheckIcon = () => (
export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
static override type = "Prompt" as const
// LLM Prompt theme color: Pink/Magenta (Rainbow)
static readonly PRIMARY_COLOR = "#ec4899"
FIXED_HEIGHT = 500 as const
MIN_WIDTH = 200 as const
PADDING = 4 as const
@ -55,6 +62,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
prompt: "",
value: "",
agentBinding: null,
pinnedToView: false,
tags: ['llm', 'prompt'],
}
}
@ -358,38 +367,86 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
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 (
<HTMLContainer
style={{
borderRadius: 6,
border: "1px solid lightgrey",
padding: this.PADDING,
height: this.FIXED_HEIGHT,
width: shape.props.w,
pointerEvents: isSelected || isHovering ? "all" : "none",
backgroundColor: "#efefef",
overflow: "visible",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "stretch",
outline: shape.props.agentBinding ? "2px solid orange" : "none",
}}
//TODO: FIX SCROLL IN PROMPT CHAT WHEN HOVERING OVER ELEMENT
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
onWheel={(e) => {
if (isSelected || isHovering) {
e.preventDefault()
e.stopPropagation()
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop += e.deltaY
}
}
}}
>
<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={{
height: '100%',
width: '100%',
padding: this.PADDING,
pointerEvents: isSelected || isHovering ? "all" : "none",
backgroundColor: "#efefef",
overflow: "visible",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "stretch",
outline: shape.props.agentBinding ? "2px solid orange" : "none",
}}
//TODO: FIX SCROLL IN PROMPT CHAT WHEN HOVERING OVER ELEMENT
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
onWheel={(e) => {
if (isSelected || isHovering) {
e.preventDefault()
e.stopPropagation()
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop += e.deltaY
}
}
}}
>
<div
ref={chatContainerRef}
style={{
@ -676,6 +733,8 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
{copyButtonText}
</button>
</div>
</div>
</StandardizedToolWrapper>
</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 { 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() {
const editor = useEditor()
const tools = useTools()
@ -34,6 +48,18 @@ export function CustomToolbar() {
const [fathomApiKeyInput, setFathomApiKeyInput] = useState('')
const [hasFathomApiKey, setHasFathomApiKey] = useState(false)
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(() => {
if (editor && tools) {
@ -545,6 +571,42 @@ export function CustomToolbar() {
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" />
<StarBoardButton className="toolbar-star-button" />
@ -891,7 +953,7 @@ export function CustomToolbar() {
</div>
<a
href="/dashboard"
href="/dashboard/"
target="_blank"
rel="noopener noreferrer"
style={{
@ -1015,7 +1077,7 @@ export function CustomToolbar() {
<TldrawUiMenuItem
{...tools["Prompt"]}
icon="prompt"
label="Prompt"
label="LLM Prompt"
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/>
)}
@ -1051,6 +1113,14 @@ export function CustomToolbar() {
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 */}
{/* Refresh All ObsNotes Button */}
{(() => {

View File

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

View File

@ -138,14 +138,25 @@ export class AutomergeDurableObject {
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.storage.put("automergeDocumentId", documentId)
this.automergeDocumentId = documentId
if (!this.automergeDocumentId) {
// No document ID exists yet, use the one provided by the client
await this.ctx.storage.put("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 }), {
return new Response(JSON.stringify({ success: true, documentId: actualDocumentId }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
@ -394,6 +405,18 @@ export class AutomergeDurableObject {
// Handle document state request from worker (for persistence)
await this.handleDocumentStateRequest(sessionId)
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:
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 {
// Create a stable string representation of the document
// Focus on the store data which is what actually changes
const storeData = doc.store || {}
const storeKeys = Object.keys(storeData).sort()
// CRITICAL FIX: JSON.stringify's second parameter when it's an array is a replacer
// that only includes those properties. We need to stringify the entire store object.
// 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)
let hash = 0
for (let i = 0; i < storeString.length; i++) {
const char = storeString.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // Convert to 32-bit integer
// Fast hash: combine record count + sorted key fingerprint + metadata
let hash = storeKeys.length // Start with record count
// Hash the record IDs and key metadata (much faster than stringifying full records)
for (let i = 0; i < storeKeys.length; i++) {
const key = storeKeys[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 & 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()
console.log(`Server generated hash:`, {
storeStringLength: storeString.length,
console.log(`Server generated hash (optimized):`, {
hash: hashString,
storeKeys: storeKeys.length,
sampleKeys: storeKeys.slice(0, 3)
@ -1181,12 +1225,17 @@ export class AutomergeDurableObject {
}
// 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)) {
console.log(`🔧 Server: Resetting X coordinate for shape ${record.id} (type: ${record.type}, parentId: ${record.parentId}). Original value:`, originalX)
record.x = 0
needsUpdate = true
}
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
needsUpdate = true
}
@ -1202,8 +1251,12 @@ export class AutomergeDurableObject {
record.meta = {}
needsUpdate = true
}
// Validate and fix index property - must be a valid IndexKey (like 'a1', 'a2', etc.)
if (!record.index || typeof record.index !== 'string' || !/^[a-z]\d+$/.test(record.index)) {
// 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 (!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
needsUpdate = true
}