feat: move Mycelial Intelligence to permanent UI bar + fix ImageGen RunPod API
- Mycelial Intelligence UI refactor: - Created permanent floating bar at top of screen (MycelialIntelligenceBar.tsx) - Bar stays fixed and doesn't zoom with canvas - Collapses when clicking outside - Removed from toolbar tool menu - Added deprecated shape stub for backwards compatibility with old boards - ImageGen RunPod fix: - Changed from async /run to sync /runsync endpoint - Fixed output parsing for output.images array format with base64 - Other updates: - Added FocusLockIndicator and UserSettingsModal UI components - mulTmux server and shape updates - Automerge sync and store improvements - Various CSS and UI refinements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
30e2219551
commit
144f5365c1
|
|
@ -175,3 +175,4 @@ dist
|
|||
.env.*.local
|
||||
.dev.vars
|
||||
.env.production
|
||||
.aider*
|
||||
|
|
|
|||
|
|
@ -64,6 +64,26 @@ export function createRouter(
|
|||
});
|
||||
});
|
||||
|
||||
// Join an existing session (generates a new token and returns session info)
|
||||
router.post('/sessions/:id/join', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Generate a new token for this joining client
|
||||
const token = tokenManager.generateToken(session.id, 60, 'write');
|
||||
|
||||
res.json({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
token,
|
||||
createdAt: session.createdAt,
|
||||
activeClients: session.clients.size,
|
||||
});
|
||||
});
|
||||
|
||||
// Generate new invite token for existing session
|
||||
router.post('/sessions/:id/tokens', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.id);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { SessionManager } from './managers/SessionManager';
|
||||
|
|
@ -6,8 +7,7 @@ import { TokenManager } from './managers/TokenManager';
|
|||
import { TerminalHandler } from './websocket/TerminalHandler';
|
||||
import { createRouter } from './api/routes';
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const WS_PORT = process.env.WS_PORT || 3001;
|
||||
const PORT = process.env.PORT || 3002;
|
||||
|
||||
async function main() {
|
||||
// Initialize managers
|
||||
|
|
@ -21,16 +21,15 @@ async function main() {
|
|||
app.use(express.json());
|
||||
app.use('/api', createRouter(sessionManager, tokenManager));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`mulTmux HTTP API listening on port ${PORT}`);
|
||||
});
|
||||
// Create HTTP server to share with WebSocket
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket Server
|
||||
const wss = new WebSocketServer({ port: Number(WS_PORT) });
|
||||
// WebSocket Server on same port, handles upgrade requests
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
// Extract token from query string
|
||||
const url = new URL(req.url || '', `http://localhost:${WS_PORT}`);
|
||||
const url = new URL(req.url || '', `http://localhost:${PORT}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
|
|
@ -42,11 +41,12 @@ async function main() {
|
|||
terminalHandler.handleConnection(ws, token);
|
||||
});
|
||||
|
||||
console.log(`mulTmux WebSocket server listening on port ${WS_PORT}`);
|
||||
server.listen(PORT, () => {
|
||||
console.log('');
|
||||
console.log('mulTmux server is ready!');
|
||||
console.log(`API: http://localhost:${PORT}/api`);
|
||||
console.log(`WebSocket: ws://localhost:${WS_PORT}`);
|
||||
console.log(`WebSocket: ws://localhost:${PORT}/ws`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
"@types/marked": "^5.0.2",
|
||||
"@uiw/react-md-editor": "^4.0.5",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ai": "^4.1.0",
|
||||
"ajv": "^8.17.1",
|
||||
"cherry-markdown": "^0.8.57",
|
||||
|
|
@ -6007,6 +6009,21 @@
|
|||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"multmux/packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker:local\"",
|
||||
"dev": "concurrently --kill-others --names client,worker,multmux --prefix-colors blue,red,magenta \"npm run dev:client\" \"npm run dev:worker:local\" \"npm run multmux:dev:server\"",
|
||||
"dev:client": "vite --host 0.0.0.0 --port 5173",
|
||||
"dev:worker": "wrangler dev --config wrangler.dev.toml --remote --port 5172",
|
||||
"dev:worker:local": "wrangler dev --config wrangler.dev.toml --port 5172 --ip 0.0.0.0",
|
||||
|
|
@ -43,6 +43,8 @@
|
|||
"@types/marked": "^5.0.2",
|
||||
"@uiw/react-md-editor": "^4.0.5",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ai": "^4.1.0",
|
||||
"ajv": "^8.17.1",
|
||||
"cherry-markdown": "^0.8.57",
|
||||
|
|
|
|||
|
|
@ -640,8 +640,9 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
'holon': 'Holon',
|
||||
'obsidianBrowser': 'ObsidianBrowser',
|
||||
'fathomMeetingsBrowser': 'FathomMeetingsBrowser',
|
||||
// locationShare removed
|
||||
'imageGen': 'ImageGen',
|
||||
'videoGen': 'VideoGen',
|
||||
'multmux': 'Multmux',
|
||||
}
|
||||
|
||||
// Normalize the shape type if it's a custom type with incorrect case
|
||||
|
|
@ -650,6 +651,68 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
sanitized.type = customShapeTypeMap[sanitized.type]
|
||||
}
|
||||
|
||||
// CRITICAL: Sanitize Multmux shapes AFTER case normalization - ensure all required props exist
|
||||
// Old shapes may have wsUrl (removed) or undefined values
|
||||
if (sanitized.type === 'Multmux') {
|
||||
console.log(`🔧 Sanitizing Multmux shape ${sanitized.id}:`, JSON.stringify(sanitized.props))
|
||||
// Remove deprecated wsUrl prop
|
||||
if ('wsUrl' in sanitized.props) {
|
||||
delete sanitized.props.wsUrl
|
||||
}
|
||||
// CRITICAL: Create a clean props object with all required values
|
||||
// This ensures no undefined values slip through validation
|
||||
// Every value MUST be explicitly defined - undefined values cause ValidationError
|
||||
const w = (typeof sanitized.props.w === 'number' && !isNaN(sanitized.props.w)) ? sanitized.props.w : 800
|
||||
const h = (typeof sanitized.props.h === 'number' && !isNaN(sanitized.props.h)) ? sanitized.props.h : 600
|
||||
const sessionId = (typeof sanitized.props.sessionId === 'string') ? sanitized.props.sessionId : ''
|
||||
const sessionName = (typeof sanitized.props.sessionName === 'string') ? sanitized.props.sessionName : ''
|
||||
const token = (typeof sanitized.props.token === 'string') ? sanitized.props.token : ''
|
||||
const serverUrl = (typeof sanitized.props.serverUrl === 'string') ? sanitized.props.serverUrl : 'http://localhost:3000'
|
||||
const pinnedToView = (sanitized.props.pinnedToView === true) ? true : false
|
||||
// Filter out any undefined or non-string elements from tags array
|
||||
let tags: string[] = ['terminal', 'multmux']
|
||||
if (Array.isArray(sanitized.props.tags)) {
|
||||
const filteredTags = sanitized.props.tags.filter((t: any) => typeof t === 'string' && t !== '')
|
||||
if (filteredTags.length > 0) {
|
||||
tags = filteredTags
|
||||
}
|
||||
}
|
||||
|
||||
// Build clean props object - all values are guaranteed to be defined
|
||||
const cleanProps = {
|
||||
w: w,
|
||||
h: h,
|
||||
sessionId: sessionId,
|
||||
sessionName: sessionName,
|
||||
token: token,
|
||||
serverUrl: serverUrl,
|
||||
pinnedToView: pinnedToView,
|
||||
tags: tags,
|
||||
}
|
||||
|
||||
// CRITICAL: Verify no undefined values before assigning
|
||||
// This is a safety check - if any value is undefined, something went wrong above
|
||||
for (const [key, value] of Object.entries(cleanProps)) {
|
||||
if (value === undefined) {
|
||||
console.error(`❌ CRITICAL: Multmux prop ${key} is undefined after sanitization! This should never happen.`)
|
||||
// Fix it with a default value based on key
|
||||
switch (key) {
|
||||
case 'w': (cleanProps as any).w = 800; break
|
||||
case 'h': (cleanProps as any).h = 600; break
|
||||
case 'sessionId': (cleanProps as any).sessionId = ''; break
|
||||
case 'sessionName': (cleanProps as any).sessionName = ''; break
|
||||
case 'token': (cleanProps as any).token = ''; break
|
||||
case 'serverUrl': (cleanProps as any).serverUrl = 'http://localhost:3000'; break
|
||||
case 'pinnedToView': (cleanProps as any).pinnedToView = false; break
|
||||
case 'tags': (cleanProps as any).tags = ['terminal', 'multmux']; break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.props = cleanProps
|
||||
console.log(`🔧 Sanitized Multmux shape ${sanitized.id} props:`, JSON.stringify(sanitized.props))
|
||||
}
|
||||
|
||||
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
|
||||
// This ensures arrows and other shapes are properly recognized
|
||||
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||
|
|
@ -785,14 +848,62 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
if ('w' in sanitized.props) delete sanitized.props.w
|
||||
if ('h' in sanitized.props) delete sanitized.props.h
|
||||
|
||||
// Line shapes REQUIRE points property
|
||||
// Line shapes REQUIRE points property with at least 2 points
|
||||
if (!sanitized.props.points || typeof sanitized.props.points !== 'object' || Array.isArray(sanitized.props.points)) {
|
||||
sanitized.props.points = {
|
||||
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
|
||||
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
|
||||
}
|
||||
} else {
|
||||
// Ensure the points object has at least 2 valid points
|
||||
const pointKeys = Object.keys(sanitized.props.points)
|
||||
if (pointKeys.length < 2) {
|
||||
sanitized.props.points = {
|
||||
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
|
||||
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Fix draw shapes - ensure valid segments structure (required by schema)
|
||||
// Draw shapes with empty segments cause "No nearest point found" errors
|
||||
if (sanitized.type === 'draw') {
|
||||
// Remove invalid w/h from props (they cause validation errors)
|
||||
if ('w' in sanitized.props) delete sanitized.props.w
|
||||
if ('h' in sanitized.props) delete sanitized.props.h
|
||||
|
||||
// Draw shapes REQUIRE segments property with at least one segment containing points
|
||||
if (!sanitized.props.segments || !Array.isArray(sanitized.props.segments) || sanitized.props.segments.length === 0) {
|
||||
// Create a minimal valid segment with at least 2 points
|
||||
sanitized.props.segments = [{
|
||||
type: 'free',
|
||||
points: [
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 10, y: 0, z: 0.5 }
|
||||
]
|
||||
}]
|
||||
} else {
|
||||
// Ensure each segment has valid points
|
||||
sanitized.props.segments = sanitized.props.segments.map((segment: any) => {
|
||||
if (!segment.points || !Array.isArray(segment.points) || segment.points.length < 2) {
|
||||
return {
|
||||
type: segment.type || 'free',
|
||||
points: [
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 10, y: 0, z: 0.5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
return segment
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure required draw shape properties exist
|
||||
if (typeof sanitized.props.isClosed !== 'boolean') sanitized.props.isClosed = false
|
||||
if (typeof sanitized.props.isComplete !== 'boolean') sanitized.props.isComplete = true
|
||||
if (typeof sanitized.props.isPen !== 'boolean') sanitized.props.isPen = false
|
||||
}
|
||||
|
||||
// CRITICAL: Fix group shapes - remove invalid w/h from props
|
||||
if (sanitized.type === 'group') {
|
||||
|
|
@ -855,8 +966,58 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||
}
|
||||
|
||||
// CRITICAL: Preserve arrow text property (ensure it's a string)
|
||||
// CRITICAL: Fix arrow shapes - ensure valid start/end structure (required by schema)
|
||||
// Arrows with invalid start/end cause "No nearest point found" errors
|
||||
if (sanitized.type === 'arrow') {
|
||||
// Ensure start property exists and has valid structure
|
||||
if (!sanitized.props.start || typeof sanitized.props.start !== 'object') {
|
||||
sanitized.props.start = { x: 0, y: 0 }
|
||||
} else {
|
||||
// Ensure start has x and y properties (could be bound to a shape or free)
|
||||
const start = sanitized.props.start as any
|
||||
if (start.type === 'binding') {
|
||||
// Binding type must have boundShapeId, normalizedAnchor, and other properties
|
||||
if (!start.boundShapeId) {
|
||||
// Invalid binding - convert to point
|
||||
sanitized.props.start = { x: start.x ?? 0, y: start.y ?? 0 }
|
||||
}
|
||||
} else if (start.type === 'point' || start.type === undefined) {
|
||||
// Point type must have x and y
|
||||
if (typeof start.x !== 'number' || typeof start.y !== 'number') {
|
||||
sanitized.props.start = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure end property exists and has valid structure
|
||||
if (!sanitized.props.end || typeof sanitized.props.end !== 'object') {
|
||||
sanitized.props.end = { x: 100, y: 0 }
|
||||
} else {
|
||||
// Ensure end has x and y properties (could be bound to a shape or free)
|
||||
const end = sanitized.props.end as any
|
||||
if (end.type === 'binding') {
|
||||
// Binding type must have boundShapeId
|
||||
if (!end.boundShapeId) {
|
||||
// Invalid binding - convert to point
|
||||
sanitized.props.end = { x: end.x ?? 100, y: end.y ?? 0 }
|
||||
}
|
||||
} else if (end.type === 'point' || end.type === undefined) {
|
||||
// Point type must have x and y
|
||||
if (typeof end.x !== 'number' || typeof end.y !== 'number') {
|
||||
sanitized.props.end = { x: 100, y: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure bend is a valid number
|
||||
if (typeof sanitized.props.bend !== 'number' || isNaN(sanitized.props.bend)) {
|
||||
sanitized.props.bend = 0
|
||||
}
|
||||
|
||||
// Ensure arrowhead properties exist
|
||||
if (!sanitized.props.arrowheadStart) sanitized.props.arrowheadStart = 'none'
|
||||
if (!sanitized.props.arrowheadEnd) sanitized.props.arrowheadEnd = 'arrow'
|
||||
|
||||
// Ensure text property exists and is a string
|
||||
if (sanitized.props.text === undefined || sanitized.props.text === null) {
|
||||
sanitized.props.text = ''
|
||||
|
|
|
|||
|
|
@ -270,7 +270,11 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
} else {
|
||||
// Handle text messages (our custom protocol for backward compatibility)
|
||||
const message = JSON.parse(event.data)
|
||||
|
||||
// Only log non-presence messages to reduce console spam
|
||||
if (message.type !== 'presence' && message.type !== 'pong') {
|
||||
console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type)
|
||||
}
|
||||
|
||||
// Handle ping/pong messages for keep-alive
|
||||
if (message.type === 'ping') {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeU
|
|||
import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
|
||||
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
||||
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
||||
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
|
||||
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
|
||||
// Location shape removed - no longer needed
|
||||
|
||||
export function useAutomergeStoreV2({
|
||||
|
|
@ -138,36 +140,12 @@ export function useAutomergeStoreV2({
|
|||
userId: string
|
||||
adapter?: any
|
||||
}): TLStoreWithStatus {
|
||||
console.log("useAutomergeStoreV2 called with handle:", !!handle, "adapter:", !!adapter)
|
||||
|
||||
// Create a custom schema that includes all the custom shapes
|
||||
const customSchema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
ChatBox: {} as any,
|
||||
VideoChat: {} as any,
|
||||
Embed: {} as any,
|
||||
Markdown: {} as any,
|
||||
MycrozineTemplate: {} as any,
|
||||
Slide: {} as any,
|
||||
Prompt: {} as any,
|
||||
Transcription: {} as any,
|
||||
ObsNote: {} as any,
|
||||
FathomNote: {} as any,
|
||||
Holon: {} as any,
|
||||
ObsidianBrowser: {} as any,
|
||||
FathomMeetingsBrowser: {} as any,
|
||||
ImageGen: {} as any,
|
||||
VideoGen: {} as any,
|
||||
Multmux: {} as any,
|
||||
},
|
||||
bindings: defaultBindingSchemas,
|
||||
})
|
||||
// useAutomergeStoreV2 initializing
|
||||
|
||||
// Create store with shape utils and explicit schema for all custom shapes
|
||||
// Note: Some shapes don't have `static override props`, so we must explicitly list them all
|
||||
const [store] = useState(() => {
|
||||
const store = createTLStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: [
|
||||
const shapeUtils = [
|
||||
ChatBoxShape,
|
||||
VideoChatShape,
|
||||
EmbedShape,
|
||||
|
|
@ -184,7 +162,65 @@ export function useAutomergeStoreV2({
|
|||
ImageGenShape,
|
||||
VideoGenShape,
|
||||
MultmuxShape,
|
||||
],
|
||||
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
||||
]
|
||||
|
||||
// CRITICAL: Explicitly list ALL custom shape types to ensure they're registered
|
||||
// This is a fallback in case dynamic extraction from shape utils fails
|
||||
const knownCustomShapeTypes = [
|
||||
'ChatBox',
|
||||
'VideoChat',
|
||||
'Embed',
|
||||
'Markdown',
|
||||
'MycrozineTemplate',
|
||||
'Slide',
|
||||
'Prompt',
|
||||
'Transcription',
|
||||
'ObsNote',
|
||||
'FathomNote',
|
||||
'Holon',
|
||||
'ObsidianBrowser',
|
||||
'FathomMeetingsBrowser',
|
||||
'ImageGen',
|
||||
'VideoGen',
|
||||
'Multmux',
|
||||
'MycelialIntelligence', // Deprecated - kept for backwards compatibility
|
||||
]
|
||||
|
||||
// Build schema with explicit entries for all custom shapes
|
||||
const customShapeSchemas: Record<string, any> = {}
|
||||
|
||||
// First, register all known custom shape types with empty schemas as fallback
|
||||
knownCustomShapeTypes.forEach(type => {
|
||||
customShapeSchemas[type] = {} as any
|
||||
})
|
||||
|
||||
// Then, override with actual props for shapes that have them defined
|
||||
shapeUtils.forEach((util) => {
|
||||
const type = (util as any).type
|
||||
if (type && (util as any).props) {
|
||||
// Shape has static props - use them for proper validation
|
||||
customShapeSchemas[type] = {
|
||||
props: (util as any).props,
|
||||
migrations: (util as any).migrations,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Log what shapes were registered for debugging
|
||||
// Custom shape schemas registered
|
||||
|
||||
const customSchema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
...customShapeSchemas,
|
||||
},
|
||||
bindings: defaultBindingSchemas,
|
||||
})
|
||||
|
||||
const store = createTLStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: shapeUtils,
|
||||
})
|
||||
return store
|
||||
})
|
||||
|
|
@ -199,7 +235,7 @@ export function useAutomergeStoreV2({
|
|||
const allRecords = storeWithStatus.store.allRecords()
|
||||
const shapes = allRecords.filter(r => r.typeName === 'shape')
|
||||
const pages = allRecords.filter(r => r.typeName === 'page')
|
||||
console.log(`📊 useAutomergeStoreV2: Store synced with ${allRecords.length} total records, ${shapes.length} shapes, ${pages.length} pages`)
|
||||
// Store synced
|
||||
}
|
||||
}, [storeWithStatus.status, storeWithStatus.store])
|
||||
|
||||
|
|
@ -213,34 +249,47 @@ export function useAutomergeStoreV2({
|
|||
|
||||
const unsubs: (() => void)[] = []
|
||||
|
||||
// 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
|
||||
// Track pending local changes using a COUNTER instead of a boolean.
|
||||
// The old boolean approach failed because during rapid changes (like dragging),
|
||||
// multiple echoes could arrive but only the first was skipped.
|
||||
// With a counter:
|
||||
// - Increment before each handle.change()
|
||||
// - Decrement (and skip) for each echo that arrives
|
||||
// - Process changes only when counter is 0 (those are remote changes)
|
||||
let pendingLocalChanges = 0
|
||||
|
||||
// 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[]) => {
|
||||
const broadcastJsonSync = (addedOrUpdatedRecords: any[], deletedRecordIds: string[] = []) => {
|
||||
// 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 (!changedRecords || changedRecords.length === 0) {
|
||||
if ((!addedOrUpdatedRecords || addedOrUpdatedRecords.length === 0) && deletedRecordIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`📤 Broadcasting ${changedRecords.length} changed records via manual JSON sync`)
|
||||
// Broadcasting changes via JSON sync
|
||||
const shapeRecords = addedOrUpdatedRecords.filter(r => r?.typeName === 'shape')
|
||||
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
|
||||
if (shapeRecords.length > 0 || deletedShapes.length > 0) {
|
||||
console.log(`📤 Broadcasting ${shapeRecords.length} shape changes and ${deletedShapes.length} deletions via JSON sync`)
|
||||
}
|
||||
|
||||
if (adapter && typeof (adapter as any).send === 'function') {
|
||||
// Send changes to other clients via the network adapter
|
||||
(adapter as any).send({
|
||||
// CRITICAL: Always include a documentId for the server to process correctly
|
||||
const docId: string = handle?.documentId || `automerge:${Date.now()}`;
|
||||
const adapterSend = (adapter as any).send.bind(adapter);
|
||||
adapterSend({
|
||||
type: 'sync',
|
||||
data: {
|
||||
store: Object.fromEntries(changedRecords.map(r => [r.id, r]))
|
||||
store: Object.fromEntries(addedOrUpdatedRecords.map(r => [r.id, r])),
|
||||
deleted: deletedRecordIds // Include list of deleted record IDs
|
||||
},
|
||||
documentId: handle?.documentId,
|
||||
documentId: docId,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
} else {
|
||||
|
|
@ -250,24 +299,32 @@ export function useAutomergeStoreV2({
|
|||
|
||||
// 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
|
||||
const patchCount = payload.patches?.length || 0
|
||||
const shapePatches = payload.patches?.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
}) || []
|
||||
|
||||
// Debug logging for sync issues
|
||||
console.log(`🔄 automergeChangeHandler: ${patchCount} patches (${shapePatches.length} shapes), pendingLocalChanges=${pendingLocalChanges}`)
|
||||
|
||||
// Skip echoes of our own local changes using a counter.
|
||||
// Each local handle.change() increments the counter, and each echo decrements it.
|
||||
// Only process changes when counter is 0 (those are remote changes from other clients).
|
||||
if (pendingLocalChanges > 0) {
|
||||
console.log(`⏭️ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`)
|
||||
pendingLocalChanges--
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`✅ Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`)
|
||||
|
||||
try {
|
||||
// Apply patches from Automerge to TLDraw store
|
||||
if (payload.patches && payload.patches.length > 0) {
|
||||
// Debug: Check if patches contain shapes
|
||||
const shapePatches = payload.patches.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
if (shapePatches.length > 0) {
|
||||
console.log(`🔌 Automerge patches contain ${shapePatches.length} shape patches out of ${payload.patches.length} total patches`)
|
||||
console.log(`📥 Applying ${shapePatches.length} shape patches from remote`)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -283,13 +340,10 @@ export function useAutomergeStoreV2({
|
|||
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
|
||||
|
||||
if (shapesAfter.length !== shapesBefore.length) {
|
||||
console.log(`✅ Applied ${payload.patches.length} patches: shapes changed from ${shapesBefore.length} to ${shapesAfter.length}`)
|
||||
// Patches applied
|
||||
}
|
||||
|
||||
// Only log if there are many patches or if debugging is needed
|
||||
if (payload.patches.length > 5) {
|
||||
console.log(`✅ Successfully applied ${payload.patches.length} patches`)
|
||||
}
|
||||
// Patches processed successfully
|
||||
} catch (patchError) {
|
||||
console.error("Error applying patches batch, attempting individual patch application:", patchError)
|
||||
// Try applying patches one by one to identify problematic ones
|
||||
|
|
@ -349,7 +403,7 @@ export function useAutomergeStoreV2({
|
|||
}
|
||||
|
||||
if (successCount < payload.patches.length || payload.patches.length > 5) {
|
||||
console.log(`✅ Successfully applied ${successCount} out of ${payload.patches.length} patches`)
|
||||
// Partial patches applied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -439,31 +493,30 @@ 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 = 100 // Save position updates every 100ms for real-time feel
|
||||
const POSITION_UPDATE_THROTTLE_MS = 50 // Save position updates every 50ms for near real-time feel
|
||||
|
||||
const flushPositionUpdates = () => {
|
||||
if (positionUpdateQueue && handle) {
|
||||
const queuedChanges = positionUpdateQueue
|
||||
positionUpdateQueue = null
|
||||
|
||||
// CRITICAL: Defer position update saves to prevent interrupting active interactions
|
||||
requestAnimationFrame(() => {
|
||||
// Apply immediately for real-time sync
|
||||
try {
|
||||
isLocalChange = true
|
||||
pendingLocalChanges++
|
||||
handle.change((doc) => {
|
||||
applyTLStoreChangesToAutomerge(doc, queuedChanges)
|
||||
})
|
||||
// Trigger sync to broadcast position updates
|
||||
const changedRecords = [
|
||||
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
|
||||
const addedOrUpdatedRecords = [
|
||||
...Object.values(queuedChanges.added || {}),
|
||||
...Object.values(queuedChanges.updated || {}),
|
||||
...Object.values(queuedChanges.removed || {})
|
||||
...Object.values(queuedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
|
||||
]
|
||||
broadcastJsonSync(changedRecords)
|
||||
const deletedRecordIds = Object.keys(queuedChanges.removed || {})
|
||||
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
|
||||
} catch (error) {
|
||||
console.error("Error applying throttled position updates to Automerge:", error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1131,17 +1184,18 @@ export function useAutomergeStoreV2({
|
|||
|
||||
// Apply queued changes immediately
|
||||
try {
|
||||
isLocalChange = true
|
||||
pendingLocalChanges++
|
||||
handle.change((doc) => {
|
||||
applyTLStoreChangesToAutomerge(doc, queuedChanges)
|
||||
})
|
||||
// Trigger sync to broadcast eraser changes
|
||||
const changedRecords = [
|
||||
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
|
||||
const addedOrUpdatedRecords = [
|
||||
...Object.values(queuedChanges.added || {}),
|
||||
...Object.values(queuedChanges.updated || {}),
|
||||
...Object.values(queuedChanges.removed || {})
|
||||
...Object.values(queuedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
|
||||
]
|
||||
broadcastJsonSync(changedRecords)
|
||||
const deletedRecordIds = Object.keys(queuedChanges.removed || {})
|
||||
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
|
||||
} catch (error) {
|
||||
console.error('❌ Error applying queued eraser changes:', error)
|
||||
}
|
||||
|
|
@ -1168,49 +1222,37 @@ export function useAutomergeStoreV2({
|
|||
removed: { ...(queuedChanges.removed || {}), ...(finalFilteredChanges.removed || {}) }
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isLocalChange = true
|
||||
// Apply immediately for real-time sync
|
||||
pendingLocalChanges++
|
||||
handle.change((doc) => {
|
||||
applyTLStoreChangesToAutomerge(doc, mergedChanges)
|
||||
})
|
||||
// Trigger sync to broadcast merged changes
|
||||
const changedRecords = [
|
||||
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
|
||||
const addedOrUpdatedRecords = [
|
||||
...Object.values(mergedChanges.added || {}),
|
||||
...Object.values(mergedChanges.updated || {}),
|
||||
...Object.values(mergedChanges.removed || {})
|
||||
...Object.values(mergedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
|
||||
]
|
||||
broadcastJsonSync(changedRecords)
|
||||
})
|
||||
const deletedRecordIds = Object.keys(mergedChanges.removed || {})
|
||||
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
|
||||
|
||||
return
|
||||
}
|
||||
// OPTIMIZED: Use requestIdleCallback to defer Automerge changes when browser is idle
|
||||
// This prevents blocking mouse interactions without queuing changes
|
||||
const applyChanges = () => {
|
||||
// Mark to prevent feedback loop when this change comes back from Automerge
|
||||
isLocalChange = true
|
||||
|
||||
// Apply changes immediately for real-time sync (no deferral)
|
||||
// The old requestIdleCallback approach caused multi-second delays
|
||||
pendingLocalChanges++
|
||||
handle.change((doc) => {
|
||||
applyTLStoreChangesToAutomerge(doc, finalFilteredChanges)
|
||||
})
|
||||
|
||||
// CRITICAL: Manually trigger JSON sync broadcast to other clients
|
||||
// Use requestAnimationFrame to defer this slightly so the change is fully processed
|
||||
const changedRecords = [
|
||||
// CRITICAL: Broadcast immediately for real-time collaboration
|
||||
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
|
||||
const addedOrUpdatedRecords = [
|
||||
...Object.values(finalFilteredChanges.added || {}),
|
||||
...Object.values(finalFilteredChanges.updated || {}),
|
||||
...Object.values(finalFilteredChanges.removed || {})
|
||||
...Object.values(finalFilteredChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
|
||||
]
|
||||
requestAnimationFrame(() => broadcastJsonSync(changedRecords))
|
||||
}
|
||||
|
||||
// Use requestIdleCallback if available to apply changes when browser is idle
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(applyChanges, { timeout: 100 })
|
||||
} else {
|
||||
// Fallback: use requestAnimationFrame for next frame
|
||||
requestAnimationFrame(applyChanges)
|
||||
}
|
||||
const deletedRecordIds = Object.keys(finalFilteredChanges.removed || {})
|
||||
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
|
||||
}
|
||||
|
||||
// Only log if there are many changes or if debugging is needed
|
||||
|
|
@ -1255,7 +1297,7 @@ export function useAutomergeStoreV2({
|
|||
const queuedChanges = eraserChangeQueue
|
||||
eraserChangeQueue = null
|
||||
if (handle) {
|
||||
isLocalChange = true
|
||||
pendingLocalChanges++
|
||||
handle.change((doc) => {
|
||||
applyTLStoreChangesToAutomerge(doc, queuedChanges)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -94,41 +94,59 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
// 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 applyJsonSyncData = useCallback((data: TLStoreSnapshot & { deleted?: string[] }) => {
|
||||
const currentHandle = handleRef.current
|
||||
if (!currentHandle || !data?.store) {
|
||||
if (!currentHandle || (!data?.store && !data?.deleted)) {
|
||||
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`)
|
||||
const changedRecordCount = data.store ? Object.keys(data.store).length : 0
|
||||
const shapeRecords = data.store ? Object.values(data.store).filter((r: any) => r?.typeName === 'shape') : []
|
||||
const deletedRecordIds = data.deleted || []
|
||||
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
|
||||
|
||||
// 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
|
||||
// Log incoming sync data for debugging
|
||||
console.log(`📥 Received JSON sync: ${changedRecordCount} records (${shapeRecords.length} shapes), ${deletedRecordIds.length} deletions (${deletedShapes.length} shapes)`)
|
||||
if (shapeRecords.length > 0) {
|
||||
shapeRecords.forEach((shape: any) => {
|
||||
console.log(`📥 Shape update: ${shape.type} ${shape.id}`, {
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
w: shape.props?.w,
|
||||
h: shape.props?.h
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
if (deletedShapes.length > 0) {
|
||||
console.log(`📥 Shape deletions:`, deletedShapes)
|
||||
}
|
||||
|
||||
// Apply changes to the Automerge document
|
||||
// This will trigger patches which will update the TLDraw store
|
||||
// NOTE: We do NOT increment pendingLocalChanges here because these are REMOTE changes
|
||||
// that we WANT to be processed by automergeChangeHandler and applied to the store
|
||||
currentHandle.change((doc: any) => {
|
||||
if (!doc.store) {
|
||||
doc.store = {}
|
||||
}
|
||||
// Merge the changed records into the Automerge document
|
||||
if (data.store) {
|
||||
Object.entries(data.store).forEach(([id, record]) => {
|
||||
doc.store[id] = record
|
||||
})
|
||||
}
|
||||
// Delete records that were removed on the other client
|
||||
if (deletedRecordIds.length > 0) {
|
||||
deletedRecordIds.forEach(id => {
|
||||
if (doc.store[id]) {
|
||||
delete doc.store[id]
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✅ Applied ${changedRecordCount} records to Automerge document - patches will update store`)
|
||||
console.log(`✅ Applied ${changedRecordCount} records and ${deletedRecordIds.length} deletions to Automerge document`)
|
||||
}, [])
|
||||
|
||||
// Presence update callback - applies presence from other clients
|
||||
|
|
@ -189,7 +207,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
currentStore.put([instancePresence])
|
||||
})
|
||||
|
||||
console.log(`✅ Applied instance_presence for remote user ${userId}`)
|
||||
// Presence applied for remote user
|
||||
} catch (error) {
|
||||
console.error('❌ Error applying presence:', error)
|
||||
}
|
||||
|
|
@ -214,7 +232,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
// Log when sync messages are sent/received
|
||||
adapter.on('message', (msg: any) => {
|
||||
console.log('🔄 CloudflareAdapter received message from network:', msg.type)
|
||||
// Message received from network
|
||||
})
|
||||
|
||||
return { repo, adapter }
|
||||
|
|
@ -226,13 +244,9 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
const initializeHandle = async () => {
|
||||
try {
|
||||
console.log("🔌 Initializing Automerge Repo with NetworkAdapter for room:", roomId)
|
||||
|
||||
// CRITICAL: Wait for the network adapter to be ready before creating document
|
||||
// This ensures the WebSocket connection is established for sync
|
||||
console.log("⏳ Waiting for network adapter to be ready...")
|
||||
await adapter.whenReady()
|
||||
console.log("✅ Network adapter is ready, WebSocket connected")
|
||||
|
||||
if (mounted) {
|
||||
// CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID)
|
||||
|
|
@ -240,18 +254,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
// The network adapter broadcasts sync messages between all clients in the same room
|
||||
const handle = repo.create<TLStoreSnapshot>()
|
||||
|
||||
console.log("Created Automerge handle via Repo:", {
|
||||
handleId: handle.documentId,
|
||||
isReady: handle.isReady(),
|
||||
roomId: roomId
|
||||
})
|
||||
|
||||
// Wait for the handle to be ready
|
||||
await handle.whenReady()
|
||||
|
||||
// CRITICAL: Always load initial data from the server
|
||||
// The server stores documents in R2 as JSON, so we need to load and initialize the Automerge document
|
||||
console.log("📥 Loading initial data from server...")
|
||||
try {
|
||||
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||
if (response.ok) {
|
||||
|
|
@ -259,7 +266,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||
|
||||
console.log(`📥 Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`)
|
||||
// Document loaded from server
|
||||
|
||||
// Initialize the Automerge document with server data
|
||||
if (serverDoc.store && serverRecordCount > 0) {
|
||||
|
|
@ -274,12 +281,12 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
})
|
||||
})
|
||||
|
||||
console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`)
|
||||
// Initialized Automerge document from server
|
||||
} else {
|
||||
console.log("📥 Server document is empty - starting with empty Automerge document")
|
||||
// Server document is empty - starting fresh
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
console.log("📥 No document found on server (404) - starting with empty document")
|
||||
// No document found on server - starting fresh
|
||||
} else {
|
||||
console.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
|
@ -293,15 +300,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
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 and ready:", {
|
||||
handleId: handle.documentId,
|
||||
isReady: handle.isReady(),
|
||||
hasDoc: !!finalDoc,
|
||||
storeKeys: finalStoreKeys,
|
||||
shapeCount: finalShapeCount,
|
||||
roomId: roomId
|
||||
})
|
||||
|
||||
// Automerge handle initialized and ready
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,15 +85,21 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
|||
<button
|
||||
onClick={handleStarToggle}
|
||||
disabled={isLoading}
|
||||
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
||||
className={`toolbar-btn star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
||||
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading-spinner">⏳</span>
|
||||
) : isStarred ? (
|
||||
<span className="star-icon starred">⭐</span>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
|
||||
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="star-icon">☆</span>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
{isStarred ? (
|
||||
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
|
||||
) : (
|
||||
<path d="M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.565.565 0 0 0-.163-.505L1.71 6.745l4.052-.576a.525.525 0 0 0 .393-.288L8 2.223l1.847 3.658a.525.525 0 0 0 .393.288l4.052.575-2.906 2.77a.565.565 0 0 0-.163.506l.694 3.957-3.686-1.894a.503.503 0 0 0-.461 0z"/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,14 @@ const LoginButton: React.FC<LoginButtonProps> = ({ className = '' }) => {
|
|||
<>
|
||||
<button
|
||||
onClick={handleLoginClick}
|
||||
className={`login-button ${className}`}
|
||||
className={`toolbar-btn login-button ${className}`}
|
||||
title="Sign in to save your work and access additional features"
|
||||
>
|
||||
Sign In
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M6 3.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 6.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-8A1.5 1.5 0 0 0 5 3.5v2a.5.5 0 0 0 1 0v-2z"/>
|
||||
<path fillRule="evenodd" d="M11.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H1.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
|
||||
</svg>
|
||||
<span>Sign In</span>
|
||||
</button>
|
||||
|
||||
{showLogin && (
|
||||
|
|
|
|||
|
|
@ -276,11 +276,7 @@
|
|||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Adjust toolbar container position on mobile */
|
||||
.toolbar-container {
|
||||
right: 35px !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
/* Note: toolbar-container positioning is now handled in style.css */
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
|
|
@ -472,52 +468,9 @@ html.dark .user-status {
|
|||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Login Button Styles */
|
||||
/* Login Button Styles - extends .toolbar-btn */
|
||||
.login-button {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.toolbar-login-button {
|
||||
margin-right: 0;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toolbar-login-button:hover {
|
||||
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
/* Base styles come from .toolbar-btn */
|
||||
}
|
||||
|
||||
/* Login Modal Overlay */
|
||||
|
|
|
|||
|
|
@ -1,26 +1,6 @@
|
|||
/* Star Board Button Styles */
|
||||
/* Star Board Button Styles - extends .toolbar-btn */
|
||||
.star-board-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
/* Base styles come from .toolbar-btn */
|
||||
}
|
||||
|
||||
/* Custom popup notification styles */
|
||||
|
|
@ -64,48 +44,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Toolbar-specific star button styling to match login button exactly */
|
||||
.toolbar-star-button {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.5px;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.star-board-button:hover {
|
||||
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.toolbar-star-button:hover {
|
||||
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Starred state for star button */
|
||||
.star-board-button.starred {
|
||||
background: #6B7280;
|
||||
color: white;
|
||||
background: var(--tool-bg);
|
||||
border-color: #eab308;
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.star-board-button.starred:hover {
|
||||
background: #4B5563;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
|
||||
.star-board-button:disabled {
|
||||
|
|
|
|||
|
|
@ -906,3 +906,589 @@ input[type="submit"],
|
|||
.embed-container button:hover {
|
||||
background-color: var(--hover-bg) !important;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Mycelial Intelligence Styles
|
||||
======================================== */
|
||||
|
||||
/* Mycelium network path animation */
|
||||
.mycelium-path {
|
||||
animation: mycelium-flow 4s ease-in-out infinite;
|
||||
stroke-dasharray: 10 5;
|
||||
}
|
||||
|
||||
@keyframes mycelium-flow {
|
||||
0%, 100% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: 30;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading dots animation */
|
||||
.loading-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.3;
|
||||
animation: loading-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Typing cursor blink */
|
||||
@keyframes blink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Voice recording pulse animation */
|
||||
@keyframes voice-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 15px rgba(0, 255, 136, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-recording {
|
||||
animation: voice-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Glow effect on hover for MI buttons */
|
||||
.mi-button:hover {
|
||||
filter: drop-shadow(0 0 10px rgba(0, 255, 136, 0.5));
|
||||
}
|
||||
|
||||
/* Mycelial Intelligence input focus state */
|
||||
.mi-input:focus {
|
||||
border-color: #00ff88 !important;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for MI chat */
|
||||
.mi-chat-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.mi-chat-container::-webkit-scrollbar-track {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mi-chat-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 136, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mi-chat-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Toolbar and Share Zone Alignment
|
||||
======================================== */
|
||||
|
||||
/* Position the share zone (people menu) to not overlap with custom toolbar */
|
||||
.tlui-share-zone {
|
||||
position: fixed !important;
|
||||
top: 4px !important;
|
||||
right: 8px !important;
|
||||
z-index: 99998 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Custom people menu styling */
|
||||
.custom-people-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
html.dark .custom-people-menu {
|
||||
background: rgba(45, 55, 72, 0.9);
|
||||
}
|
||||
|
||||
/* Ensure custom toolbar buttons don't overlap with share zone */
|
||||
.toolbar-container {
|
||||
position: fixed !important;
|
||||
top: 4px !important;
|
||||
/* Adjust right position to leave room for people menu (about 80px) */
|
||||
right: 90px !important;
|
||||
z-index: 99999 !important;
|
||||
display: flex !important;
|
||||
gap: 6px !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Unified Toolbar Button Styles
|
||||
======================================== */
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
background: var(--tool-bg);
|
||||
color: var(--tool-text);
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.toolbar-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-btn {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Profile Dropdown Styles
|
||||
======================================== */
|
||||
|
||||
.profile-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 240px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--tool-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--tool-text);
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
font-size: 11px;
|
||||
color: var(--tool-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profile-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-dropdown-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.profile-dropdown-item svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profile-dropdown-item.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.profile-dropdown-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.profile-dropdown-warning {
|
||||
padding: 10px 16px;
|
||||
font-size: 11px;
|
||||
color: #d97706;
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
border-left: 3px solid #d97706;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Settings Modal Styles
|
||||
======================================== */
|
||||
|
||||
.settings-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100001;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--tool-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-close-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--tool-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-item-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.settings-item-description {
|
||||
font-size: 12px;
|
||||
color: var(--tool-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.settings-item-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.settings-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--tool-bg);
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: 6px;
|
||||
color: var(--tool-text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-toggle-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-action-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-action-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.settings-action-btn.secondary {
|
||||
background: var(--tool-bg);
|
||||
color: var(--tool-text);
|
||||
border: 1px solid var(--tool-border);
|
||||
}
|
||||
|
||||
.settings-action-btn.secondary:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.settings-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.settings-input-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-btn-sm {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--tool-bg);
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: 6px;
|
||||
color: var(--tool-text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-btn-sm:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.settings-btn-sm.primary {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.settings-btn-sm.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.settings-button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-button-group .settings-action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.toolbar-container {
|
||||
right: 70px !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.tlui-share-zone {
|
||||
right: 4px !important;
|
||||
}
|
||||
|
||||
.custom-people-menu {
|
||||
padding: 2px 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
max-width: calc(100% - 32px);
|
||||
margin: 16px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,33 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { Editor, TLShapeId } from 'tldraw'
|
||||
import { Editor, TLShapeId, getIndexAbove } from 'tldraw'
|
||||
|
||||
export interface PinnedViewOptions {
|
||||
/**
|
||||
* The position to pin the shape at.
|
||||
* - 'current': Keep at current screen position (default)
|
||||
* - 'top-center': Pin to top center of viewport
|
||||
* - 'bottom-center': Pin to bottom center of viewport
|
||||
* - 'center': Pin to center of viewport
|
||||
*/
|
||||
position?: 'current' | 'top-center' | 'bottom-center' | 'center'
|
||||
/**
|
||||
* Offset from the edge (for top-center, bottom-center positions)
|
||||
*/
|
||||
offsetY?: number
|
||||
offsetX?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage shapes pinned to the viewport.
|
||||
* When a shape is pinned, it stays in the same screen position as the camera moves.
|
||||
*/
|
||||
export function usePinnedToView(editor: Editor | null, shapeId: string | undefined, isPinned: boolean) {
|
||||
export function usePinnedToView(
|
||||
editor: Editor | null,
|
||||
shapeId: string | undefined,
|
||||
isPinned: boolean,
|
||||
options: PinnedViewOptions = {}
|
||||
) {
|
||||
const { position = 'current', offsetY = 0, offsetX = 0 } = options
|
||||
const pinnedScreenPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const originalCoordinatesRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const originalSizeRef = useRef<{ w: number; h: number } | null>(null)
|
||||
|
|
@ -39,67 +61,58 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin
|
|||
}
|
||||
originalZoomRef.current = currentCamera.z
|
||||
|
||||
// Get the shape's current page position (top-left corner)
|
||||
// Calculate screen position based on position option
|
||||
let screenPoint: { x: number; y: number }
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const shapeWidth = (shape.props as any).w || 0
|
||||
const shapeHeight = (shape.props as any).h || 0
|
||||
|
||||
if (position === 'top-center') {
|
||||
// Center horizontally at the top of the viewport
|
||||
screenPoint = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + offsetY,
|
||||
}
|
||||
} else if (position === 'bottom-center') {
|
||||
// Center horizontally at the bottom of the viewport
|
||||
screenPoint = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY,
|
||||
}
|
||||
} else if (position === 'center') {
|
||||
// Center in the viewport
|
||||
screenPoint = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY,
|
||||
}
|
||||
} else {
|
||||
// Default: use current position
|
||||
const pagePoint = { x: shape.x, y: shape.y }
|
||||
// Convert to screen coordinates - this is where we want the shape to stay
|
||||
const screenPoint = editor.pageToScreen(pagePoint)
|
||||
screenPoint = editor.pageToScreen(pagePoint)
|
||||
}
|
||||
|
||||
pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y }
|
||||
lastCameraRef.current = { ...currentCamera }
|
||||
|
||||
// Bring the shape to the front by setting its index higher than all other shapes
|
||||
// Bring the shape to the front using tldraw's proper index functions
|
||||
try {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
let highestIndex = 'a0'
|
||||
|
||||
// Find the highest index among all shapes
|
||||
let highestIndex = shape.index
|
||||
for (const s of allShapes) {
|
||||
if (s.index && typeof s.index === 'string') {
|
||||
// Compare string indices (fractional indexing)
|
||||
// Higher alphabetical order = higher z-index
|
||||
if (s.index > highestIndex) {
|
||||
if (s.id !== shape.id && s.index > highestIndex) {
|
||||
highestIndex = s.index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bring the shape to the front by manually setting index
|
||||
// Note: sendToFront doesn't exist in this version of tldraw, so we use manual index setting
|
||||
// Try to set a safe index value
|
||||
// Use conservative values that are known to work (a1, a2, b1, etc.)
|
||||
let newIndex: string = 'a2' // Safe default
|
||||
|
||||
// Try to find a valid index higher than existing ones
|
||||
const allIndices = allShapes
|
||||
.map(s => s.index)
|
||||
.filter((idx): idx is any => typeof idx === 'string' && /^[a-z]\d+$/.test(idx))
|
||||
.sort()
|
||||
|
||||
if (allIndices.length > 0) {
|
||||
const highest = allIndices[allIndices.length - 1]
|
||||
const match = highest.match(/^([a-z])(\d+)$/)
|
||||
if (match) {
|
||||
const letter = match[1]
|
||||
const num = parseInt(match[2], 10)
|
||||
// Increment number, or move to next letter if number gets too high
|
||||
if (num < 100) {
|
||||
newIndex = `${letter}${num + 1}`
|
||||
} else if (letter < 'y') {
|
||||
const nextLetter = String.fromCharCode(letter.charCodeAt(0) + 1)
|
||||
newIndex = `${nextLetter}1`
|
||||
} else {
|
||||
// Use a safe value if we're running out of letters
|
||||
newIndex = 'a2'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate before using
|
||||
if (/^[a-z]\d+$/.test(newIndex)) {
|
||||
// Only update if we need to move higher
|
||||
if (highestIndex > shape.index) {
|
||||
const newIndex = getIndexAbove(highestIndex)
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: shape.type,
|
||||
index: newIndex as any,
|
||||
index: newIndex,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -268,14 +281,43 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin
|
|||
return
|
||||
}
|
||||
|
||||
const pinnedScreenPos = pinnedScreenPositionRef.current
|
||||
if (!pinnedScreenPos) {
|
||||
const currentCamera = editor.getCamera()
|
||||
const lastCamera = lastCameraRef.current
|
||||
|
||||
// For preset positions (top-center, etc.), always recalculate based on viewport
|
||||
// For 'current' position, use the stored screen position
|
||||
let pinnedScreenPos: { x: number; y: number }
|
||||
|
||||
if (position !== 'current') {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const shapeWidth = (currentShape.props as any).w || 0
|
||||
const shapeHeight = (currentShape.props as any).h || 0
|
||||
|
||||
if (position === 'top-center') {
|
||||
pinnedScreenPos = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + offsetY,
|
||||
}
|
||||
} else if (position === 'bottom-center') {
|
||||
pinnedScreenPos = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY,
|
||||
}
|
||||
} else if (position === 'center') {
|
||||
pinnedScreenPos = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY,
|
||||
}
|
||||
} else {
|
||||
pinnedScreenPos = pinnedScreenPositionRef.current!
|
||||
}
|
||||
} else {
|
||||
if (!pinnedScreenPositionRef.current) {
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
return
|
||||
}
|
||||
|
||||
const currentCamera = editor.getCamera()
|
||||
const lastCamera = lastCameraRef.current
|
||||
pinnedScreenPos = pinnedScreenPositionRef.current
|
||||
}
|
||||
|
||||
// Check if camera has changed significantly
|
||||
const cameraChanged = !lastCamera || (
|
||||
|
|
@ -284,7 +326,10 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin
|
|||
Math.abs(currentCamera.z - lastCamera.z) > 0.001
|
||||
)
|
||||
|
||||
if (cameraChanged) {
|
||||
// For preset positions, always check for updates (viewport might have changed)
|
||||
const shouldUpdate = cameraChanged || position !== 'current'
|
||||
|
||||
if (shouldUpdate) {
|
||||
// Throttle updates to max 60fps (every ~16ms)
|
||||
const timeSinceLastUpdate = timestamp - lastUpdateTimeRef.current
|
||||
const minUpdateInterval = 16 // ~60fps
|
||||
|
|
@ -405,6 +450,6 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin
|
|||
}
|
||||
editor.off('change' as any, handleShapeChange)
|
||||
}
|
||||
}, [editor, shapeId, isPinned])
|
||||
}, [editor, shapeId, isPinned, position, offsetX, offsetY])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, useTldrawUser, TLUserPreferences } from "tldraw"
|
||||
import { Tldraw, Editor, TLShapeId, TLRecord, useTldrawUser, TLUserPreferences, createShapeId } from "tldraw"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { ChatBoxTool } from "@/tools/ChatBoxTool"
|
||||
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
|
||||
|
|
@ -46,6 +46,8 @@ import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
|||
import { VideoGenTool } from "@/tools/VideoGenTool"
|
||||
import { MultmuxTool } from "@/tools/MultmuxTool"
|
||||
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
||||
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
|
||||
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
|
||||
import {
|
||||
lockElement,
|
||||
unlockElement,
|
||||
|
|
@ -87,6 +89,7 @@ const customShapeUtils = [
|
|||
ImageGenShape,
|
||||
VideoGenShape,
|
||||
MultmuxShape,
|
||||
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
||||
]
|
||||
const customTools = [
|
||||
ChatBoxTool,
|
||||
|
|
@ -107,8 +110,7 @@ const customTools = [
|
|||
]
|
||||
|
||||
// Debug: Log tool and shape registration info
|
||||
console.log('🔧 Board: Custom tools registered:', customTools.map(t => ({ id: t.id, shapeType: t.prototype?.shapeType })))
|
||||
console.log('🔧 Board: Custom shapes registered:', customShapeUtils.map(s => ({ type: s.type })))
|
||||
// Custom tools and shapes registered
|
||||
|
||||
export function Board() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
|
|
@ -961,6 +963,47 @@ export function Board() {
|
|||
initializeGlobalCollections(editor, collections)
|
||||
// Note: User presence is configured through the useAutomergeSync hook above
|
||||
// The authenticated username should appear in the people section
|
||||
|
||||
// Auto-create Mycelial Intelligence shape on page load if not present
|
||||
// Use a flag to ensure this only runs once per session
|
||||
const miCreatedKey = `mi-created-${roomId}`
|
||||
const alreadyCreatedThisSession = sessionStorage.getItem(miCreatedKey)
|
||||
|
||||
if (!alreadyCreatedThisSession) {
|
||||
setTimeout(() => {
|
||||
const existingMI = editor.getCurrentPageShapes().find(s => s.type === 'MycelialIntelligence')
|
||||
if (!existingMI) {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const miWidth = 520
|
||||
const screenCenter = {
|
||||
x: viewport.x + (viewport.w / 2) - (miWidth / 2),
|
||||
y: viewport.y + 20, // 20px from top
|
||||
}
|
||||
const pagePoint = editor.screenToPage(screenCenter)
|
||||
|
||||
editor.createShape({
|
||||
id: createShapeId(),
|
||||
type: 'MycelialIntelligence',
|
||||
x: pagePoint.x,
|
||||
y: pagePoint.y,
|
||||
props: {
|
||||
w: miWidth,
|
||||
h: 52,
|
||||
prompt: '',
|
||||
response: '',
|
||||
isLoading: false,
|
||||
isListening: false,
|
||||
isExpanded: false,
|
||||
conversationHistory: [],
|
||||
pinnedToView: true,
|
||||
indexingProgress: 0,
|
||||
isIndexing: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
sessionStorage.setItem(miCreatedKey, 'true')
|
||||
}, 500) // Small delay to ensure editor is ready
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CmdK />
|
||||
|
|
|
|||
|
|
@ -351,7 +351,8 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
throw new Error("RunPod API key not configured. Please set VITE_RUNPOD_API_KEY environment variable.")
|
||||
}
|
||||
|
||||
const url = `https://api.runpod.ai/v2/${endpointId}/run`
|
||||
// Use runsync for synchronous execution - returns output directly without polling
|
||||
const url = `https://api.runpod.ai/v2/${endpointId}/runsync`
|
||||
|
||||
console.log("📤 ImageGen: Sending request to:", url)
|
||||
|
||||
|
|
@ -375,27 +376,25 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}
|
||||
|
||||
const data = await response.json() as RunPodJobResponse
|
||||
console.log("📥 ImageGen: Response data:", JSON.stringify(data, null, 2))
|
||||
console.log("📥 ImageGen: Response data:", JSON.stringify(data, null, 2).substring(0, 500) + '...')
|
||||
|
||||
// Handle async job pattern (RunPod often returns job IDs)
|
||||
if (data.id && (data.status === 'IN_QUEUE' || data.status === 'IN_PROGRESS' || data.status === 'STARTING')) {
|
||||
console.log("⏳ ImageGen: Job queued/in progress, polling job ID:", data.id)
|
||||
const imageUrl = await pollRunPodJob(data.id, apiKey, endpointId)
|
||||
console.log("✅ ImageGen: Job completed, image URL:", imageUrl)
|
||||
|
||||
this.editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
imageUrl: imageUrl,
|
||||
isLoading: false,
|
||||
error: null
|
||||
},
|
||||
})
|
||||
} else if (data.output) {
|
||||
// Handle direct response
|
||||
// With runsync, we get the output directly (no polling needed)
|
||||
if (data.output) {
|
||||
let imageUrl = ''
|
||||
if (typeof data.output === 'string') {
|
||||
|
||||
// Handle output.images array format (Automatic1111 endpoint format)
|
||||
if (Array.isArray(data.output.images) && data.output.images.length > 0) {
|
||||
const firstImage = data.output.images[0]
|
||||
// Base64 encoded image string
|
||||
if (typeof firstImage === 'string') {
|
||||
imageUrl = firstImage.startsWith('data:') ? firstImage : `data:image/png;base64,${firstImage}`
|
||||
console.log('✅ ImageGen: Found base64 image in output.images array')
|
||||
} else if (firstImage.data) {
|
||||
imageUrl = firstImage.data.startsWith('data:') ? firstImage.data : `data:image/png;base64,${firstImage.data}`
|
||||
} else if (firstImage.url) {
|
||||
imageUrl = firstImage.url
|
||||
}
|
||||
} else if (typeof data.output === 'string') {
|
||||
imageUrl = data.output
|
||||
} else if (data.output.image) {
|
||||
imageUrl = data.output.image
|
||||
|
|
@ -404,7 +403,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
} else if (Array.isArray(data.output) && data.output.length > 0) {
|
||||
const firstItem = data.output[0]
|
||||
if (typeof firstItem === 'string') {
|
||||
imageUrl = firstItem
|
||||
imageUrl = firstItem.startsWith('data:') ? firstItem : `data:image/png;base64,${firstItem}`
|
||||
} else if (firstItem.image) {
|
||||
imageUrl = firstItem.image
|
||||
} else if (firstItem.url) {
|
||||
|
|
@ -413,6 +412,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}
|
||||
|
||||
if (imageUrl) {
|
||||
console.log('✅ ImageGen: Image generated successfully')
|
||||
this.editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
|
|
@ -423,12 +423,12 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
},
|
||||
})
|
||||
} else {
|
||||
throw new Error("No image URL found in response")
|
||||
throw new Error("No image URL found in response output")
|
||||
}
|
||||
} else if (data.error) {
|
||||
throw new Error(`RunPod API error: ${data.error}`)
|
||||
} else {
|
||||
throw new Error("No valid response from RunPod API")
|
||||
throw new Error("No valid response from RunPod API - missing output field")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, Geometry2d, Rectangle2d } from 'tldraw'
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, Geometry2d, Rectangle2d, T, createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw'
|
||||
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
|
||||
import { usePinnedToView } from '../hooks/usePinnedToView'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
export type IMultmuxShape = TLBaseShape<
|
||||
'Multmux',
|
||||
|
|
@ -12,7 +15,6 @@ export type IMultmuxShape = TLBaseShape<
|
|||
sessionName: string
|
||||
token: string
|
||||
serverUrl: string
|
||||
wsUrl: string
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
|
|
@ -24,9 +26,81 @@ interface SessionResponse {
|
|||
token: string
|
||||
}
|
||||
|
||||
interface SessionListItem {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
clientCount: number
|
||||
}
|
||||
|
||||
// Helper to convert HTTP URL to WebSocket URL
|
||||
function httpToWs(httpUrl: string): string {
|
||||
return httpUrl
|
||||
.replace(/^http:/, 'ws:')
|
||||
.replace(/^https:/, 'wss:')
|
||||
.replace(/\/?$/, '/ws')
|
||||
}
|
||||
|
||||
// Migration versions for Multmux shape
|
||||
const versions = createShapePropsMigrationIds('Multmux', {
|
||||
AddMissingProps: 1,
|
||||
RemoveWsUrl: 2,
|
||||
})
|
||||
|
||||
// Migrations to handle shapes with missing/undefined props
|
||||
export const multmuxShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
id: versions.AddMissingProps,
|
||||
up: (props: any) => {
|
||||
return {
|
||||
w: props.w ?? 800,
|
||||
h: props.h ?? 600,
|
||||
sessionId: props.sessionId ?? '',
|
||||
sessionName: props.sessionName ?? 'New Terminal',
|
||||
token: props.token ?? '',
|
||||
serverUrl: props.serverUrl ?? 'http://localhost:3002',
|
||||
wsUrl: props.wsUrl ?? 'ws://localhost:3002',
|
||||
pinnedToView: props.pinnedToView ?? false,
|
||||
tags: Array.isArray(props.tags) ? props.tags : ['terminal', 'multmux'],
|
||||
}
|
||||
},
|
||||
down: (props: any) => props,
|
||||
},
|
||||
{
|
||||
id: versions.RemoveWsUrl,
|
||||
up: (props: any) => {
|
||||
// Remove wsUrl, it's now derived from serverUrl
|
||||
const { wsUrl, ...rest } = props
|
||||
return {
|
||||
...rest,
|
||||
serverUrl: rest.serverUrl ?? 'http://localhost:3002',
|
||||
}
|
||||
},
|
||||
down: (props: any) => ({
|
||||
...props,
|
||||
wsUrl: httpToWs(props.serverUrl || 'http://localhost:3002'),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
||||
static override type = 'Multmux' as const
|
||||
|
||||
static override props = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
sessionId: T.string,
|
||||
sessionName: T.string,
|
||||
token: T.string,
|
||||
serverUrl: T.string,
|
||||
pinnedToView: T.boolean,
|
||||
tags: T.arrayOf(T.string),
|
||||
}
|
||||
|
||||
static override migrations = multmuxShapeMigrations
|
||||
|
||||
// Terminal theme color: Dark purple/violet
|
||||
static readonly PRIMARY_COLOR = "#8b5cf6"
|
||||
|
||||
|
|
@ -35,10 +109,9 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
w: 800,
|
||||
h: 600,
|
||||
sessionId: '',
|
||||
sessionName: 'New Terminal',
|
||||
sessionName: '',
|
||||
token: '',
|
||||
serverUrl: 'http://localhost:3000',
|
||||
wsUrl: 'ws://localhost:3001',
|
||||
serverUrl: 'http://localhost:3002',
|
||||
pinnedToView: false,
|
||||
tags: ['terminal', 'multmux'],
|
||||
}
|
||||
|
|
@ -56,11 +129,14 @@ export class MultmuxShape extends BaseBoxShapeUtil<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 [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
||||
const [loadingSessions, setLoadingSessions] = useState(false)
|
||||
const [sessionName, setSessionName] = useState('')
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const xtermRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
|
||||
// Use the pinning hook
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
|
@ -87,17 +163,96 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
})
|
||||
}
|
||||
|
||||
// WebSocket connection
|
||||
// Fetch available sessions
|
||||
const fetchSessions = async () => {
|
||||
setLoadingSessions(true)
|
||||
try {
|
||||
const response = await fetch(`${shape.props.serverUrl}/api/sessions`)
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { sessions?: SessionListItem[] }
|
||||
setSessions(data.sessions || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sessions:', error)
|
||||
} finally {
|
||||
setLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize xterm.js terminal
|
||||
useEffect(() => {
|
||||
if (!shape.props.token || !shape.props.wsUrl) {
|
||||
if (!shape.props.token || !terminalRef.current || xtermRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const websocket = new WebSocket(`${shape.props.wsUrl}?token=${shape.props.token}`)
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e2e',
|
||||
foreground: '#cdd6f4',
|
||||
cursor: '#f5e0dc',
|
||||
cursorAccent: '#1e1e2e',
|
||||
black: '#45475a',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#cba6f7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#bac2de',
|
||||
brightBlack: '#585b70',
|
||||
brightRed: '#f38ba8',
|
||||
brightGreen: '#a6e3a1',
|
||||
brightYellow: '#f9e2af',
|
||||
brightBlue: '#89b4fa',
|
||||
brightMagenta: '#cba6f7',
|
||||
brightCyan: '#94e2d5',
|
||||
brightWhite: '#a6adc8',
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(terminalRef.current)
|
||||
|
||||
// Small delay to ensure container is sized
|
||||
setTimeout(() => {
|
||||
fitAddon.fit()
|
||||
}, 100)
|
||||
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
return () => {
|
||||
term.dispose()
|
||||
xtermRef.current = null
|
||||
fitAddonRef.current = null
|
||||
}
|
||||
}, [shape.props.token])
|
||||
|
||||
// Fit terminal when shape resizes
|
||||
useEffect(() => {
|
||||
if (fitAddonRef.current && xtermRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit()
|
||||
}, 50)
|
||||
}
|
||||
}, [shape.props.w, shape.props.h, isMinimized])
|
||||
|
||||
// WebSocket connection
|
||||
useEffect(() => {
|
||||
if (!shape.props.token || !shape.props.serverUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = httpToWs(shape.props.serverUrl)
|
||||
const websocket = new WebSocket(`${wsUrl}?token=${shape.props.token}`)
|
||||
|
||||
websocket.onopen = () => {
|
||||
setConnected(true)
|
||||
setOutput(prev => [...prev, '✓ Connected to terminal session'])
|
||||
xtermRef.current?.writeln('\r\n\x1b[32m✓ Connected to terminal session\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
|
|
@ -106,21 +261,23 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
|
||||
switch (message.type) {
|
||||
case 'output':
|
||||
setOutput(prev => [...prev, message.data])
|
||||
// Write terminal output directly to xterm
|
||||
xtermRef.current?.write(message.data)
|
||||
break
|
||||
case 'joined':
|
||||
setOutput(prev => [...prev, `✓ Joined session: ${message.sessionName}`])
|
||||
xtermRef.current?.writeln(`\r\n\x1b[32m✓ Joined session: ${message.sessionName}\x1b[0m\r\n`)
|
||||
break
|
||||
case 'presence':
|
||||
if (message.data.action === 'join') {
|
||||
setOutput(prev => [...prev, `→ User joined (${message.data.totalClients} total)`])
|
||||
xtermRef.current?.writeln(`\r\n\x1b[33m→ User joined (${message.data.totalClients} total)\x1b[0m`)
|
||||
} else if (message.data.action === 'leave') {
|
||||
setOutput(prev => [...prev, `← User left (${message.data.totalClients} total)`])
|
||||
xtermRef.current?.writeln(`\r\n\x1b[33m← User left (${message.data.totalClients} total)\x1b[0m`)
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
setOutput(prev => [...prev, `✗ Error: ${message.message}`])
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m✗ Error: ${message.message}\x1b[0m\r\n`)
|
||||
break
|
||||
// Ignore 'input' messages from other clients (they're just for awareness)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
|
|
@ -129,13 +286,13 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
setOutput(prev => [...prev, '✗ Connection error'])
|
||||
xtermRef.current?.writeln('\r\n\x1b[31m✗ Connection error\x1b[0m\r\n')
|
||||
setConnected(false)
|
||||
}
|
||||
|
||||
websocket.onclose = () => {
|
||||
setConnected(false)
|
||||
setOutput(prev => [...prev, '✗ Connection closed'])
|
||||
xtermRef.current?.writeln('\r\n\x1b[31m✗ Connection closed\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
setWs(websocket)
|
||||
|
|
@ -143,28 +300,26 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
return () => {
|
||||
websocket.close()
|
||||
}
|
||||
}, [shape.props.token, shape.props.wsUrl])
|
||||
}, [shape.props.token, shape.props.serverUrl])
|
||||
|
||||
// Auto-scroll terminal output
|
||||
// Handle terminal input - send keystrokes to server
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight
|
||||
}
|
||||
}, [output])
|
||||
if (!xtermRef.current || !ws) return
|
||||
|
||||
const handleInputSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input || !ws || !connected) return
|
||||
|
||||
// Send input to terminal
|
||||
const disposable = xtermRef.current.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: input + '\n',
|
||||
data: data,
|
||||
timestamp: Date.now(),
|
||||
}))
|
||||
|
||||
setInput('')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposable.dispose()
|
||||
}
|
||||
}, [ws])
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
try {
|
||||
|
|
@ -172,7 +327,7 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: shape.props.sessionName || 'Canvas Terminal',
|
||||
name: sessionName || `Terminal ${new Date().toLocaleTimeString()}`,
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
@ -182,22 +337,59 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
|
||||
const session: SessionResponse = await response.json()
|
||||
|
||||
// Update shape with session details
|
||||
// CRITICAL: Ensure all props are defined - undefined values cause ValidationError
|
||||
// Explicitly build props object with all required values to prevent undefined from slipping through
|
||||
this.editor.updateShape<IMultmuxShape>({
|
||||
id: shape.id,
|
||||
type: 'Multmux',
|
||||
props: {
|
||||
...shape.props,
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
token: session.token,
|
||||
w: shape.props.w ?? 800,
|
||||
h: shape.props.h ?? 600,
|
||||
sessionId: session.id ?? '',
|
||||
sessionName: session.name ?? '',
|
||||
token: session.token ?? '',
|
||||
serverUrl: shape.props.serverUrl ?? 'http://localhost:3002',
|
||||
pinnedToView: shape.props.pinnedToView ?? false,
|
||||
tags: Array.isArray(shape.props.tags) ? shape.props.tags : ['terminal', 'multmux'],
|
||||
},
|
||||
})
|
||||
|
||||
setOutput(prev => [...prev, `✓ Created session: ${session.name}`])
|
||||
// Session created - terminal will connect via WebSocket
|
||||
console.log('✓ Created session:', session.name)
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error)
|
||||
setOutput(prev => [...prev, `✗ Failed to create session: ${error}`])
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinSession = async (sessionId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${shape.props.serverUrl}/api/sessions/${sessionId}/join`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to join session')
|
||||
}
|
||||
|
||||
const data = await response.json() as { name?: string; token?: string }
|
||||
|
||||
// CRITICAL: Ensure all props are defined - undefined values cause ValidationError
|
||||
this.editor.updateShape<IMultmuxShape>({
|
||||
id: shape.id,
|
||||
type: 'Multmux',
|
||||
props: {
|
||||
w: shape.props.w ?? 800,
|
||||
h: shape.props.h ?? 600,
|
||||
sessionId: sessionId ?? '',
|
||||
sessionName: data.name ?? 'Joined Session',
|
||||
token: data.token ?? '',
|
||||
serverUrl: shape.props.serverUrl ?? 'http://localhost:3002',
|
||||
pinnedToView: shape.props.pinnedToView ?? false,
|
||||
tags: Array.isArray(shape.props.tags) ? shape.props.tags : ['terminal', 'multmux'],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to join session:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,10 +415,7 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
this.editor.updateShape<IMultmuxShape>({
|
||||
id: shape.id,
|
||||
type: 'Multmux',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
props: { ...shape.props, tags: newTags }
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
|
|
@ -236,48 +425,147 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
height: '100%',
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: '20px',
|
||||
padding: '24px',
|
||||
fontFamily: 'monospace',
|
||||
pointerEvents: 'all',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
gap: '20px',
|
||||
}}>
|
||||
<h3 style={{ margin: 0, color: '#cba6f7' }}>Setup mulTmux Terminal</h3>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2 style={{ margin: '0 0 8px 0', color: '#cba6f7', fontSize: '24px' }}>mulTmux</h2>
|
||||
<p style={{ margin: 0, opacity: 0.7, fontSize: '14px' }}>Collaborative Terminal Sessions</p>
|
||||
</div>
|
||||
|
||||
{/* Create Session */}
|
||||
<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,
|
||||
},
|
||||
})
|
||||
}}
|
||||
value={sessionName}
|
||||
onChange={(e) => setSessionName(e.target.value)}
|
||||
placeholder="Session name (optional)"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
marginTop: '4px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#313244',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: '4px',
|
||||
borderRadius: '8px',
|
||||
color: '#cdd6f4',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Canvas Terminal"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleCreateSession}
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
backgroundColor: '#8b5cf6',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '16px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
+ Create New Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
{/* Divider */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ flex: 1, height: '1px', backgroundColor: '#45475a' }} />
|
||||
<span style={{ opacity: 0.5, fontSize: '12px' }}>OR JOIN EXISTING</span>
|
||||
<div style={{ flex: 1, height: '1px', backgroundColor: '#45475a' }} />
|
||||
</div>
|
||||
|
||||
{/* Join Session */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px', minHeight: 0 }}>
|
||||
<button
|
||||
onClick={fetchSessions}
|
||||
disabled={loadingSessions}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
backgroundColor: '#313244',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: '8px',
|
||||
color: '#cdd6f4',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{loadingSessions ? 'Loading...' : 'Refresh Sessions'}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: '8px',
|
||||
padding: '8px',
|
||||
}}>
|
||||
{sessions.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px', opacity: 0.5 }}>
|
||||
No active sessions
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleJoinSession(session.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#45475a',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#cdd6f4',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>{session.name}</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.7 }}>
|
||||
{session.clientCount} connected
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#89b4fa',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showAdvanced ? '▼' : '▶'} Advanced Settings
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<label style={{ fontSize: '12px', opacity: 0.7 }}>
|
||||
Server URL:
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -286,10 +574,7 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
this.editor.updateShape<IMultmuxShape>({
|
||||
id: shape.id,
|
||||
type: 'Multmux',
|
||||
props: {
|
||||
...shape.props,
|
||||
serverUrl: e.target.value,
|
||||
},
|
||||
props: { ...shape.props, serverUrl: e.target.value },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
|
|
@ -301,92 +586,14 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
borderRadius: '4px',
|
||||
color: '#cdd6f4',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
placeholder="http://localhost:3000"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</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"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</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()}
|
||||
onMouseDown={(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',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
|
|
@ -415,10 +622,7 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
this.editor.updateShape<IMultmuxShape>({
|
||||
id: shape.id,
|
||||
type: 'Multmux',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
props: { ...shape.props, tags: newTags }
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
|
|
@ -451,45 +655,24 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Terminal output */}
|
||||
{/* xterm.js Terminal */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
padding: '4px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{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) => {
|
||||
// Allow pointer events for text selection but stop propagation to prevent tldraw interactions
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Focus the terminal when clicked
|
||||
xtermRef.current?.focus()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
|
|
@ -501,7 +684,6 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
}
|
||||
|
||||
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,0 +1,69 @@
|
|||
/**
|
||||
* DEPRECATED: MycelialIntelligence shape is no longer used as a canvas tool.
|
||||
* The functionality has been moved to the permanent UI bar (MycelialIntelligenceBar.tsx).
|
||||
*
|
||||
* This shape util is kept ONLY for backwards compatibility with existing boards
|
||||
* that may have MycelialIntelligence shapes saved. It renders a placeholder message.
|
||||
*/
|
||||
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from '@tldraw/tldraw'
|
||||
|
||||
export type IMycelialIntelligenceShape = TLBaseShape<
|
||||
'MycelialIntelligence',
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
// Keep old props for migration compatibility
|
||||
prompt?: string
|
||||
conversationHistory?: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
}
|
||||
>
|
||||
|
||||
export class MycelialIntelligenceShape extends BaseBoxShapeUtil<IMycelialIntelligenceShape> {
|
||||
static override type = 'MycelialIntelligence' as const
|
||||
|
||||
getDefaultProps(): IMycelialIntelligenceShape['props'] {
|
||||
return {
|
||||
w: 400,
|
||||
h: 300,
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: IMycelialIntelligenceShape) {
|
||||
return (
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
border: '2px dashed rgba(16, 185, 129, 0.5)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '32px', marginBottom: '12px' }}>🍄🧠</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Mycelial Intelligence
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', textAlign: 'center', opacity: 0.8 }}>
|
||||
This tool has moved to the floating bar at the top of the screen.
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', textAlign: 'center', opacity: 0.6, marginTop: '8px' }}>
|
||||
You can delete this shape - it's no longer needed.
|
||||
</span>
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IMycelialIntelligenceShape) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
|
@ -109,6 +109,12 @@ export class MultmuxIdle extends StateNode {
|
|||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
sessionId: '',
|
||||
sessionName: '',
|
||||
token: '',
|
||||
serverUrl: 'http://localhost:3000',
|
||||
pinnedToView: false,
|
||||
tags: ['terminal', 'multmux'],
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuGroup id="camera-controls">
|
||||
<TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.copyLinkToCurrentView} />
|
||||
<TldrawUiMenuItem {...customActions.copyFocusLink} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.revertCamera} disabled={!hasCameraHistory} />
|
||||
<TldrawUiMenuItem {...customActions.lockElement} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
|
||||
|
|
@ -239,6 +240,9 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuItem {...tools.FathomMeetings} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Holon} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.ImageGen} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.VideoGen} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Multmux} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.MycelialIntelligence} disabled={hasSelection} />
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
{/* Collections Group */}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { createShapeId } from "tldraw"
|
|||
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||
import { isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||
import { UserSettingsModal } from "./UserSettingsModal"
|
||||
|
||||
// Dark mode utilities
|
||||
const getDarkMode = (): boolean => {
|
||||
|
|
@ -40,13 +41,11 @@ export function CustomToolbar() {
|
|||
|
||||
const { session, setSession, clearSession } = useAuth()
|
||||
const [showProfilePopup, setShowProfilePopup] = useState(false)
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [showVaultBrowser, setShowVaultBrowser] = useState(false)
|
||||
const [showHolonBrowser, setShowHolonBrowser] = useState(false)
|
||||
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
|
||||
const [showFathomPanel, setShowFathomPanel] = useState(false)
|
||||
const [showFathomApiKeyInput, setShowFathomApiKeyInput] = useState(false)
|
||||
const [fathomApiKeyInput, setFathomApiKeyInput] = useState('')
|
||||
const [hasFathomApiKey, setHasFathomApiKey] = useState(false)
|
||||
const profilePopupRef = useRef<HTMLDivElement>(null)
|
||||
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
|
||||
|
||||
|
|
@ -64,11 +63,7 @@ export function CustomToolbar() {
|
|||
useEffect(() => {
|
||||
if (editor && tools) {
|
||||
setIsReady(true)
|
||||
// Debug: log available tools
|
||||
console.log('🔧 CustomToolbar: Available tools:', Object.keys(tools))
|
||||
console.log('🔧 CustomToolbar: VideoGen exists:', !!tools["VideoGen"])
|
||||
console.log('🔧 CustomToolbar: Multmux exists:', !!tools["Multmux"])
|
||||
console.log('🔧 CustomToolbar: ImageGen exists:', !!tools["ImageGen"])
|
||||
// Tools are ready
|
||||
}
|
||||
}, [editor, tools])
|
||||
|
||||
|
|
@ -95,10 +90,7 @@ export function CustomToolbar() {
|
|||
// Listen for open-fathom-meetings event - now creates a shape instead of modal
|
||||
useEffect(() => {
|
||||
const handleOpenFathomMeetings = () => {
|
||||
console.log('🔧 Received open-fathom-meetings event')
|
||||
|
||||
// Allow multiple FathomMeetingsBrowser instances - users can work with multiple meeting browsers
|
||||
console.log('🔧 Creating new FathomMeetingsBrowser shape')
|
||||
// Allow multiple FathomMeetingsBrowser instances
|
||||
|
||||
// Get the current viewport center
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
|
|
@ -120,8 +112,6 @@ export function CustomToolbar() {
|
|||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created FathomMeetingsBrowser shape:', browserShape.id)
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
editor.setCurrentTool('select')
|
||||
|
|
@ -140,22 +130,18 @@ export function CustomToolbar() {
|
|||
// Listen for open-obsidian-browser event - now creates a shape instead of modal
|
||||
useEffect(() => {
|
||||
const handleOpenBrowser = (event?: CustomEvent) => {
|
||||
console.log('🔧 Received open-obsidian-browser event')
|
||||
|
||||
// Check if ObsidianBrowser already exists
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const existingBrowserShapes = allShapes.filter(shape => shape.type === 'ObsidianBrowser')
|
||||
|
||||
if (existingBrowserShapes.length > 0) {
|
||||
// If a browser already exists, just select it
|
||||
console.log('✅ ObsidianBrowser already exists, selecting it')
|
||||
editor.setSelectedShapes([existingBrowserShapes[0].id])
|
||||
editor.setCurrentTool('hand')
|
||||
return
|
||||
}
|
||||
|
||||
// No existing browser, create a new one
|
||||
console.log('🔧 Creating new ObsidianBrowser shape')
|
||||
|
||||
// Try to get click position from event or use current page point
|
||||
let xPosition: number
|
||||
|
|
@ -169,24 +155,21 @@ export function CustomToolbar() {
|
|||
const clickPoint = (event as any)?.detail?.point
|
||||
if (clickPoint) {
|
||||
// Use click coordinates from event
|
||||
xPosition = clickPoint.x - shapeWidth / 2 // Center the shape on click
|
||||
yPosition = clickPoint.y - shapeHeight / 2 // Center the shape on click
|
||||
console.log('📍 Positioning at event click location:', { clickPoint, xPosition, yPosition })
|
||||
xPosition = clickPoint.x - shapeWidth / 2
|
||||
yPosition = clickPoint.y - shapeHeight / 2
|
||||
} else {
|
||||
// Try to get current page point (if called from a click)
|
||||
const currentPagePoint = editor.inputs.currentPagePoint
|
||||
if (currentPagePoint && currentPagePoint.x !== undefined && currentPagePoint.y !== undefined) {
|
||||
xPosition = currentPagePoint.x - shapeWidth / 2 // Center the shape on click
|
||||
yPosition = currentPagePoint.y - shapeHeight / 2 // Center the shape on click
|
||||
console.log('📍 Positioning at current page point:', { currentPagePoint, xPosition, yPosition })
|
||||
xPosition = currentPagePoint.x - shapeWidth / 2
|
||||
yPosition = currentPagePoint.y - shapeHeight / 2
|
||||
} else {
|
||||
// Fallback to viewport center if no click coordinates available
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
xPosition = centerX - shapeWidth / 2 // Center the shape
|
||||
yPosition = centerY - shapeHeight / 2 // Center the shape
|
||||
console.log('📍 Positioning at viewport center (fallback):', { centerX, centerY, xPosition, yPosition })
|
||||
xPosition = centerX - shapeWidth / 2
|
||||
yPosition = centerY - shapeHeight / 2
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,8 +184,6 @@ export function CustomToolbar() {
|
|||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created ObsidianBrowser shape:', browserShape.id)
|
||||
|
||||
// Select the new shape and switch to hand tool
|
||||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
editor.setCurrentTool('hand')
|
||||
|
|
@ -221,22 +202,17 @@ export function CustomToolbar() {
|
|||
// Listen for open-holon-browser event - now creates a shape instead of modal
|
||||
useEffect(() => {
|
||||
const handleOpenHolonBrowser = () => {
|
||||
console.log('🔧 Received open-holon-browser event')
|
||||
|
||||
// Check if a HolonBrowser shape already exists
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const existingBrowserShapes = allShapes.filter(s => s.type === 'HolonBrowser')
|
||||
|
||||
if (existingBrowserShapes.length > 0) {
|
||||
// If a browser already exists, just select it
|
||||
console.log('✅ HolonBrowser already exists, selecting it')
|
||||
editor.setSelectedShapes([existingBrowserShapes[0].id])
|
||||
editor.setCurrentTool('select')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔧 Creating new HolonBrowser shape')
|
||||
|
||||
// Get the current viewport center
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
|
|
@ -257,8 +233,6 @@ export function CustomToolbar() {
|
|||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created HolonBrowser shape:', browserShape.id)
|
||||
|
||||
// Select the new shape and switch to hand tool
|
||||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
editor.setCurrentTool('hand')
|
||||
|
|
@ -276,8 +250,6 @@ export function CustomToolbar() {
|
|||
|
||||
// Handle Holon selection from browser
|
||||
const handleHolonSelect = (holonData: HolonData) => {
|
||||
console.log('🎯 Creating Holon shape from data:', holonData)
|
||||
|
||||
try {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = editor.getCamera()
|
||||
|
|
@ -318,8 +290,6 @@ export function CustomToolbar() {
|
|||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created Holon shape from data:', holonShape.id)
|
||||
|
||||
// Restore camera position if it changed
|
||||
const newCamera = editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
|
|
@ -336,15 +306,12 @@ export function CustomToolbar() {
|
|||
// Listen for create-obsnote-shapes event from the tool
|
||||
useEffect(() => {
|
||||
const handleCreateShapes = () => {
|
||||
console.log('🎯 CustomToolbar: Received create-obsnote-shapes event')
|
||||
|
||||
// If vault browser is open, trigger shape creation
|
||||
if (showVaultBrowser) {
|
||||
const event = new CustomEvent('trigger-obsnote-creation')
|
||||
window.dispatchEvent(event)
|
||||
} else {
|
||||
// If vault browser is not open, open it first
|
||||
console.log('🎯 Vault browser not open, opening it first')
|
||||
setVaultBrowserMode('keyboard')
|
||||
setShowVaultBrowser(true)
|
||||
}
|
||||
|
|
@ -400,16 +367,6 @@ export function CustomToolbar() {
|
|||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Check Fathom API key status
|
||||
useEffect(() => {
|
||||
if (session.authed && session.username) {
|
||||
const hasKey = isFathomApiKeyConfigured(session.username)
|
||||
setHasFathomApiKey(hasKey)
|
||||
} else {
|
||||
setHasFathomApiKey(false)
|
||||
}
|
||||
}, [session.authed, session.username])
|
||||
|
||||
const handleLogout = () => {
|
||||
// Clear the session
|
||||
clearSession()
|
||||
|
|
@ -418,22 +375,6 @@ export function CustomToolbar() {
|
|||
setShowProfilePopup(false)
|
||||
}
|
||||
|
||||
const openApiKeysDialog = () => {
|
||||
addDialog({
|
||||
id: "api-keys",
|
||||
component: ({ onClose }: { onClose: () => void }) => (
|
||||
<SettingsDialog
|
||||
onClose={() => {
|
||||
onClose()
|
||||
removeDialog("api-keys")
|
||||
checkApiKeys() // Refresh API key status
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const handleObsNoteSelect = (obsNote: ObsidianObsNote) => {
|
||||
// Get current camera position to place the obs_note
|
||||
const camera = editor.getCamera()
|
||||
|
|
@ -566,466 +507,104 @@ export function CustomToolbar() {
|
|||
<div
|
||||
className="toolbar-container"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "4px",
|
||||
right: "40px",
|
||||
zIndex: 99999,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
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" />
|
||||
<LoginButton className="toolbar-btn" />
|
||||
<StarBoardButton className="toolbar-btn" />
|
||||
|
||||
{session.authed && (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
className="toolbar-btn profile-btn"
|
||||
onClick={() => setShowProfilePopup(!showProfilePopup)}
|
||||
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={`Signed in as ${session.username}`}
|
||||
>
|
||||
<span style={{ fontSize: "12px" }}>
|
||||
{hasApiKey ? "🔑" : "❌"}
|
||||
</span>
|
||||
<span>CryptID: {session.username}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
|
||||
</svg>
|
||||
<span className="profile-username">{session.username}</span>
|
||||
</button>
|
||||
|
||||
{showProfilePopup && (
|
||||
<div
|
||||
ref={profilePopupRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "40px",
|
||||
right: "0",
|
||||
width: "250px",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "4px",
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
|
||||
padding: "16px",
|
||||
zIndex: 100000,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "12px", fontWeight: "bold" }}>
|
||||
CryptID: {session.username}
|
||||
<div ref={profilePopupRef} className="profile-dropdown">
|
||||
<div className="profile-dropdown-header">
|
||||
<div className="profile-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<span className="profile-name">{session.username}</span>
|
||||
<span className="profile-label">CryptID Account</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Status */}
|
||||
<div style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px",
|
||||
backgroundColor: hasApiKey ? "#f0f9ff" : "#fef2f2",
|
||||
borderRadius: "4px",
|
||||
border: `1px solid ${hasApiKey ? "#0ea5e9" : "#f87171"}`
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "8px"
|
||||
}}>
|
||||
<span style={{ fontWeight: "500" }}>AI API Keys</span>
|
||||
<span style={{ fontSize: "14px" }}>
|
||||
{hasApiKey ? "✅ Configured" : "❌ Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
margin: "0 0 8px 0"
|
||||
}}>
|
||||
{hasApiKey
|
||||
? "Your AI models are ready to use"
|
||||
: "Configure API keys to use AI features"
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
onClick={openApiKeysDialog}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: hasApiKey ? "#0ea5e9" : "#ef4444",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = hasApiKey ? "#0284c7" : "#dc2626"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = hasApiKey ? "#0ea5e9" : "#ef4444"
|
||||
}}
|
||||
>
|
||||
{hasApiKey ? "Manage Keys" : "Add API Keys"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="profile-dropdown-divider" />
|
||||
|
||||
{/* Obsidian Vault Settings */}
|
||||
<div style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #e9ecef"
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "8px"
|
||||
}}>
|
||||
<span style={{ fontWeight: "500" }}>Obsidian Vault</span>
|
||||
<span style={{ fontSize: "14px" }}>
|
||||
{session.obsidianVaultName ? "✅ Configured" : "❌ Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.obsidianVaultName ? (
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<div style={{
|
||||
fontSize: "12px",
|
||||
color: "#007acc",
|
||||
fontWeight: "600",
|
||||
marginBottom: "4px"
|
||||
}}>
|
||||
{session.obsidianVaultName}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: "11px",
|
||||
color: "#666",
|
||||
fontFamily: "monospace",
|
||||
wordBreak: "break-all"
|
||||
}}>
|
||||
{session.obsidianVaultPath === 'folder-selected'
|
||||
? 'Folder selected (path not available)'
|
||||
: session.obsidianVaultPath}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
margin: "0 0 8px 0"
|
||||
}}>
|
||||
No Obsidian vault configured
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('🔧 Set Vault button clicked, opening folder picker')
|
||||
setVaultBrowserMode('button')
|
||||
setShowVaultBrowser(true)
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: session.obsidianVaultName ? "#007acc" : "#28a745",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = session.obsidianVaultName ? "#005a9e" : "#218838"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = session.obsidianVaultName ? "#007acc" : "#28a745"
|
||||
}}
|
||||
>
|
||||
{session.obsidianVaultName ? "Change Vault" : "Set Vault"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fathom API Key Settings */}
|
||||
<div style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px",
|
||||
backgroundColor: hasFathomApiKey ? "#f0f9ff" : "#fef2f2",
|
||||
borderRadius: "4px",
|
||||
border: `1px solid ${hasFathomApiKey ? "#0ea5e9" : "#f87171"}`
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "8px"
|
||||
}}>
|
||||
<span style={{ fontWeight: "500" }}>Fathom API</span>
|
||||
<span style={{ fontSize: "14px" }}>
|
||||
{hasFathomApiKey ? "✅ Connected" : "❌ Not connected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showFathomApiKeyInput ? (
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={fathomApiKeyInput}
|
||||
onChange={(e) => setFathomApiKeyInput(e.target.value)}
|
||||
placeholder="Enter Fathom API key..."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
marginBottom: "8px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (fathomApiKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (fathomApiKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#0ea5e9",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#6b7280",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
margin: "0 0 8px 0"
|
||||
}}>
|
||||
{hasFathomApiKey
|
||||
? "Your Fathom account is connected"
|
||||
: "Connect your Fathom account to import meetings"}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomApiKeyInput(true)
|
||||
const currentKey = getFathomApiKey(session.username)
|
||||
if (currentKey) {
|
||||
setFathomApiKeyInput(currentKey)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 12px",
|
||||
backgroundColor: hasFathomApiKey ? "#0ea5e9" : "#ef4444",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{hasFathomApiKey ? "Change Key" : "Add API Key"}
|
||||
</button>
|
||||
{hasFathomApiKey && (
|
||||
<button
|
||||
onClick={() => {
|
||||
removeFathomApiKey(session.username)
|
||||
setHasFathomApiKey(false)
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: "#6b7280",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/dashboard/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "#3B82F6",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontWeight: "500",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
marginBottom: "8px",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2563EB"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#3B82F6"
|
||||
}}
|
||||
>
|
||||
My Dashboard
|
||||
<a href="/dashboard/" className="profile-dropdown-item">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
||||
</svg>
|
||||
<span>My Saved Boards</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
className="profile-dropdown-item"
|
||||
onClick={() => {
|
||||
setShowProfilePopup(false)
|
||||
setShowSettingsModal(true)
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
|
||||
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
<div className="profile-dropdown-divider" />
|
||||
|
||||
<button className="profile-dropdown-item" onClick={toggleDarkMode}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
{isDarkMode ? (
|
||||
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm.5-9.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm0 11a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm5-5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-11 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9.743-4.036a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm-7.779 7.779a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm7.072 0a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707zM3.757 4.464a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707z"/>
|
||||
) : (
|
||||
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
||||
)}
|
||||
</svg>
|
||||
<span>{isDarkMode ? 'Light Mode' : 'Dark Mode'}</span>
|
||||
</button>
|
||||
|
||||
<div className="profile-dropdown-divider" />
|
||||
|
||||
{!session.backupCreated && (
|
||||
<div style={{
|
||||
marginBottom: "12px",
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
padding: "8px",
|
||||
backgroundColor: "#f8f8f8",
|
||||
borderRadius: "4px"
|
||||
}}>
|
||||
Remember to back up your encryption keys to prevent data loss!
|
||||
<div className="profile-dropdown-warning">
|
||||
Back up your encryption keys to prevent data loss
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "#EF4444",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontWeight: "500",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#DC2626"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#EF4444"
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
<button className="profile-dropdown-item danger" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
|
||||
<path fillRule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
|
||||
</svg>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
{showSettingsModal && (
|
||||
<UserSettingsModal
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
isDarkMode={isDarkMode}
|
||||
onToggleDarkMode={toggleDarkMode}
|
||||
/>
|
||||
)}
|
||||
<DefaultToolbar>
|
||||
<DefaultToolbarContent />
|
||||
{tools["VideoChat"] && (
|
||||
|
|
@ -1110,6 +689,14 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["FathomMeetings"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["FathomMeetings"]}
|
||||
icon="calendar"
|
||||
label="Fathom Meetings"
|
||||
isSelected={tools["FathomMeetings"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["ImageGen"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ImageGen"]}
|
||||
|
|
@ -1134,6 +721,7 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{/* MycelialIntelligence moved to permanent floating bar */}
|
||||
{/* Share Location tool removed for now */}
|
||||
{/* Refresh All ObsNotes Button */}
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { useEditor } from "tldraw"
|
||||
import {
|
||||
onFocusLockChange,
|
||||
unlockCameraFocus,
|
||||
getFocusLockedShapeId,
|
||||
} from "./cameraUtils"
|
||||
import type { TLShapeId } from "tldraw"
|
||||
|
||||
export function FocusLockIndicator() {
|
||||
const editor = useEditor()
|
||||
const [isLocked, setIsLocked] = useState(false)
|
||||
const [shapeName, setShapeName] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onFocusLockChange((locked, shapeId) => {
|
||||
setIsLocked(locked)
|
||||
|
||||
if (locked && shapeId) {
|
||||
// Try to get a name for the shape
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (shape) {
|
||||
// Check for common name properties
|
||||
const name =
|
||||
(shape.props as any)?.name ||
|
||||
(shape.props as any)?.title ||
|
||||
(shape.meta as any)?.name ||
|
||||
shape.type
|
||||
setShapeName(name)
|
||||
}
|
||||
} else {
|
||||
setShapeName("")
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
if (!isLocked) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="focus-lock-indicator"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "60px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "10px 16px",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
color: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
fontSize: "14px",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
Focused on:{" "}
|
||||
<strong style={{ color: "#60a5fa" }}>{shapeName || "Shape"}</strong>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => unlockCameraFocus(editor)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = "#2563eb")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = "#3b82f6")
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</svg>
|
||||
Unlock View
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: "#9ca3af",
|
||||
fontSize: "12px",
|
||||
marginLeft: "4px",
|
||||
}}
|
||||
>
|
||||
(Press Esc)
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,700 @@
|
|||
import React, { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useEditor } from "tldraw"
|
||||
import { canvasAI, useCanvasAI } from "@/lib/canvasAI"
|
||||
import { useWebSpeechTranscription } from "@/hooks/useWebSpeechTranscription"
|
||||
|
||||
// Microphone icon component
|
||||
const MicrophoneIcon = ({ isListening, isDark }: { isListening: boolean; isDark: boolean }) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={isListening ? "#10b981" : "currentColor"}
|
||||
style={{
|
||||
filter: isListening ? 'drop-shadow(0 0 8px #10b981)' : 'none',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/>
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Send icon component
|
||||
const SendIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Expand/collapse icon
|
||||
const ExpandIcon = ({ isExpanded }: { isExpanded: boolean }) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Hook to detect dark mode
|
||||
const useDarkMode = () => {
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDark(document.documentElement.classList.contains('dark'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return isDark
|
||||
}
|
||||
|
||||
const ACCENT_COLOR = "#10b981" // Emerald green
|
||||
|
||||
interface ConversationMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export function MycelialIntelligenceBar() {
|
||||
const editor = useEditor()
|
||||
const isDark = useDarkMode()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [conversationHistory, setConversationHistory] = useState<ConversationMessage[]>([])
|
||||
const [streamingResponse, setStreamingResponse] = useState("")
|
||||
const [indexingProgress, setIndexingProgress] = useState(0)
|
||||
const [isIndexing, setIsIndexing] = useState(false)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
// Initialize canvas AI with editor
|
||||
useCanvasAI(editor)
|
||||
|
||||
// Theme-aware colors
|
||||
const colors = {
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backgroundHover: 'rgba(255, 255, 255, 1)',
|
||||
border: 'rgba(229, 231, 235, 0.8)',
|
||||
borderHover: 'rgba(209, 213, 219, 1)',
|
||||
text: '#18181b',
|
||||
textMuted: '#71717a',
|
||||
inputBg: 'rgba(244, 244, 245, 0.8)',
|
||||
inputBorder: 'rgba(228, 228, 231, 1)',
|
||||
inputText: '#18181b',
|
||||
shadow: '0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.08)',
|
||||
shadowHover: '0 12px 40px rgba(0, 0, 0, 0.15), 0 6px 20px rgba(0, 0, 0, 0.1)',
|
||||
userBubble: 'rgba(16, 185, 129, 0.1)',
|
||||
assistantBubble: 'rgba(244, 244, 245, 0.8)',
|
||||
}
|
||||
|
||||
// Voice transcription
|
||||
const handleTranscriptUpdate = useCallback((text: string) => {
|
||||
setPrompt(prev => (prev + text).trim())
|
||||
}, [])
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
isSupported: isVoiceSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} = useWebSpeechTranscription({
|
||||
onTranscriptUpdate: handleTranscriptUpdate,
|
||||
continuous: false,
|
||||
interimResults: true,
|
||||
})
|
||||
|
||||
// Update isListening state when recording changes
|
||||
useEffect(() => {
|
||||
setIsListening(isRecording)
|
||||
}, [isRecording])
|
||||
|
||||
// Scroll to bottom when conversation updates
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [conversationHistory, streamingResponse])
|
||||
|
||||
// Click outside to collapse - detects clicks on canvas or outside the MI bar
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | PointerEvent) => {
|
||||
// Check if click is outside the MI bar container
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Use pointerdown to catch clicks before they reach canvas
|
||||
document.addEventListener('pointerdown', handleClickOutside, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handleClickOutside, true)
|
||||
}
|
||||
}, [isExpanded])
|
||||
|
||||
// Handle voice toggle
|
||||
const toggleVoice = useCallback(() => {
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}, [isRecording, startRecording, stopRecording])
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt || isLoading) return
|
||||
|
||||
// Clear prompt immediately
|
||||
setPrompt('')
|
||||
|
||||
const newHistory: ConversationMessage[] = [
|
||||
...conversationHistory,
|
||||
{ role: 'user', content: trimmedPrompt }
|
||||
]
|
||||
|
||||
setConversationHistory(newHistory)
|
||||
setIsLoading(true)
|
||||
setIsExpanded(true)
|
||||
setStreamingResponse("")
|
||||
|
||||
try {
|
||||
const { isIndexing: currentlyIndexing } = canvasAI.getIndexingStatus()
|
||||
if (!currentlyIndexing) {
|
||||
setIsIndexing(true)
|
||||
|
||||
await canvasAI.indexCanvas((progress) => {
|
||||
setIndexingProgress(progress)
|
||||
})
|
||||
|
||||
setIsIndexing(false)
|
||||
setIndexingProgress(100)
|
||||
}
|
||||
|
||||
let fullResponse = ''
|
||||
await canvasAI.query(
|
||||
trimmedPrompt,
|
||||
(partial, done) => {
|
||||
fullResponse = partial
|
||||
setStreamingResponse(partial)
|
||||
if (done) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const updatedHistory: ConversationMessage[] = [
|
||||
...newHistory,
|
||||
{ role: 'assistant', content: fullResponse }
|
||||
]
|
||||
|
||||
setConversationHistory(updatedHistory)
|
||||
setStreamingResponse("")
|
||||
setIsLoading(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mycelial Intelligence query error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'An error occurred'
|
||||
|
||||
const errorHistory: ConversationMessage[] = [
|
||||
...newHistory,
|
||||
{ role: 'assistant', content: `Error: ${errorMessage}` }
|
||||
]
|
||||
|
||||
setConversationHistory(errorHistory)
|
||||
setStreamingResponse("")
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [prompt, isLoading, conversationHistory])
|
||||
|
||||
// Toggle expanded state
|
||||
const toggleExpand = useCallback(() => {
|
||||
setIsExpanded(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const collapsedHeight = 48
|
||||
const expandedHeight = 400
|
||||
const barWidth = 520
|
||||
const height = isExpanded ? expandedHeight : collapsedHeight
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="mycelial-intelligence-bar"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: barWidth,
|
||||
height,
|
||||
zIndex: 99999,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onPointerEnter={() => setIsHovering(true)}
|
||||
onPointerLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: isHovering ? colors.backgroundHover : colors.background,
|
||||
borderRadius: isExpanded ? '20px' : '24px',
|
||||
border: `1px solid ${isHovering ? colors.borderHover : colors.border}`,
|
||||
boxShadow: isHovering ? colors.shadowHover : colors.shadow,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: "'Inter', 'SF Pro Display', -apple-system, sans-serif",
|
||||
transition: 'all 0.3s ease',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
}}
|
||||
>
|
||||
{/* Collapsed: Single-line prompt bar */}
|
||||
{!isExpanded && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 10px 6px 14px',
|
||||
height: '100%',
|
||||
}}>
|
||||
{/* Mushroom + Brain icon */}
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
opacity: 0.9,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
🍄🧠
|
||||
</span>
|
||||
|
||||
{/* Input field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
placeholder="Ask mi anything about this workspace..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '8px 4px',
|
||||
fontSize: '14px',
|
||||
color: colors.inputText,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Indexing indicator */}
|
||||
{isIndexing && (
|
||||
<span style={{
|
||||
color: ACCENT_COLOR,
|
||||
fontSize: '11px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
{Math.round(indexingProgress)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Voice button (compact) */}
|
||||
{isVoiceSupported && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleVoice()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: isRecording
|
||||
? `rgba(16, 185, 129, 0.15)`
|
||||
: 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isRecording ? ACCENT_COLOR : colors.textMuted,
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={isRecording ? "Stop recording" : "Voice input"}
|
||||
>
|
||||
<MicrophoneIcon isListening={isRecording} isDark={isDark} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Send button (compact, pill shape) */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSubmit()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
style={{
|
||||
height: '34px',
|
||||
padding: '0 14px',
|
||||
borderRadius: '17px',
|
||||
border: 'none',
|
||||
background: prompt.trim() && !isLoading
|
||||
? ACCENT_COLOR
|
||||
: colors.inputBg,
|
||||
cursor: prompt.trim() && !isLoading ? 'pointer' : 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: prompt.trim() && !isLoading ? 'white' : colors.textMuted,
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
opacity: prompt.trim() && !isLoading ? 1 : 0.5,
|
||||
}}
|
||||
title="Send"
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
|
||||
{/* Expand button if there's history */}
|
||||
{conversationHistory.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpand()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: ACCENT_COLOR,
|
||||
transition: 'all 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="View conversation"
|
||||
>
|
||||
<ExpandIcon isExpanded={false} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded: Header + Conversation + Input */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>🍄🧠</span>
|
||||
<span style={{
|
||||
color: colors.text,
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
<span style={{ fontStyle: 'italic', opacity: 0.85 }}>ask your mycelial intelligence anything about this workspace</span>
|
||||
</span>
|
||||
{isIndexing && (
|
||||
<span style={{
|
||||
color: colors.textMuted,
|
||||
fontSize: '11px',
|
||||
marginLeft: '4px',
|
||||
}}>
|
||||
Indexing... {Math.round(indexingProgress)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpand()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: colors.textMuted,
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
title="Collapse"
|
||||
>
|
||||
<ExpandIcon isExpanded={true} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversation area */}
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{conversationHistory.length === 0 && !streamingResponse && (
|
||||
<div style={{
|
||||
color: colors.textMuted,
|
||||
fontSize: '13px',
|
||||
textAlign: 'center',
|
||||
padding: '20px 16px',
|
||||
}}>
|
||||
I can search, summarize, and find connections across all your workspace content.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conversationHistory.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: msg.role === 'user' ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
|
||||
backgroundColor: msg.role === 'user' ? colors.userBubble : colors.assistantBubble,
|
||||
border: `1px solid ${msg.role === 'user' ? 'rgba(16, 185, 129, 0.2)' : colors.border}`,
|
||||
color: colors.text,
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Streaming response */}
|
||||
{streamingResponse && (
|
||||
<div style={{
|
||||
alignSelf: 'flex-start',
|
||||
maxWidth: '85%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '14px 14px 14px 4px',
|
||||
backgroundColor: colors.assistantBubble,
|
||||
border: `1px solid ${colors.border}`,
|
||||
color: colors.text,
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{streamingResponse}
|
||||
{isLoading && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '2px',
|
||||
height: '14px',
|
||||
backgroundColor: ACCENT_COLOR,
|
||||
marginLeft: '2px',
|
||||
animation: 'blink 1s infinite',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && !streamingResponse && (
|
||||
<div style={{
|
||||
alignSelf: 'flex-start',
|
||||
display: 'flex',
|
||||
gap: '5px',
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR }} />
|
||||
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR, animationDelay: '0.2s' }} />
|
||||
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR, animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area (expanded) */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 12px',
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
}}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
placeholder="Ask a follow-up..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: colors.inputBg,
|
||||
border: `1px solid ${colors.inputBorder}`,
|
||||
borderRadius: '18px',
|
||||
padding: '8px 14px',
|
||||
fontSize: '13px',
|
||||
color: colors.inputText,
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Voice input button */}
|
||||
{isVoiceSupported && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleVoice()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
border: `1px solid ${isRecording ? ACCENT_COLOR : colors.inputBorder}`,
|
||||
background: isRecording
|
||||
? `rgba(16, 185, 129, 0.1)`
|
||||
: colors.inputBg,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isRecording ? ACCENT_COLOR : colors.textMuted,
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: isRecording ? `0 0 12px rgba(16, 185, 129, 0.3)` : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={isRecording ? "Stop recording" : "Start voice input"}
|
||||
>
|
||||
<MicrophoneIcon isListening={isRecording} isDark={isDark} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSubmit()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: prompt.trim() && !isLoading
|
||||
? ACCENT_COLOR
|
||||
: colors.inputBg,
|
||||
cursor: prompt.trim() && !isLoading ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: prompt.trim() && !isLoading ? 'white' : colors.textMuted,
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: prompt.trim() && !isLoading
|
||||
? '0 2px 8px rgba(16, 185, 129, 0.3)'
|
||||
: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Send message"
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CSS animations */}
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.loading-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
import { useState, useEffect, useRef } from "react"
|
||||
import { useAuth } from "../context/AuthContext"
|
||||
import { useDialogs } from "tldraw"
|
||||
import { SettingsDialog } from "./SettingsDialog"
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||
|
||||
interface UserSettingsModalProps {
|
||||
onClose: () => void
|
||||
isDarkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
}
|
||||
|
||||
export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: UserSettingsModalProps) {
|
||||
const { session, setSession } = useAuth()
|
||||
const { addDialog, removeDialog } = useDialogs()
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [hasApiKey, setHasApiKey] = useState(false)
|
||||
const [hasFathomApiKey, setHasFathomApiKey] = useState(false)
|
||||
const [showFathomApiKeyInput, setShowFathomApiKeyInput] = useState(false)
|
||||
const [fathomApiKeyInput, setFathomApiKeyInput] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'ai' | 'integrations'>('general')
|
||||
|
||||
// Check API key status
|
||||
const checkApiKeys = () => {
|
||||
const settings = localStorage.getItem("openai_api_key")
|
||||
try {
|
||||
if (settings) {
|
||||
try {
|
||||
const parsed = JSON.parse(settings)
|
||||
if (parsed.keys) {
|
||||
const hasValidKey = Object.values(parsed.keys).some(key =>
|
||||
typeof key === 'string' && key.trim() !== ''
|
||||
)
|
||||
setHasApiKey(hasValidKey)
|
||||
} else {
|
||||
setHasApiKey(typeof settings === 'string' && settings.trim() !== '')
|
||||
}
|
||||
} catch (e) {
|
||||
setHasApiKey(typeof settings === 'string' && settings.trim() !== '')
|
||||
}
|
||||
} else {
|
||||
setHasApiKey(false)
|
||||
}
|
||||
} catch (e) {
|
||||
setHasApiKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkApiKeys()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (session.authed && session.username) {
|
||||
setHasFathomApiKey(isFathomApiKeyConfigured(session.username))
|
||||
}
|
||||
}, [session.authed, session.username])
|
||||
|
||||
// Handle escape key and click outside
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const openApiKeysDialog = () => {
|
||||
addDialog({
|
||||
id: "api-keys",
|
||||
component: ({ onClose: dialogClose }: { onClose: () => void }) => (
|
||||
<SettingsDialog
|
||||
onClose={() => {
|
||||
dialogClose()
|
||||
removeDialog("api-keys")
|
||||
checkApiKeys()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetVault = () => {
|
||||
window.dispatchEvent(new CustomEvent('open-obsidian-browser'))
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-modal-overlay">
|
||||
<div className="settings-modal" ref={modalRef}>
|
||||
<div className="settings-modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button className="settings-close-btn" onClick={onClose} title="Close (Esc)">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'general' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'ai' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('ai')}
|
||||
>
|
||||
AI Models
|
||||
</button>
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'integrations' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('integrations')}
|
||||
>
|
||||
Integrations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
{activeTab === 'general' && (
|
||||
<div className="settings-section">
|
||||
<div className="settings-item">
|
||||
<div className="settings-item-info">
|
||||
<span className="settings-item-label">Appearance</span>
|
||||
<span className="settings-item-description">Toggle between light and dark mode</span>
|
||||
</div>
|
||||
<button
|
||||
className="settings-toggle-btn"
|
||||
onClick={onToggleDarkMode}
|
||||
>
|
||||
<span className="toggle-icon">{isDarkMode ? '🌙' : '☀️'}</span>
|
||||
<span>{isDarkMode ? 'Dark' : 'Light'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ai' && (
|
||||
<div className="settings-section">
|
||||
<div className="settings-item">
|
||||
<div className="settings-item-info">
|
||||
<span className="settings-item-label">AI API Keys</span>
|
||||
<span className="settings-item-description">
|
||||
{hasApiKey ? 'Your AI models are configured and ready' : 'Configure API keys to use AI features'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-item-status">
|
||||
<span className={`status-badge ${hasApiKey ? 'success' : 'warning'}`}>
|
||||
{hasApiKey ? 'Configured' : 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="settings-action-btn" onClick={openApiKeysDialog}>
|
||||
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="settings-section">
|
||||
{/* Obsidian Vault */}
|
||||
<div className="settings-item">
|
||||
<div className="settings-item-info">
|
||||
<span className="settings-item-label">Obsidian Vault</span>
|
||||
<span className="settings-item-description">
|
||||
{session.obsidianVaultName
|
||||
? `Connected: ${session.obsidianVaultName}`
|
||||
: 'Connect your Obsidian vault to import notes'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-item-status">
|
||||
<span className={`status-badge ${session.obsidianVaultName ? 'success' : 'warning'}`}>
|
||||
{session.obsidianVaultName ? 'Connected' : 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="settings-action-btn" onClick={handleSetVault}>
|
||||
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
|
||||
</button>
|
||||
|
||||
<div className="settings-divider" />
|
||||
|
||||
{/* Fathom API */}
|
||||
<div className="settings-item">
|
||||
<div className="settings-item-info">
|
||||
<span className="settings-item-label">Fathom Meetings</span>
|
||||
<span className="settings-item-description">
|
||||
{hasFathomApiKey
|
||||
? 'Your Fathom account is connected'
|
||||
: 'Connect Fathom to import meeting recordings'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-item-status">
|
||||
<span className={`status-badge ${hasFathomApiKey ? 'success' : 'warning'}`}>
|
||||
{hasFathomApiKey ? 'Connected' : 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFathomApiKeyInput ? (
|
||||
<div className="settings-input-group">
|
||||
<input
|
||||
type="password"
|
||||
value={fathomApiKeyInput}
|
||||
onChange={(e) => setFathomApiKeyInput(e.target.value)}
|
||||
placeholder="Enter Fathom API key..."
|
||||
className="settings-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && fathomApiKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="settings-input-actions">
|
||||
<button
|
||||
className="settings-btn-sm primary"
|
||||
onClick={() => {
|
||||
if (fathomApiKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="settings-btn-sm"
|
||||
onClick={() => {
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-button-group">
|
||||
<button
|
||||
className="settings-action-btn"
|
||||
onClick={() => {
|
||||
setShowFathomApiKeyInput(true)
|
||||
const currentKey = getFathomApiKey(session.username)
|
||||
if (currentKey) setFathomApiKeyInput(currentKey)
|
||||
}}
|
||||
>
|
||||
{hasFathomApiKey ? 'Change API Key' : 'Add API Key'}
|
||||
</button>
|
||||
{hasFathomApiKey && (
|
||||
<button
|
||||
className="settings-action-btn secondary"
|
||||
onClick={() => {
|
||||
removeFathomApiKey(session.username)
|
||||
setHasFathomApiKey(false)
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,26 @@ const MAX_HISTORY = 10 // Keep last 10 camera positions
|
|||
|
||||
const frameObservers = new Map<string, ResizeObserver>()
|
||||
|
||||
// Focus lock state - tracks when camera is locked to a specific shape
|
||||
let focusLockedShapeId: TLShapeId | null = null
|
||||
let focusLockCleanup: (() => void) | null = null
|
||||
let focusLockListeners: Set<(locked: boolean, shapeId: TLShapeId | null) => void> = new Set()
|
||||
|
||||
// Subscribe to focus lock state changes
|
||||
export const onFocusLockChange = (callback: (locked: boolean, shapeId: TLShapeId | null) => void) => {
|
||||
focusLockListeners.add(callback)
|
||||
// Call immediately with current state
|
||||
callback(focusLockedShapeId !== null, focusLockedShapeId)
|
||||
return () => focusLockListeners.delete(callback)
|
||||
}
|
||||
|
||||
const notifyFocusLockListeners = () => {
|
||||
focusLockListeners.forEach(cb => cb(focusLockedShapeId !== null, focusLockedShapeId))
|
||||
}
|
||||
|
||||
export const isCameraFocusLocked = () => focusLockedShapeId !== null
|
||||
export const getFocusLockedShapeId = () => focusLockedShapeId
|
||||
|
||||
// Helper function to store camera position
|
||||
const storeCameraPosition = (editor: Editor) => {
|
||||
const currentCamera = editor.getCamera()
|
||||
|
|
@ -290,6 +310,19 @@ export const setInitialCameraFromUrl = (editor: Editor) => {
|
|||
const zoom = url.searchParams.get("zoom")
|
||||
const shapeId = url.searchParams.get("shapeId")
|
||||
const frameId = url.searchParams.get("frameId")
|
||||
const focusId = url.searchParams.get("focusId")
|
||||
|
||||
// Handle focus lock mode - locks camera to a specific shape
|
||||
if (focusId) {
|
||||
// Small delay to ensure store is loaded
|
||||
setTimeout(() => {
|
||||
const success = lockCameraToShape(editor, focusId as TLShapeId)
|
||||
if (success) {
|
||||
editor.select(focusId as TLShapeId)
|
||||
}
|
||||
}, 100)
|
||||
return // Don't apply other camera settings when in focus mode
|
||||
}
|
||||
|
||||
if (x && y && zoom) {
|
||||
editor.stopCameraAnimation()
|
||||
|
|
@ -342,6 +375,158 @@ export const copyFrameLink = (_editor: Editor, frameId: string) => {
|
|||
navigator.clipboard.writeText(url.toString())
|
||||
}
|
||||
|
||||
// Lock camera to a specific shape - prevents panning/zooming and keeps shape centered
|
||||
export const lockCameraToShape = (editor: Editor, shapeId: TLShapeId) => {
|
||||
// Clean up any existing focus lock
|
||||
if (focusLockCleanup) {
|
||||
focusLockCleanup()
|
||||
}
|
||||
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) {
|
||||
console.warn("Cannot lock camera to non-existent shape:", shapeId)
|
||||
return false
|
||||
}
|
||||
|
||||
focusLockedShapeId = shapeId
|
||||
|
||||
// Center camera on the shape with appropriate zoom
|
||||
const bounds = editor.getShapePageBounds(shape)
|
||||
if (bounds) {
|
||||
const viewportBounds = editor.getViewportPageBounds()
|
||||
|
||||
// Calculate zoom to fit shape with padding
|
||||
const padding = 100
|
||||
const targetZoom = Math.min(
|
||||
(viewportBounds.w - padding * 2) / bounds.w,
|
||||
(viewportBounds.h - padding * 2) / bounds.h,
|
||||
2 // Max zoom
|
||||
)
|
||||
|
||||
editor.zoomToBounds(bounds, {
|
||||
targetZoom: Math.max(0.25, Math.min(targetZoom, 2)),
|
||||
inset: padding,
|
||||
animation: { duration: 400, easing: (t) => t * (2 - t) },
|
||||
})
|
||||
}
|
||||
|
||||
// Store original camera interaction methods to restore later
|
||||
const originalCameraOptions = editor.getCameraOptions()
|
||||
|
||||
// Disable camera panning and zooming
|
||||
editor.setCameraOptions({
|
||||
...originalCameraOptions,
|
||||
isLocked: true,
|
||||
})
|
||||
|
||||
// Watch for shape position changes to re-center camera
|
||||
const unsubscribeChange = editor.store.listen((entry) => {
|
||||
if (!focusLockedShapeId) return
|
||||
|
||||
// Check if the locked shape was updated
|
||||
for (const record of Object.values(entry.changes.updated)) {
|
||||
const [_from, to] = record as [TLShape, TLShape]
|
||||
if (to.id === focusLockedShapeId && to.typeName === 'shape') {
|
||||
// Shape moved, recenter camera
|
||||
const newBounds = editor.getShapePageBounds(to)
|
||||
if (newBounds) {
|
||||
const currentZoom = editor.getCamera().z
|
||||
editor.zoomToBounds(newBounds, {
|
||||
targetZoom: currentZoom,
|
||||
inset: 100,
|
||||
animation: { duration: 200 },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if locked shape was deleted
|
||||
for (const id of Object.keys(entry.changes.removed)) {
|
||||
if (id === focusLockedShapeId) {
|
||||
unlockCameraFocus(editor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup function
|
||||
focusLockCleanup = () => {
|
||||
unsubscribeChange()
|
||||
editor.setCameraOptions({
|
||||
...editor.getCameraOptions(),
|
||||
isLocked: false,
|
||||
})
|
||||
focusLockedShapeId = null
|
||||
focusLockCleanup = null
|
||||
notifyFocusLockListeners()
|
||||
}
|
||||
|
||||
notifyFocusLockListeners()
|
||||
return true
|
||||
}
|
||||
|
||||
// Unlock the camera from focus mode
|
||||
export const unlockCameraFocus = (_editor: Editor) => {
|
||||
if (focusLockCleanup) {
|
||||
focusLockCleanup()
|
||||
}
|
||||
|
||||
// Update URL to remove focusId
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("focusId")
|
||||
window.history.replaceState(null, "", url.toString())
|
||||
}
|
||||
|
||||
// Copy a focus link for the selected shape(s)
|
||||
export const copyFocusLink = async (editor: Editor) => {
|
||||
const selectedIds = editor.getSelectedShapeIds()
|
||||
if (selectedIds.length === 0) {
|
||||
console.warn("No shapes selected for focus link")
|
||||
return
|
||||
}
|
||||
|
||||
// Use the first selected shape
|
||||
const shapeId = selectedIds[0]
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
|
||||
// Build URL with focusId parameter
|
||||
const baseUrl = `${window.location.origin}${window.location.pathname}`
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set("focusId", shapeId.toString())
|
||||
|
||||
// Also include current camera bounds for context
|
||||
const bounds = editor.getShapePageBounds(shape)
|
||||
if (bounds) {
|
||||
// Calculate optimal camera position for the shape
|
||||
const viewportBounds = editor.getViewportPageBounds()
|
||||
const padding = 100
|
||||
const targetZoom = Math.min(
|
||||
(viewportBounds.w - padding * 2) / bounds.w,
|
||||
(viewportBounds.h - padding * 2) / bounds.h,
|
||||
2
|
||||
)
|
||||
url.searchParams.set("zoom", Math.max(0.25, Math.min(targetZoom, 2)).toFixed(2))
|
||||
}
|
||||
|
||||
const finalUrl = url.toString()
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(finalUrl)
|
||||
} else {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = finalUrl
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to copy focus link:", error)
|
||||
alert("Failed to copy link. Please check clipboard permissions.")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize lock indicators and watch for changes
|
||||
export const watchForLockedShapes = (editor: Editor) => {
|
||||
editor.on('change', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { CustomMainMenu } from "./CustomMainMenu"
|
||||
import { CustomToolbar } from "./CustomToolbar"
|
||||
import { CustomContextMenu } from "./CustomContextMenu"
|
||||
import { FocusLockIndicator } from "./FocusLockIndicator"
|
||||
import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
|
||||
import {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
DefaultKeyboardShortcutsDialogContent,
|
||||
|
|
@ -8,14 +10,96 @@ import {
|
|||
TldrawUiMenuItem,
|
||||
useTools,
|
||||
useActions,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from "tldraw"
|
||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||
|
||||
// Custom People Menu component for showing connected users
|
||||
function CustomPeopleMenu() {
|
||||
const editor = useEditor()
|
||||
|
||||
// Get current user info
|
||||
const myUserColor = useValue('myColor', () => editor.user.getColor(), [editor])
|
||||
const myUserName = useValue('myName', () => editor.user.getName() || 'You', [editor])
|
||||
|
||||
// Get all collaborators (other users in the session)
|
||||
const collaborators = useValue('collaborators', () => editor.getCollaborators(), [editor])
|
||||
|
||||
return (
|
||||
<div className="custom-people-menu">
|
||||
{/* Current user avatar */}
|
||||
<div
|
||||
title={`${myUserName} (you)`}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: myUserColor,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
cursor: 'default',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Other users */}
|
||||
{collaborators.map((presence) => (
|
||||
<div
|
||||
key={presence.id}
|
||||
title={`${presence.userName || 'Anonymous'}`}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: presence.color,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
marginLeft: '-8px',
|
||||
cursor: 'default',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* User count badge */}
|
||||
{collaborators.length > 0 && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-1)',
|
||||
marginLeft: '4px',
|
||||
}}>
|
||||
{collaborators.length + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom SharePanel that shows the people menu
|
||||
function CustomSharePanel() {
|
||||
return (
|
||||
<div className="tlui-share-zone" draggable={false}>
|
||||
<CustomPeopleMenu />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Combined InFrontOfCanvas component for floating UI elements
|
||||
function CustomInFrontOfCanvas() {
|
||||
return (
|
||||
<>
|
||||
<MycelialIntelligenceBar />
|
||||
<FocusLockIndicator />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const components: TLComponents = {
|
||||
Toolbar: CustomToolbar,
|
||||
MainMenu: CustomMainMenu,
|
||||
ContextMenu: CustomContextMenu,
|
||||
HelperButtons: SlidesPanel,
|
||||
SharePanel: CustomSharePanel,
|
||||
InFrontOfTheCanvas: CustomInFrontOfCanvas,
|
||||
KeyboardShortcutsDialog: (props: any) => {
|
||||
const tools = useTools()
|
||||
const actions = useActions()
|
||||
|
|
@ -34,6 +118,9 @@ export const components: TLComponents = {
|
|||
tools["Holon"],
|
||||
tools["FathomMeetings"],
|
||||
tools["ImageGen"],
|
||||
tools["VideoGen"],
|
||||
tools["Multmux"],
|
||||
// MycelialIntelligence moved to permanent floating bar
|
||||
].filter(tool => tool && tool.kbd)
|
||||
|
||||
// Get all custom actions with keyboard shortcuts
|
||||
|
|
@ -42,6 +129,8 @@ export const components: TLComponents = {
|
|||
actions["zoom-out"],
|
||||
actions["zoom-to-selection"],
|
||||
actions["copy-link-to-current-view"],
|
||||
actions["copy-focus-link"],
|
||||
actions["unlock-camera-focus"],
|
||||
actions["revert-camera"],
|
||||
actions["lock-element"],
|
||||
actions["save-to-pdf"],
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import {
|
|||
import {
|
||||
cameraHistory,
|
||||
copyLinkToCurrentView,
|
||||
copyFocusLink,
|
||||
lockElement,
|
||||
revertCamera,
|
||||
unlockElement,
|
||||
unlockCameraFocus,
|
||||
zoomToSelection,
|
||||
} from "./cameraUtils"
|
||||
import { saveToPdf } from "../utils/pdfUtils"
|
||||
|
|
@ -228,6 +230,43 @@ export const overrides: TLUiOverrides = {
|
|||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("Multmux"),
|
||||
},
|
||||
MycelialIntelligence: {
|
||||
id: "MycelialIntelligence",
|
||||
icon: "chat",
|
||||
label: "Mycelial Intelligence",
|
||||
kbd: "ctrl+shift+m",
|
||||
readonlyOk: true,
|
||||
type: "MycelialIntelligence",
|
||||
onSelect: () => {
|
||||
// Spawn the MI shape at top center of viewport
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
const shapeWidth = 600
|
||||
const shapeHeight = 60
|
||||
|
||||
// Calculate center top position
|
||||
const x = viewport.x + (viewport.w / 2) - (shapeWidth / 2)
|
||||
const y = viewport.y + 20
|
||||
|
||||
// Check if MI already exists on canvas - if so, select it
|
||||
const existingMI = editor.getCurrentPageShapes().find(s => s.type === 'MycelialIntelligence')
|
||||
if (existingMI) {
|
||||
editor.setSelectedShapes([existingMI.id])
|
||||
return
|
||||
}
|
||||
|
||||
// Create the shape
|
||||
editor.createShape({
|
||||
type: 'MycelialIntelligence',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
pinnedToView: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
hand: {
|
||||
...tools.hand,
|
||||
onDoubleClick: (info: any) => {
|
||||
|
|
@ -292,6 +331,24 @@ export const overrides: TLUiOverrides = {
|
|||
onSelect: () => copyLinkToCurrentView(editor),
|
||||
readonlyOk: true,
|
||||
},
|
||||
copyFocusLink: {
|
||||
id: "copy-focus-link",
|
||||
label: "Copy Focus Link (Locked View)",
|
||||
kbd: "alt+shift+f",
|
||||
onSelect: () => {
|
||||
if (editor.getSelectedShapeIds().length > 0) {
|
||||
copyFocusLink(editor)
|
||||
}
|
||||
},
|
||||
readonlyOk: true,
|
||||
},
|
||||
unlockCameraFocus: {
|
||||
id: "unlock-camera-focus",
|
||||
label: "Unlock Camera",
|
||||
kbd: "escape",
|
||||
onSelect: () => unlockCameraFocus(editor),
|
||||
readonlyOk: true,
|
||||
},
|
||||
revertCamera: {
|
||||
id: "revert-camera",
|
||||
label: "Revert Camera",
|
||||
|
|
|
|||
|
|
@ -362,11 +362,13 @@ export class AutomergeDurableObject {
|
|||
break
|
||||
case "sync":
|
||||
// Handle Automerge sync message
|
||||
if (message.data && message.documentId) {
|
||||
// This is a sync message with binary data
|
||||
if (message.data) {
|
||||
// This is a sync message with data - broadcast to other clients
|
||||
// CRITICAL: Don't require documentId - JSON sync messages might not have it
|
||||
// but they still need to be broadcast for real-time collaboration
|
||||
await this.handleSyncMessage(sessionId, message)
|
||||
} else {
|
||||
// This is a sync request - send current document state
|
||||
// This is a sync request (no data) - send current document state
|
||||
const doc = await this.getDocument()
|
||||
const client = this.clients.get(sessionId)
|
||||
if (client) {
|
||||
|
|
@ -1418,6 +1420,53 @@ export class AutomergeDurableObject {
|
|||
})
|
||||
}
|
||||
|
||||
// Special handling for Multmux shapes - ensure all required props exist
|
||||
// Old shapes may have wsUrl (removed) or undefined values
|
||||
// CRITICAL: Every prop must be explicitly defined - undefined values cause ValidationError
|
||||
if (record.type === 'Multmux') {
|
||||
if (!record.props || typeof record.props !== 'object') {
|
||||
record.props = {}
|
||||
needsUpdate = true
|
||||
}
|
||||
// Remove deprecated wsUrl prop
|
||||
if ('wsUrl' in record.props) {
|
||||
delete record.props.wsUrl
|
||||
needsUpdate = true
|
||||
}
|
||||
// CRITICAL: Create clean props with all required values - no undefined allowed
|
||||
const w = (typeof record.props.w === 'number' && !isNaN(record.props.w)) ? record.props.w : 800
|
||||
const h = (typeof record.props.h === 'number' && !isNaN(record.props.h)) ? record.props.h : 600
|
||||
const sessionId = (typeof record.props.sessionId === 'string') ? record.props.sessionId : ''
|
||||
const sessionName = (typeof record.props.sessionName === 'string') ? record.props.sessionName : ''
|
||||
const token = (typeof record.props.token === 'string') ? record.props.token : ''
|
||||
const serverUrl = (typeof record.props.serverUrl === 'string') ? record.props.serverUrl : 'http://localhost:3000'
|
||||
const pinnedToView = (record.props.pinnedToView === true) ? true : false
|
||||
// Filter out any undefined or non-string elements from tags array
|
||||
let tags: string[] = ['terminal', 'multmux']
|
||||
if (Array.isArray(record.props.tags)) {
|
||||
const filteredTags = record.props.tags.filter((t: any) => typeof t === 'string' && t !== '')
|
||||
if (filteredTags.length > 0) {
|
||||
tags = filteredTags
|
||||
}
|
||||
}
|
||||
// Check if any prop needs updating
|
||||
if (record.props.w !== w || record.props.h !== h ||
|
||||
record.props.sessionId !== sessionId || record.props.sessionName !== sessionName ||
|
||||
record.props.token !== token || record.props.serverUrl !== serverUrl ||
|
||||
record.props.pinnedToView !== pinnedToView ||
|
||||
JSON.stringify(record.props.tags) !== JSON.stringify(tags)) {
|
||||
record.props.w = w
|
||||
record.props.h = h
|
||||
record.props.sessionId = sessionId
|
||||
record.props.sessionName = sessionName
|
||||
record.props.token = token
|
||||
record.props.serverUrl = serverUrl
|
||||
record.props.pinnedToView = pinnedToView
|
||||
record.props.tags = tags
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
migrationStats.migrated++
|
||||
// Only log detailed migration info for first few shapes to avoid spam
|
||||
|
|
|
|||
Loading…
Reference in New Issue