import { Editor, TLFrameShape, TLParentId, TLShape, TLShapeId } from "tldraw" export const cameraHistory: { x: number; y: number; z: number }[] = [] const MAX_HISTORY = 10 // Keep last 10 camera positions const frameObservers = new Map() // 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() // Only store if there's a meaningful change from the last position const lastPosition = cameraHistory[cameraHistory.length - 1] if ( !lastPosition || Math.abs(lastPosition.x - currentCamera.x) > 1 || Math.abs(lastPosition.y - currentCamera.y) > 1 || Math.abs(lastPosition.z - currentCamera.z) > 0.1 ) { cameraHistory.push({ ...currentCamera }) if (cameraHistory.length > MAX_HISTORY) { cameraHistory.shift() } } } export const zoomToSelection = (editor: Editor) => { // Store camera position before zooming storeCameraPosition(editor) // Get all selected shape IDs const selectedIds = editor.getSelectedShapeIds() if (selectedIds.length === 0) return // Get the common bounds that encompass all selected shapes const commonBounds = editor.getSelectionPageBounds() if (!commonBounds) return // Calculate viewport dimensions const viewportPageBounds = editor.getViewportPageBounds() // Calculate the ratio of selection size to viewport size const widthRatio = commonBounds.width / viewportPageBounds.width const heightRatio = commonBounds.height / viewportPageBounds.height // Calculate target zoom based on selection size let targetZoom if (widthRatio < 0.1 || heightRatio < 0.1) { // For very small selections, zoom in up to 20x targetZoom = Math.min( (viewportPageBounds.width * 0.8) / commonBounds.width, (viewportPageBounds.height * 0.8) / commonBounds.height, 40, // Max zoom of 20x for small selections ) } else if (widthRatio > 1 || heightRatio > 1) { // For selections larger than viewport, zoom out more targetZoom = Math.min( (viewportPageBounds.width * 0.7) / commonBounds.width, (viewportPageBounds.height * 0.7) / commonBounds.height, 0.125, // Min zoom of 1/8x for large selections (reciprocal of 8) ) } else { // For medium-sized selections, allow up to 10x zoom targetZoom = Math.min( (viewportPageBounds.width * 0.8) / commonBounds.width, (viewportPageBounds.height * 0.8) / commonBounds.height, 20, // Medium zoom level ) } // Zoom to the common bounds editor.zoomToBounds(commonBounds, { targetZoom, inset: widthRatio > 1 || heightRatio > 1 ? 20 : 50, // Less padding for large selections animation: { duration: 400, easing: (t) => t * (2 - t), }, }) // Update URL with new camera position and first selected shape ID const newCamera = editor.getCamera() const url = new URL(window.location.href) url.searchParams.set("shapeId", selectedIds[0].toString()) url.searchParams.set("x", newCamera.x.toFixed(2)) url.searchParams.set("y", newCamera.y.toFixed(2)) url.searchParams.set("zoom", newCamera.z.toFixed(2)) window.history.replaceState(null, "", url.toString()) } export const revertCamera = (editor: Editor) => { if (cameraHistory.length > 0) { const previousCamera = cameraHistory.pop() if (previousCamera) { // Get current viewport bounds const viewportPageBounds = editor.getViewportPageBounds() // Create bounds that center on the previous camera position const targetBounds = { x: previousCamera.x - viewportPageBounds.width / 2 / previousCamera.z, y: previousCamera.y - viewportPageBounds.height / 2 / previousCamera.z, w: viewportPageBounds.width / previousCamera.z, h: viewportPageBounds.height / previousCamera.z, } // Use the same zoom animation as zoomToShape editor.zoomToBounds(targetBounds, { targetZoom: previousCamera.z, animation: { duration: 400, easing: (t) => t * (2 - t), }, }) //console.log("Reverted to camera position:", previousCamera) } } else { //console.log("No camera history available") } } export const copyLinkToCurrentView = async (editor: Editor) => { if (!editor.store.serialize()) { //console.warn("Store not ready") return } try { const baseUrl = `${window.location.origin}${window.location.pathname}` const url = new URL(baseUrl) const camera = editor.getCamera() // Preserve two decimal points for camera values url.searchParams.set("x", camera.x.toFixed(2)) url.searchParams.set("y", camera.y.toFixed(2)) url.searchParams.set("zoom", camera.z.toFixed(2)) const selectedIds = editor.getSelectedShapeIds() if (selectedIds.length > 0) { url.searchParams.set("shapeId", selectedIds[0].toString()) } const finalUrl = url.toString() if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(finalUrl) } else { const textArea = document.createElement("textarea") textArea.value = finalUrl document.body.appendChild(textArea) try { await navigator.clipboard.writeText(textArea.value) } catch (err) { } document.body.removeChild(textArea) } } catch (error) { alert("Failed to copy link. Please check clipboard permissions.") } } // Add this function to create lock indicators const createLockIndicator = (editor: Editor, shape: TLShape) => { const lockIndicator = document.createElement('div') lockIndicator.id = `lock-indicator-${shape.id}` lockIndicator.className = 'lock-indicator' lockIndicator.innerHTML = '🔒' // Set styles to position at top-right of shape lockIndicator.style.position = 'absolute' lockIndicator.style.right = '3px' lockIndicator.style.top = '3px' lockIndicator.style.pointerEvents = 'all' lockIndicator.style.zIndex = '99999' lockIndicator.style.background = 'white' lockIndicator.style.border = '1px solid #ddd' lockIndicator.style.borderRadius = '4px' lockIndicator.style.padding = '4px' lockIndicator.style.cursor = 'pointer' lockIndicator.style.boxShadow = '0 1px 3px rgba(0,0,0,0.12)' lockIndicator.style.fontSize = '12px' lockIndicator.style.lineHeight = '1' lockIndicator.style.display = 'flex' lockIndicator.style.alignItems = 'center' lockIndicator.style.justifyContent = 'center' lockIndicator.style.width = '20px' lockIndicator.style.height = '20px' lockIndicator.style.userSelect = 'none' // Add hover effect lockIndicator.onmouseenter = () => { lockIndicator.style.backgroundColor = '#f0f0f0' } lockIndicator.onmouseleave = () => { lockIndicator.style.backgroundColor = 'white' } // Add tooltip and click handlers with stopPropagation lockIndicator.title = 'Unlock shape' lockIndicator.addEventListener('click', (e) => { e.stopPropagation() e.preventDefault() unlockElement(editor, shape.id) }, true) lockIndicator.addEventListener('mousedown', (e) => { e.stopPropagation() e.preventDefault() }, true) lockIndicator.addEventListener('pointerdown', (e) => { e.stopPropagation() e.preventDefault() }, true) const shapeElement = document.querySelector(`[data-shape-id="${shape.id}"]`) if (shapeElement) { shapeElement.appendChild(lockIndicator) } } // Modify lockElement to use the new function export const lockElement = async (editor: Editor) => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length === 0) return try { selectedShapes.forEach(shape => { editor.updateShape({ id: shape.id, type: shape.type, isLocked: true, meta: { ...shape.meta, isLocked: true, canInteract: true, // Allow interactions canMove: false, // Prevent moving canResize: false, // Prevent resizing canEdit: true, // Allow text editing canUpdateProps: true // Allow updating props (for prompt inputs/outputs) //TO DO: FIX TEXT INPUT ON LOCKED ELEMENTS (e.g. prompt shape) AND ATTACH TO SCREEN EDGE } }) createLockIndicator(editor, shape) }) } catch (error) { console.error("Failed to lock elements:", error) } } export const unlockElement = (editor: Editor, shapeId: string) => { const indicator = document.getElementById(`lock-indicator-${shapeId}`) if (indicator) { indicator.remove() } const shape = editor.getShape(shapeId as TLShapeId) if (shape) { editor.updateShape({ id: shapeId as TLShapeId, type: shape.type, isLocked: false, meta: { ...shape.meta, isLocked: false, canInteract: true, canMove: true, canResize: true, canEdit: true, canUpdateProps: true } }) } } // Initialize lock indicators based on stored state export const initLockIndicators = (editor: Editor) => { editor.getCurrentPageShapes().forEach(shape => { if (shape.isLocked || shape.meta?.isLocked) { createLockIndicator(editor, shape) } }) } export const setInitialCameraFromUrl = (editor: Editor) => { const url = new URL(window.location.href) const x = url.searchParams.get("x") const y = url.searchParams.get("y") 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() editor.setCamera( { x: parseFloat(x), y: parseFloat(y), z: parseFloat(zoom) }, { animation: { duration: 0 } } ) } // Handle shape/frame selection and zoom if (shapeId) { editor.select(shapeId as TLShapeId) const bounds = editor.getSelectionPageBounds() if (bounds && !x && !y && !zoom) { zoomToSelection(editor) } } else if (frameId) { editor.select(frameId as TLShapeId) const frame = editor.getShape(frameId as TLShapeId) if (frame && !x && !y && !zoom) { const bounds = editor.getShapePageBounds(frame as TLShape) if (bounds) { editor.zoomToBounds(bounds, { targetZoom: 1, animation: { duration: 0 }, }) } } } } export const zoomToFrame = (editor: Editor, frameId: string) => { if (!editor) return const frame = editor.getShape(frameId as TLParentId) as TLFrameShape if (!frame) return editor.zoomToBounds(editor.getShapePageBounds(frame)!, { inset: 32, animation: { duration: 500 }, }) } export const copyFrameLink = (_editor: Editor, frameId: string) => { const url = new URL(window.location.href) url.searchParams.set("frameId", frameId) 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', () => { editor.getCurrentPageShapes().forEach(shape => { const hasIndicator = document.getElementById(`lock-indicator-${shape.id}`) if (shape.isLocked && !hasIndicator) { createLockIndicator(editor, shape) } else if (!shape.isLocked && hasIndicator) { hasIndicator.remove() } }) }) }