import { Editor, useDefaultHelpers } from "tldraw" import { shapeIdValidator, TLArrowShape, TLGeoShape, TLUiOverrides, } from "tldraw" import { cameraHistory, copyLinkToCurrentView, copyFocusLink, lockElement, revertCamera, unlockElement, unlockCameraFocus, zoomToSelection, } from "./cameraUtils" import { saveToPdf } from "../utils/pdfUtils" import { searchText, searchSemantic, askCanvasAI, indexCanvasForSearch, explainViewport, findSimilarToSelection } from "../utils/searchUtils" import { EmbedShape, IEmbedShape } from "@/shapes/EmbedShapeUtil" import { moveToSlide } from "@/slides/useSlides" import { ISlideShape } from "@/shapes/SlideShapeUtil" import { getEdge } from "@/propagators/tlgraph" import { llm, getApiKey } from "@/utils/llmUtils" // Feature flags - must match Board.tsx to prevent tool registration mismatch const IS_PRODUCTION = import.meta.env.PROD const ENABLE_WORKFLOW = !IS_PRODUCTION // Workflow blocks - dev only const ENABLE_CALENDAR = !IS_PRODUCTION // Calendar - dev only const ENABLE_DRAWFAST = !IS_PRODUCTION // Drawfast - dev only export const overrides: TLUiOverrides = { tools(editor, tools) { return { ...tools, select: { ...tools.select, onPointerDown: (info: any) => { const shape = editor.getShapeAtPoint(info.point) if (shape && editor.getSelectedShapeIds().includes(shape.id)) { // If clicking on a selected shape, initiate drag behavior editor.dispatch({ type: "pointer", name: "pointer_down", point: info.point, button: info.button, shiftKey: info.shiftKey, altKey: info.altKey, ctrlKey: info.ctrlKey, metaKey: info.metaKey, pointerId: info.pointerId, target: "shape", shape, isPen: false, accelKey: info.ctrlKey || info.metaKey, }) return } // Otherwise, use default select tool behavior ;(tools.select as any).onPointerDown?.(info) }, //TODO: Fix double click to zoom on selector tool later... // onDoubleClick: (info: any) => { // const shape = editor.getShapeAtPoint(info.point) // if (shape?.type === "Embed") { // // Let the Embed shape handle its own double-click behavior // const util = editor.getShapeUtil(shape) as EmbedShape // util?.onDoubleClick?.(shape as IEmbedShape) // return true // } // // Handle all pointer types (mouse, touch, pen) // const point = info.point || (info.touches && info.touches[0]) || info // // Zoom in at the clicked/touched point // editor.zoomIn(point, { animation: { duration: 200 } }) // // Prevent default text creation // info.preventDefault?.() // info.stopPropagation?.() // return true // }, // onDoubleClickCanvas: (info: any) => { // // Handle all pointer types (mouse, touch, pen) // const point = info.point || (info.touches && info.touches[0]) || info // // Zoom in at the clicked/touched point // editor.zoomIn(point, { animation: { duration: 200 } }) // // Prevent default text creation // info.preventDefault?.() // info.stopPropagation?.() // return true // }, }, VideoChat: { id: "VideoChat", icon: "video", label: "Video Chat", kbd: "ctrl+shift+v", readonlyOk: true, type: "VideoChat", onSelect: () => editor.setCurrentTool("VideoChat"), }, ChatBox: { id: "ChatBox", icon: "chat", label: "Chat", kbd: "ctrl+shift+c", readonlyOk: true, type: "ChatBox", onSelect: () => editor.setCurrentTool("ChatBox"), }, Embed: { id: "Embed", icon: "embed", label: "Embed", kbd: "ctrl+shift+e", readonlyOk: true, type: "Embed", onSelect: () => editor.setCurrentTool("Embed"), }, SlideShape: { id: "Slide", icon: "slides", label: "Slide", kbd: "ctrl+shift+s", type: "Slide", readonlyOk: true, onSelect: () => { editor.setCurrentTool("Slide") }, }, Markdown: { id: "Markdown", icon: "markdown", label: "Markdown", kbd: "ctrl+shift+m", readonlyOk: true, type: "Markdown", onSelect: () => editor.setCurrentTool("Markdown"), }, MycroZineGenerator: { id: "MycroZineGenerator", icon: "zine", label: "MycroZine Generator", type: "MycroZineGenerator", kbd: "ctrl+shift+z", readonlyOk: true, onSelect: () => editor.setCurrentTool("MycroZineGenerator"), }, Prompt: { id: "Prompt", icon: "prompt", label: "LLM Prompt", type: "Prompt", kbd: "ctrl+shift+l", readonlyOk: true, onSelect: () => editor.setCurrentTool("Prompt"), }, gesture: { id: "gesture", icon: "draw", label: "Gesture", readonlyOk: true, type: "gesture", onSelect: () => editor.setCurrentTool("gesture"), }, ObsidianNote: { id: "ObsidianNote", icon: "file-text", label: "Obsidian Note", kbd: "ctrl+shift+o", readonlyOk: true, type: "ObsNote", onSelect: () => editor.setCurrentTool("ObsidianNote"), }, Transcription: { id: "Transcription", icon: "microphone", label: "Transcription", kbd: "ctrl+shift+t", readonlyOk: true, type: "Transcription", onSelect: () => editor.setCurrentTool("Transcription"), }, Holon: { id: "Holon", icon: "circle", label: "Holon", kbd: "ctrl+shift+h", readonlyOk: true, type: "Holon", onSelect: () => editor.setCurrentTool("Holon"), }, FathomMeetings: { id: "fathom-meetings", icon: "calendar", label: "Fathom Meetings", kbd: "ctrl+shift+f", readonlyOk: true, // Removed type property to prevent automatic shape creation // Shape creation is handled manually in FathomMeetingsTool.onPointerDown onSelect: () => editor.setCurrentTool("fathom-meetings"), }, MeetingIntelligence: { id: "meeting-intelligence", icon: "microphone", label: "Meeting Intelligence", readonlyOk: true, onSelect: () => editor.setCurrentTool("meeting-intelligence"), }, ImageGen: { id: "ImageGen", icon: "image", label: "Image Generation", kbd: "ctrl+shift+i", readonlyOk: true, type: "ImageGen", onSelect: () => editor.setCurrentTool("ImageGen"), }, VideoGen: { id: "VideoGen", icon: "video", label: "Video Generation", kbd: "ctrl+shift+g", readonlyOk: true, onSelect: () => editor.setCurrentTool("VideoGen"), }, BlenderGen: { id: "BlenderGen", icon: "box", label: "Blender 3D", kbd: "ctrl+shift+b", readonlyOk: true, onSelect: () => editor.setCurrentTool("BlenderGen"), }, // Drawfast - only available in dev (must match ENABLE_DRAWFAST flag in Board.tsx) ...(ENABLE_DRAWFAST ? { Drawfast: { id: "Drawfast", icon: "tool-pencil", label: "Drawfast (AI Sketch)", kbd: "ctrl+shift+d", readonlyOk: true, onSelect: () => editor.setCurrentTool("Drawfast"), }, } : {}), Multmux: { id: "Multmux", icon: "terminal", label: "Terminal", kbd: "ctrl+shift+k", readonlyOk: true, onSelect: () => editor.setCurrentTool("Multmux"), }, Map: { id: "Map", icon: "geo-globe", label: "Map", kbd: "ctrl+shift+m", readonlyOk: true, onSelect: () => editor.setCurrentTool("map"), }, // Calendar - only available in dev (must match ENABLE_CALENDAR flag in Board.tsx) ...(ENABLE_CALENDAR ? { calendar: { id: "calendar", icon: "calendar", label: "Calendar", kbd: "ctrl+alt+k", readonlyOk: true, onSelect: () => editor.setCurrentTool("calendar"), }, } : {}), // WorkflowBlock - only available in dev (must match ENABLE_WORKFLOW flag in Board.tsx) ...(ENABLE_WORKFLOW ? { WorkflowBlock: { id: "WorkflowBlock", icon: "sticker", label: "Workflow Block", readonlyOk: true, onSelect: () => editor.setCurrentTool("WorkflowBlock"), }, } : {}), // MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx) hand: { ...tools.hand, onDoubleClick: (info: any) => { editor.zoomIn(info.point, { animation: { duration: 200 } }) }, onPointerDown: (info: any) => { // Make hand tool drag shapes when clicking on them (without requiring selection) // Since tldraw already detects shapes on hover (cursor changes), getShapeAtPoint should work const shape = editor.getShapeAtPoint(info.point) if (shape) { // Drag the shape directly without selecting it first editor.dispatch({ type: "pointer", name: "pointer_down", point: info.point, button: info.button, shiftKey: info.shiftKey, altKey: info.altKey, ctrlKey: info.ctrlKey, metaKey: info.metaKey, pointerId: info.pointerId, target: "shape", shape, isPen: false, accelKey: info.ctrlKey || info.metaKey, }) return // Don't let hand tool move camera } // If not clicking on a shape, use default hand tool behavior (move camera) ;(tools.hand as any).onPointerDown?.(info) }, }, } }, actions(editor, actions) { const customActions = { "zoom-in": { ...actions["zoom-in"], kbd: "ctrl+up", }, "zoom-out": { ...actions["zoom-out"], kbd: "ctrl+down", }, zoomToSelection: { id: "zoom-to-selection", label: "Zoom to Selection", kbd: "z", onSelect: () => { if (editor.getSelectedShapeIds().length > 0) { zoomToSelection(editor) } }, readonlyOk: true, }, copyLinkToCurrentView: { id: "copy-link-to-current-view", label: "Copy Link to Current View", kbd: "ctrl+alt+c", onSelect: () => copyLinkToCurrentView(editor), readonlyOk: true, }, copyFocusLink: { id: "copy-focus-link", label: "Copy Focus Link (Locked View)", kbd: "ctrl+alt+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", kbd: "ctrl+alt+b", onSelect: () => { if (cameraHistory.length > 0) { revertCamera(editor) } }, readonlyOk: true, }, lockElement: { id: "lock-element", label: "Lock Element", kbd: "shift+l", onSelect: () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { lockElement(editor) } }, readonlyOk: true, }, unlockElement: { id: "unlock-element", label: "Unlock Element", onSelect: () => { if (editor.getSelectedShapeIds().length > 0) { unlockElement(editor, editor.getSelectedShapeIds()[0]) } }, }, saveToPdf: { id: "save-to-pdf", label: "Save Selection as PDF", kbd: "ctrl+alt+p", onSelect: () => { if (editor.getSelectedShapeIds().length > 0) { saveToPdf(editor) } }, readonlyOk: true, }, moveSelectedLeft: { id: "move-selected-left", label: "Move Left", kbd: "ArrowLeft", onSelect: () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { selectedShapes.forEach((shape) => { editor.updateShape({ id: shape.id, type: shape.type, x: shape.x - 50, y: shape.y, }) }) } }, }, moveSelectedRight: { id: "move-selected-right", label: "Move Right", kbd: "ArrowRight", onSelect: () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { selectedShapes.forEach((shape) => { editor.updateShape({ id: shape.id, type: shape.type, x: shape.x + 50, y: shape.y, }) }) } }, }, moveSelectedUp: { id: "move-selected-up", label: "Move Up", kbd: "ArrowUp", onSelect: () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { selectedShapes.forEach((shape) => { editor.updateShape({ id: shape.id, type: shape.type, x: shape.x, y: shape.y - 50, }) }) } }, }, moveSelectedDown: { id: "move-selected-down", label: "Move Down", kbd: "ArrowDown", onSelect: () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { selectedShapes.forEach((shape) => { editor.updateShape({ id: shape.id, type: shape.type, x: shape.x, y: shape.y + 50, }) }) } }, }, searchShapes: { id: "search-shapes", label: "Search Shapes", kbd: "s", readonlyOk: true, onSelect: () => searchText(editor), }, semanticSearch: { id: "semantic-search", label: "Semantic Search (AI)", kbd: "shift+s", readonlyOk: true, onSelect: async () => { try { await searchSemantic(editor) } catch (error) { console.error("Semantic search error:", error) } }, }, askCanvasAI: { id: "ask-canvas-ai", label: "Ask AI About Canvas", kbd: "shift+a", readonlyOk: true, onSelect: async () => { try { // Create a simple modal/prompt for AI response const answer = await askCanvasAI(editor, undefined, (_partial, done) => { // Log streaming response to console for now if (!done) { } }) if (answer) { // Could display in a UI element - for now show alert with result } } catch (error) { console.error("Canvas AI error:", error) } }, }, indexCanvas: { id: "index-canvas", label: "Index Canvas for AI Search", kbd: "ctrl+shift+i", readonlyOk: true, onSelect: async () => { try { await indexCanvasForSearch(editor, (_progress) => { }) } catch (error) { console.error("Canvas indexing error:", error) } }, }, explainViewport: { id: "explain-viewport", label: "Explain Current View", kbd: "shift+e", readonlyOk: true, onSelect: async () => { try { await explainViewport(editor, (_partial, done) => { if (!done) { } }) } catch (error) { console.error("Viewport explanation error:", error) } }, }, findSimilar: { id: "find-similar", label: "Find Similar Shapes", kbd: "shift+f", readonlyOk: true, onSelect: async () => { if (editor.getSelectedShapeIds().length === 0) { return } try { const results = await findSimilarToSelection(editor) } catch (error) { console.error("Find similar error:", error) } }, }, llm: { id: "llm", label: "Run LLM Prompt", kbd: "ctrl+alt+r", readonlyOk: true, onSelect: () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { const selectedShape = selectedShapes[0] as TLArrowShape if (selectedShape.type !== "arrow") { return } const edge = getEdge(selectedShape, editor) if (!edge) { return } const sourceShape = editor.getShape(edge.from) const sourceText = sourceShape && sourceShape.type === "geo" ? (sourceShape.meta as any)?.text || "" : "" const prompt = `Instruction: ${edge.text} ${sourceText ? `Context: ${sourceText}` : ""}`; try { llm(prompt, (partialResponse: string) => { const targetShape = editor.getShape(edge.to) as TLGeoShape // Convert plain text to richText format for TLDraw // Split by newlines to create multiple paragraphs const paragraphs = partialResponse.split('\n').filter(line => line.trim() !== '') const richTextContent = paragraphs.length > 0 ? paragraphs.map(paragraph => ({ type: 'paragraph' as const, content: [ { type: 'text' as const, text: paragraph } ] })) : [ { type: 'paragraph' as const, content: [ { type: 'text' as const, text: partialResponse || '' } ] } ] const richText = { content: richTextContent, type: 'doc' as const } editor.updateShape({ id: edge.to, type: "geo", props: { ...targetShape.props, richText: richText, // Store text in richText format for display }, meta: { ...targetShape.meta, // Keep text in meta for backwards compatibility if needed text: partialResponse, }, }) }) } catch (error) { console.error("Error calling LLM:", error); } } else { } }, }, openObsidianBrowser: { id: "open-obsidian-browser", label: "Open Obsidian Browser", // Removed kbd: "alt+o" - Alt+O now selects the ObsidianNote tool instead readonlyOk: true, onSelect: () => { // Trigger the Obsidian browser to open // This will be handled by the ObsidianToolbarButton component const event = new CustomEvent('open-obsidian-browser') window.dispatchEvent(event) }, }, //TODO: FIX PREV & NEXT SLIDE KEYBOARD COMMANDS // "next-slide": { // id: "next-slide", // label: "Next slide", // kbd: "right", // onSelect() { // const slides = editor // .getCurrentPageShapes() // .filter((shape) => shape.type === "Slide") // if (slides.length === 0) return // const currentSlide = editor // .getSelectedShapes() // .find((shape) => shape.type === "Slide") // const currentIndex = currentSlide // ? slides.findIndex((slide) => slide.id === currentSlide.id) // : -1 // // Calculate next index with wraparound // const nextIndex = // currentIndex === -1 // ? 0 // : currentIndex >= slides.length - 1 // ? 0 // : currentIndex + 1 // const nextSlide = slides[nextIndex] // editor.select(nextSlide.id) // editor.stopCameraAnimation() // moveToSlide(editor, nextSlide as ISlideShape) // }, // }, // "previous-slide": { // id: "previous-slide", // label: "Previous slide", // kbd: "left", // onSelect() { // const slides = editor // .getCurrentPageShapes() // .filter((shape) => shape.type === "Slide") // if (slides.length === 0) return // const currentSlide = editor // .getSelectedShapes() // .find((shape) => shape.type === "Slide") // const currentIndex = currentSlide // ? slides.findIndex((slide) => slide.id === currentSlide.id) // : -1 // // Calculate previous index with wraparound // const previousIndex = // currentIndex <= 0 ? slides.length - 1 : currentIndex - 1 // const previousSlide = slides[previousIndex] // editor.select(previousSlide.id) // editor.stopCameraAnimation() // moveToSlide(editor, previousSlide as ISlideShape) // }, // }, } return { ...actions, ...customActions, } }, } // Export actions for use in context menu export const getCustomActions = (editor: Editor) => { const helpers = useDefaultHelpers() return overrides.actions?.(editor, {}, helpers) ?? {} }