diff --git a/src/css/style.css b/src/css/style.css index 8fb74d6..6b80367 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -376,4 +376,24 @@ p:has(+ ol) { box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15); overflow: hidden; background-color: white; +} + +.lock-indicator { + position: absolute; + width: 24px; + height: 24px; + background: white; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1000; + transition: transform 0.2s ease; +} + +.lock-indicator:hover { + transform: scale(1.1) !important; + background: #f0f0f0; } \ No newline at end of file diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 38e5e69..d67f9a9 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -1,6 +1,6 @@ import { useSync } from "@tldraw/sync" import { useMemo, useEffect, useState } from "react" -import { Tldraw, Editor } from "tldraw" +import { Tldraw, Editor, TLShapeId } from "tldraw" import { useParams } from "react-router-dom" import { ChatBoxTool } from "@/tools/ChatBoxTool" import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil" @@ -30,7 +30,13 @@ import { makeRealSettings, applySettingsMigrations } from "@/lib/settings" import { PromptShapeTool } from "@/tools/PromptShapeTool" import { PromptShape } from "@/shapes/PromptShapeUtil" import { llm } from "@/utils/llmUtils" -import { setInitialCameraFromUrl } from "@/ui/cameraUtils" +import { + lockElement, + unlockElement, + setInitialCameraFromUrl, + initLockIndicators, + watchForLockedShapes, +} from "@/ui/cameraUtils" // Default to production URL if env var isn't available export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" @@ -84,6 +90,13 @@ export function Board() { } }, []) + // Remove the URL-based locking effect and replace with store-based initialization + useEffect(() => { + if (!editor) return + initLockIndicators(editor) + watchForLockedShapes(editor) + }, [editor]) + return (
{ + const customActions = overrides.actions?.(editor, actions, helpers) ?? {} + return { + ...actions, + ...customActions, + } + } + }} cameraOptions={{ zoomSteps: [ 0.001, // Min zoom diff --git a/src/shapes/EmbedShapeUtil.tsx b/src/shapes/EmbedShapeUtil.tsx index 65e5337..ae6efd0 100644 --- a/src/shapes/EmbedShapeUtil.tsx +++ b/src/shapes/EmbedShapeUtil.tsx @@ -2,6 +2,9 @@ import { BaseBoxShapeUtil, TLBaseShape } from "tldraw" import { useCallback, useState } from "react" //import Embed from "react-embed" + +//TODO: FIX PEN AND MOBILE INTERACTION WITH EDITING EMBED URL - DEFAULT TO TEXT SELECTED + export type IEmbedShape = TLBaseShape< "Embed", { @@ -372,27 +375,53 @@ export class EmbedShape extends BaseBoxShapeUtil { return (
{controls("")} -
+
{ + e.preventDefault() + e.stopPropagation() + const input = e.currentTarget.querySelector('input') + input?.focus() + }} + >
e.stopPropagation()} > setInputUrl(e.target.value)} - placeholder="Enter URL" + placeholder="Enter URL to embed" style={{ width: "100%", - height: "100%", - border: "none", - padding: "10px", + padding: "15px", // Increased padding for better touch target + border: "1px solid #ccc", + borderRadius: "4px", + fontSize: "16px", // Increased font size for better visibility + touchAction: 'none', }} onKeyDown={(e) => { if (e.key === "Enter") { handleSubmit(e) } }} + onPointerDown={(e) => { + e.stopPropagation() + e.currentTarget.focus() + }} /> {error && (
{error}
@@ -555,7 +584,7 @@ export class EmbedShape extends BaseBoxShapeUtil { } } - // Add new method to handle all pointer interactions + // Update the pointer down handler onPointerDown = (shape: IEmbedShape) => { if (!shape.props.url) { const input = document.querySelector('input') diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx index dc4b3b8..c291aea 100644 --- a/src/ui/CustomContextMenu.tsx +++ b/src/ui/CustomContextMenu.tsx @@ -5,16 +5,13 @@ import { TldrawUiMenuSubmenu, TLGeoShape, TLShape, + useDefaultHelpers, } from "tldraw" import { TldrawUiMenuGroup } from "tldraw" import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw" import { TLUiContextMenuProps, useEditor } from "tldraw" import { cameraHistory, - copyLinkToCurrentView, - lockCameraToFrame, - revertCamera, - zoomToSelection, } from "./cameraUtils" import { useState, useEffect } from "react" import { saveToPdf } from "../utils/pdfUtils" @@ -22,6 +19,8 @@ import { TLFrameShape } from "tldraw" import { searchText } from "../utils/searchUtils" import { llm } from "../utils/llmUtils" import { getEdge } from "@/propagators/tlgraph" +import { getCustomActions } from './overrides' +import { overrides } from './overrides' const getAllFrames = (editor: Editor) => { return editor @@ -35,6 +34,9 @@ const getAllFrames = (editor: Editor) => { export function CustomContextMenu(props: TLUiContextMenuProps) { const editor = useEditor() + const helpers = useDefaultHelpers() + const tools = overrides.tools?.(editor, {}, helpers) ?? {} + const customActions = getCustomActions(editor) const [selectedShapes, setSelectedShapes] = useState([]) const [selectedIds, setSelectedIds] = useState([]) @@ -61,170 +63,34 @@ export function CustomContextMenu(props: TLUiContextMenuProps) { const hasSelection = selectedIds.length > 0 const hasCameraHistory = cameraHistory.length > 0 - // Check if exactly one frame is selected - const hasFrameSelected = - selectedShapes.length === 1 && selectedShapes[0].type === "frame" - + //TO DO: Fix camera history for camera revert + return ( {/* Camera Controls Group */} - zoomToSelection(editor)} - /> - copyLinkToCurrentView(editor)} - /> - revertCamera(editor)} - /> - saveToPdf(editor)} - /> - { - const selectedShape = editor.getSelectedShapes()[0]; - if (!selectedShape || 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 as TLGeoShape).props.text - : ""; - - llm( - `Instruction: ${edge.text} - ${sourceText ? `Context: ${sourceText}` : ""}`, - localStorage.getItem("openai_api_key") || "", - (partialResponse: string) => { - editor.updateShape({ - id: edge.to, - type: "geo", - props: { - ...(editor.getShape(edge.to) as TLGeoShape).props, - text: partialResponse - } - }); - } - ) - }} - /> + + + + + + + {/* Creation Tools Group */} - { - editor.setCurrentTool("VideoChat") - }} - /> - { - editor.setCurrentTool("ChatBox") - }} - /> - { - editor.setCurrentTool("Embed") - }} - /> - { - editor.setCurrentTool("Slide") - }} - /> - { - editor.setCurrentTool("MycrozineTemplate") - }} - /> - { - editor.setCurrentTool("Markdown") - }} - /> - { - editor.setCurrentTool("Prompt") - }} - /> + + + + + + + {/* Frame Controls */} - - lockCameraToFrame(editor)} - /> - - {getAllFrames(editor).map((frame) => ( diff --git a/src/ui/cameraUtils.ts b/src/ui/cameraUtils.ts index 6bb7704..c5b2db4 100644 --- a/src/ui/cameraUtils.ts +++ b/src/ui/cameraUtils.ts @@ -3,6 +3,8 @@ 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() + // Helper function to store camera position const storeCameraPosition = (editor: Editor) => { const currentCamera = editor.getCamera() @@ -80,9 +82,9 @@ export const zoomToSelection = (editor: Editor) => { const newCamera = editor.getCamera() const url = new URL(window.location.href) url.searchParams.set("shapeId", selectedIds[0].toString()) - url.searchParams.set("x", newCamera.x.toString()) - url.searchParams.set("y", newCamera.y.toString()) - url.searchParams.set("zoom", newCamera.z.toString()) + url.searchParams.set("x", Math.round(newCamera.x).toString()) + url.searchParams.set("y", Math.round(newCamera.y).toString()) + url.searchParams.set("zoom", Math.round(newCamera.z).toString()) window.history.replaceState(null, "", url.toString()) } @@ -127,22 +129,14 @@ export const copyLinkToCurrentView = async (editor: Editor) => { try { const baseUrl = `${window.location.origin}${window.location.pathname}` - console.log("Base URL:", baseUrl) - const url = new URL(baseUrl) const camera = editor.getCamera() - console.log("Current camera position:", { - x: camera.x, - y: camera.y, - zoom: camera.z, - }) - // Set camera parameters first - url.searchParams.set("x", camera.x.toString()) - url.searchParams.set("y", camera.y.toString()) - url.searchParams.set("zoom", camera.z.toString()) + // Round camera values to integers + url.searchParams.set("x", Math.round(camera.x).toString()) + url.searchParams.set("y", Math.round(camera.y).toString()) + url.searchParams.set("zoom", Math.round(camera.z).toString()) - // Add shape ID last if needed const selectedIds = editor.getSelectedShapeIds() if (selectedIds.length > 0) { url.searchParams.set("shapeId", selectedIds[0].toString()) @@ -152,11 +146,8 @@ export const copyLinkToCurrentView = async (editor: Editor) => { console.log("Final URL to copy:", finalUrl) if (navigator.clipboard && window.isSecureContext) { - console.log("Using modern clipboard API...") await navigator.clipboard.writeText(finalUrl) - console.log("URL copied successfully using clipboard API") } else { - console.log("Falling back to legacy clipboard method...") const textArea = document.createElement("textarea") textArea.value = finalUrl document.body.appendChild(textArea) @@ -173,56 +164,130 @@ export const copyLinkToCurrentView = async (editor: Editor) => { } } -/** TODO: doesnt UNlock */ -export const lockCameraToFrame = async (editor: Editor) => { +// 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 - const selectedShape = selectedShapes[0] - const isFrame = selectedShape.type === "frame" - const bounds = editor.getShapePageBounds(selectedShape) - if (!isFrame || !bounds) return try { - const baseUrl = `${window.location.origin}${window.location.pathname}` - const url = new URL(baseUrl) - - // Calculate zoom level to fit the frame (for URL only) - const viewportPageBounds = editor.getViewportPageBounds() - const targetZoom = Math.min( - viewportPageBounds.width / bounds.width, - viewportPageBounds.height / bounds.height, - 1, // Cap at 1x zoom - ) - - // Set URL parameters without affecting the current view - url.searchParams.set("x", Math.round(bounds.x).toString()) - url.searchParams.set("y", Math.round(bounds.y).toString()) - url.searchParams.set( - "zoom", - (Math.round(targetZoom * 100) / 100).toString(), - ) - url.searchParams.set("frameId", selectedShape.id) - url.searchParams.set("isLocked", "true") - - const finalUrl = url.toString() - - // Copy URL to clipboard without modifying the current view - 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) - } + 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 copy frame link:", error) - alert("Failed to copy frame link. Please check clipboard permissions.") + 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") @@ -230,7 +295,6 @@ export const setInitialCameraFromUrl = (editor: Editor) => { const zoom = url.searchParams.get("zoom") const shapeId = url.searchParams.get("shapeId") const frameId = url.searchParams.get("frameId") - //const isLocked = url.searchParams.get("isLocked") === "true" console.log('Setting initial camera from URL:', { x, y, zoom, shapeId, frameId }) @@ -238,9 +302,9 @@ export const setInitialCameraFromUrl = (editor: Editor) => { editor.stopCameraAnimation() editor.setCamera( { - x: parseFloat(x), - y: parseFloat(y), - z: parseFloat(zoom) + x: Math.round(parseFloat(x)), + y: Math.round(parseFloat(y)), + z: Math.round(parseFloat(zoom)) }, { animation: { duration: 0 } } ) @@ -266,10 +330,6 @@ export const setInitialCameraFromUrl = (editor: Editor) => { } } } - - // if (isLocked) { - // editor.setCameraOptions({ isLocked: true }) - // } } export const zoomToFrame = (editor: Editor, frameId: string) => { @@ -288,3 +348,17 @@ export const copyFrameLink = (_editor: Editor, frameId: string) => { url.searchParams.set("frameId", frameId) navigator.clipboard.writeText(url.toString()) } + +// 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() + } + }) + }) +} diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 443879d..951e384 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -1,3 +1,4 @@ +import { Editor, useDefaultHelpers } from "tldraw" import { shapeIdValidator, TLArrowShape, @@ -7,8 +8,9 @@ import { import { cameraHistory, copyLinkToCurrentView, - lockCameraToFrame, + lockElement, revertCamera, + unlockElement, zoomToSelection, } from "./cameraUtils" import { saveToPdf } from "../utils/pdfUtils" @@ -158,8 +160,7 @@ export const overrides: TLUiOverrides = { } }, actions(editor, actions) { - return { - ...actions, + const customActions = { "zoom-in": { ...actions["zoom-in"], kbd: "ctrl+up", @@ -183,9 +184,7 @@ export const overrides: TLUiOverrides = { id: "copy-link-to-current-view", label: "Copy Link to Current View", kbd: "alt+c", - onSelect: () => { - copyLinkToCurrentView(editor) - }, + onSelect: () => copyLinkToCurrentView(editor), readonlyOk: true, }, revertCamera: { @@ -199,11 +198,26 @@ export const overrides: TLUiOverrides = { }, readonlyOk: true, }, - lockToFrame: { - id: "lock-to-frame", - label: "Lock to Frame", + lockElement: { + id: "lock-element", + label: "Lock Element", kbd: "shift+l", - onSelect: () => lockCameraToFrame(editor), + 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", @@ -288,35 +302,6 @@ export const overrides: TLUiOverrides = { } }, }, - //TODO: MAKE THIS WORK, ADD USER PERMISSIONING TO JOIN BROADCAST? - startBroadcast: { - id: "start-broadcast", - label: "Start Broadcasting", - kbd: "alt+b", - readonlyOk: true, - onSelect: () => { - editor.markHistoryStoppingPoint('start-broadcast') - editor.updateInstanceState({ isBroadcasting: true }) - const url = new URL(window.location.href) - url.searchParams.set("followId", editor.user.getId()) - window.history.replaceState(null, "", url.toString()) - }, - }, - stopBroadcast: { - id: "stop-broadcast", - label: "Stop Broadcasting", - kbd: "alt+shift+b", - readonlyOk: true, - onSelect: () => { - editor.updateInstanceState({ isBroadcasting: false }) - editor.stopFollowingUser() - - // Remove followId from URL - const url = new URL(window.location.href) - url.searchParams.delete("followId") - window.history.replaceState(null, "", url.toString()) - }, - }, searchShapes: { id: "search-shapes", label: "Search Shapes", @@ -326,7 +311,7 @@ export const overrides: TLUiOverrides = { }, llm: { id: "llm", - label: "LLM", + label: "Run LLM Prompt", kbd: "g", readonlyOk: true, onSelect: () => { @@ -424,5 +409,16 @@ export const overrides: TLUiOverrides = { }, }, } + + return { + ...actions, + ...customActions, + } }, } + +// Export actions for use in context menu +export const getCustomActions = (editor: Editor) => { + const helpers = useDefaultHelpers() + return overrides.actions?.(editor, {}, helpers) ?? {} +}