543 lines
17 KiB
TypeScript
543 lines
17 KiB
TypeScript
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<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()
|
|
// 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()
|
|
}
|
|
})
|
|
})
|
|
}
|