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:
parent
1aec51e97b
commit
7eb60ebcf2
29
_redirects
29
_redirects
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
38
src/App.tsx
38
src/App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue