lock & unlock shapes, clean up overrides & context menu, make embed element easier to interact with

This commit is contained in:
Jeff-Emmett 2025-03-15 01:03:55 -07:00
parent a770d516df
commit 0add9bd514
6 changed files with 281 additions and 274 deletions

View File

@ -377,3 +377,23 @@ p:has(+ ol) {
overflow: hidden; overflow: hidden;
background-color: white; 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;
}

View File

@ -1,6 +1,6 @@
import { useSync } from "@tldraw/sync" import { useSync } from "@tldraw/sync"
import { useMemo, useEffect, useState } from "react" import { useMemo, useEffect, useState } from "react"
import { Tldraw, Editor } from "tldraw" import { Tldraw, Editor, TLShapeId } from "tldraw"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { ChatBoxTool } from "@/tools/ChatBoxTool" import { ChatBoxTool } from "@/tools/ChatBoxTool"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil" import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
@ -30,7 +30,13 @@ import { makeRealSettings, applySettingsMigrations } from "@/lib/settings"
import { PromptShapeTool } from "@/tools/PromptShapeTool" import { PromptShapeTool } from "@/tools/PromptShapeTool"
import { PromptShape } from "@/shapes/PromptShapeUtil" import { PromptShape } from "@/shapes/PromptShapeUtil"
import { llm } from "@/utils/llmUtils" 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 // Default to production URL if env var isn't available
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" 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 ( return (
<div style={{ position: "fixed", inset: 0 }}> <div style={{ position: "fixed", inset: 0 }}>
<Tldraw <Tldraw
@ -91,7 +104,16 @@ export function Board() {
shapeUtils={customShapeUtils} shapeUtils={customShapeUtils}
tools={customTools} tools={customTools}
components={components} components={components}
overrides={overrides} overrides={{
...overrides,
actions: (editor, actions, helpers) => {
const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
return {
...actions,
...customActions,
}
}
}}
cameraOptions={{ cameraOptions={{
zoomSteps: [ zoomSteps: [
0.001, // Min zoom 0.001, // Min zoom

View File

@ -2,6 +2,9 @@ import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
//import Embed from "react-embed" //import Embed from "react-embed"
//TODO: FIX PEN AND MOBILE INTERACTION WITH EDITING EMBED URL - DEFAULT TO TEXT SELECTED
export type IEmbedShape = TLBaseShape< export type IEmbedShape = TLBaseShape<
"Embed", "Embed",
{ {
@ -372,27 +375,53 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
return ( return (
<div style={wrapperStyle}> <div style={wrapperStyle}>
{controls("")} {controls("")}
<div style={contentStyle}> <div
style={{
...contentStyle,
cursor: 'text', // Add text cursor to indicate clickable
touchAction: 'none', // Prevent touch scrolling
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const input = e.currentTarget.querySelector('input')
input?.focus()
}}
>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
style={{ width: "100%", height: "100%", padding: "10px" }} style={{
width: "100%",
height: "100%",
padding: "10px",
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
onClick={(e) => e.stopPropagation()}
> >
<input <input
type="text" type="text"
value={inputUrl} value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)} onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL" placeholder="Enter URL to embed"
style={{ style={{
width: "100%", width: "100%",
height: "100%", padding: "15px", // Increased padding for better touch target
border: "none", border: "1px solid #ccc",
padding: "10px", borderRadius: "4px",
fontSize: "16px", // Increased font size for better visibility
touchAction: 'none',
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
handleSubmit(e) handleSubmit(e)
} }
}} }}
onPointerDown={(e) => {
e.stopPropagation()
e.currentTarget.focus()
}}
/> />
{error && ( {error && (
<div style={{ color: "red", marginTop: "10px" }}>{error}</div> <div style={{ color: "red", marginTop: "10px" }}>{error}</div>
@ -555,7 +584,7 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
} }
} }
// Add new method to handle all pointer interactions // Update the pointer down handler
onPointerDown = (shape: IEmbedShape) => { onPointerDown = (shape: IEmbedShape) => {
if (!shape.props.url) { if (!shape.props.url) {
const input = document.querySelector('input') const input = document.querySelector('input')

View File

@ -5,16 +5,13 @@ import {
TldrawUiMenuSubmenu, TldrawUiMenuSubmenu,
TLGeoShape, TLGeoShape,
TLShape, TLShape,
useDefaultHelpers,
} from "tldraw" } from "tldraw"
import { TldrawUiMenuGroup } from "tldraw" import { TldrawUiMenuGroup } from "tldraw"
import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw" import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw"
import { TLUiContextMenuProps, useEditor } from "tldraw" import { TLUiContextMenuProps, useEditor } from "tldraw"
import { import {
cameraHistory, cameraHistory,
copyLinkToCurrentView,
lockCameraToFrame,
revertCamera,
zoomToSelection,
} from "./cameraUtils" } from "./cameraUtils"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { saveToPdf } from "../utils/pdfUtils" import { saveToPdf } from "../utils/pdfUtils"
@ -22,6 +19,8 @@ import { TLFrameShape } from "tldraw"
import { searchText } from "../utils/searchUtils" import { searchText } from "../utils/searchUtils"
import { llm } from "../utils/llmUtils" import { llm } from "../utils/llmUtils"
import { getEdge } from "@/propagators/tlgraph" import { getEdge } from "@/propagators/tlgraph"
import { getCustomActions } from './overrides'
import { overrides } from './overrides'
const getAllFrames = (editor: Editor) => { const getAllFrames = (editor: Editor) => {
return editor return editor
@ -35,6 +34,9 @@ const getAllFrames = (editor: Editor) => {
export function CustomContextMenu(props: TLUiContextMenuProps) { export function CustomContextMenu(props: TLUiContextMenuProps) {
const editor = useEditor() const editor = useEditor()
const helpers = useDefaultHelpers()
const tools = overrides.tools?.(editor, {}, helpers) ?? {}
const customActions = getCustomActions(editor)
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([]) const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIds, setSelectedIds] = useState<string[]>([])
@ -61,170 +63,34 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
const hasSelection = selectedIds.length > 0 const hasSelection = selectedIds.length > 0
const hasCameraHistory = cameraHistory.length > 0 const hasCameraHistory = cameraHistory.length > 0
// Check if exactly one frame is selected //TO DO: Fix camera history for camera revert
const hasFrameSelected =
selectedShapes.length === 1 && selectedShapes[0].type === "frame"
return ( return (
<DefaultContextMenu {...props}> <DefaultContextMenu {...props}>
<DefaultContextMenuContent /> <DefaultContextMenuContent />
{/* Camera Controls Group */} {/* Camera Controls Group */}
<TldrawUiMenuGroup id="camera-controls"> <TldrawUiMenuGroup id="camera-controls">
<TldrawUiMenuItem <TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
id="zoom-to-selection" <TldrawUiMenuItem {...customActions.copyLinkToCurrentView} />
label="Zoom to Selection" <TldrawUiMenuItem {...customActions.revertCamera} disabled={!hasCameraHistory} />
icon="zoom-in" <TldrawUiMenuItem {...customActions.lockElement} disabled={!hasSelection} />
kbd="z" <TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
disabled={!hasSelection} <TldrawUiMenuItem {...customActions.saveToPdf} disabled={!hasSelection} />
onSelect={() => zoomToSelection(editor)} <TldrawUiMenuItem {...customActions.llm} disabled={!hasSelection} />
/>
<TldrawUiMenuItem
id="copy-link-to-current-view"
label="Copy Link to Current View"
icon="link"
kbd="alt+c"
onSelect={() => copyLinkToCurrentView(editor)}
/>
<TldrawUiMenuItem
id="revert-camera"
label="Revert Camera"
icon="undo"
kbd="alt+b"
disabled={!hasCameraHistory}
onSelect={() => revertCamera(editor)}
/>
<TldrawUiMenuItem
id="save-to-pdf"
label="Save Selection as PDF"
icon="file"
kbd="alt+s"
disabled={!hasSelection}
onSelect={() => saveToPdf(editor)}
/>
<TldrawUiMenuItem
id="run-llm-prompt"
label="Run LLM Prompt"
icon="file"
kbd="g"
disabled={!hasSelection}
onSelect={() => {
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
}
});
}
)
}}
/>
</TldrawUiMenuGroup> </TldrawUiMenuGroup>
{/* Creation Tools Group */} {/* Creation Tools Group */}
<TldrawUiMenuGroup id="creation-tools"> <TldrawUiMenuGroup id="creation-tools">
<TldrawUiMenuItem <TldrawUiMenuItem {...tools.VideoChat} disabled={hasSelection} />
id="VideoChat" <TldrawUiMenuItem {...tools.ChatBox} disabled={hasSelection} />
label="Create Video Chat" <TldrawUiMenuItem {...tools.Embed} disabled={hasSelection} />
icon="video" <TldrawUiMenuItem {...tools.SlideShape} disabled={hasSelection} />
kbd="alt+v" <TldrawUiMenuItem {...tools.Markdown} disabled={hasSelection} />
disabled={hasSelection} <TldrawUiMenuItem {...tools.MycrozineTemplate} disabled={hasSelection} />
onSelect={() => { <TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
editor.setCurrentTool("VideoChat")
}}
/>
<TldrawUiMenuItem
id="ChatBox"
label="Create Chat Box"
icon="chat"
kbd="alt+c"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("ChatBox")
}}
/>
<TldrawUiMenuItem
id="Embed"
label="Create Embed"
icon="embed"
kbd="alt+e"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Embed")
}}
/>
<TldrawUiMenuItem
id="Slide"
label="Create Slide"
icon="slides"
kbd="alt+s"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Slide")
}}
/>
<TldrawUiMenuItem
id="MycrozineTemplate"
label="Create Mycrozine Template"
icon="rectangle"
kbd="alt+z"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("MycrozineTemplate")
}}
/>
<TldrawUiMenuItem
id="Markdown"
label="Create Markdown"
icon="markdown"
kbd="alt+m"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Markdown")
}}
/>
<TldrawUiMenuItem
id="Prompt"
label="Create LLM Chat Prompt"
icon="prompt"
kbd="alt+p"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Prompt")
}}
/>
</TldrawUiMenuGroup> </TldrawUiMenuGroup>
{/* Frame Controls */} {/* Frame Controls */}
<TldrawUiMenuGroup id="frame-controls">
<TldrawUiMenuItem
id="lock-to-frame"
label="Lock to Frame"
icon="lock"
kbd="shift+l"
disabled={!hasFrameSelected}
onSelect={() => lockCameraToFrame(editor)}
/>
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="frames-list"> <TldrawUiMenuGroup id="frames-list">
<TldrawUiMenuSubmenu id="frames-dropdown" label="Shortcut to Frames"> <TldrawUiMenuSubmenu id="frames-dropdown" label="Shortcut to Frames">
{getAllFrames(editor).map((frame) => ( {getAllFrames(editor).map((frame) => (

View File

@ -3,6 +3,8 @@ import { Editor, TLFrameShape, TLParentId, TLShape, TLShapeId } from "tldraw"
export const cameraHistory: { x: number; y: number; z: number }[] = [] export const cameraHistory: { x: number; y: number; z: number }[] = []
const MAX_HISTORY = 10 // Keep last 10 camera positions const MAX_HISTORY = 10 // Keep last 10 camera positions
const frameObservers = new Map<string, ResizeObserver>()
// Helper function to store camera position // Helper function to store camera position
const storeCameraPosition = (editor: Editor) => { const storeCameraPosition = (editor: Editor) => {
const currentCamera = editor.getCamera() const currentCamera = editor.getCamera()
@ -80,9 +82,9 @@ export const zoomToSelection = (editor: Editor) => {
const newCamera = editor.getCamera() const newCamera = editor.getCamera()
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.set("shapeId", selectedIds[0].toString()) url.searchParams.set("shapeId", selectedIds[0].toString())
url.searchParams.set("x", newCamera.x.toString()) url.searchParams.set("x", Math.round(newCamera.x).toString())
url.searchParams.set("y", newCamera.y.toString()) url.searchParams.set("y", Math.round(newCamera.y).toString())
url.searchParams.set("zoom", newCamera.z.toString()) url.searchParams.set("zoom", Math.round(newCamera.z).toString())
window.history.replaceState(null, "", url.toString()) window.history.replaceState(null, "", url.toString())
} }
@ -127,22 +129,14 @@ export const copyLinkToCurrentView = async (editor: Editor) => {
try { try {
const baseUrl = `${window.location.origin}${window.location.pathname}` const baseUrl = `${window.location.origin}${window.location.pathname}`
console.log("Base URL:", baseUrl)
const url = new URL(baseUrl) const url = new URL(baseUrl)
const camera = editor.getCamera() const camera = editor.getCamera()
console.log("Current camera position:", {
x: camera.x,
y: camera.y,
zoom: camera.z,
})
// Set camera parameters first // Round camera values to integers
url.searchParams.set("x", camera.x.toString()) url.searchParams.set("x", Math.round(camera.x).toString())
url.searchParams.set("y", camera.y.toString()) url.searchParams.set("y", Math.round(camera.y).toString())
url.searchParams.set("zoom", camera.z.toString()) url.searchParams.set("zoom", Math.round(camera.z).toString())
// Add shape ID last if needed
const selectedIds = editor.getSelectedShapeIds() const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length > 0) { if (selectedIds.length > 0) {
url.searchParams.set("shapeId", selectedIds[0].toString()) url.searchParams.set("shapeId", selectedIds[0].toString())
@ -152,11 +146,8 @@ export const copyLinkToCurrentView = async (editor: Editor) => {
console.log("Final URL to copy:", finalUrl) console.log("Final URL to copy:", finalUrl)
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
console.log("Using modern clipboard API...")
await navigator.clipboard.writeText(finalUrl) await navigator.clipboard.writeText(finalUrl)
console.log("URL copied successfully using clipboard API")
} else { } else {
console.log("Falling back to legacy clipboard method...")
const textArea = document.createElement("textarea") const textArea = document.createElement("textarea")
textArea.value = finalUrl textArea.value = finalUrl
document.body.appendChild(textArea) document.body.appendChild(textArea)
@ -173,56 +164,130 @@ export const copyLinkToCurrentView = async (editor: Editor) => {
} }
} }
/** TODO: doesnt UNlock */ // Add this function to create lock indicators
export const lockCameraToFrame = async (editor: Editor) => { 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() const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length === 0) return if (selectedShapes.length === 0) return
const selectedShape = selectedShapes[0]
const isFrame = selectedShape.type === "frame"
const bounds = editor.getShapePageBounds(selectedShape)
if (!isFrame || !bounds) return
try { try {
const baseUrl = `${window.location.origin}${window.location.pathname}` selectedShapes.forEach(shape => {
const url = new URL(baseUrl) editor.updateShape({
id: shape.id,
// Calculate zoom level to fit the frame (for URL only) type: shape.type,
const viewportPageBounds = editor.getViewportPageBounds() isLocked: true,
const targetZoom = Math.min( meta: {
viewportPageBounds.width / bounds.width, ...shape.meta,
viewportPageBounds.height / bounds.height, isLocked: true,
1, // Cap at 1x zoom canInteract: true, // Allow interactions
) canMove: false, // Prevent moving
canResize: false, // Prevent resizing
// Set URL parameters without affecting the current view canEdit: true, // Allow text editing
url.searchParams.set("x", Math.round(bounds.x).toString()) canUpdateProps: true // Allow updating props (for prompt inputs/outputs)
url.searchParams.set("y", Math.round(bounds.y).toString()) //TO DO: FIX TEXT INPUT ON LOCKED ELEMENTS (e.g. prompt shape) AND ATTACH TO SCREEN EDGE
url.searchParams.set( }
"zoom", })
(Math.round(targetZoom * 100) / 100).toString(), createLockIndicator(editor, shape)
) })
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)
}
} catch (error) { } catch (error) {
console.error("Failed to copy frame link:", error) console.error("Failed to lock elements:", error)
alert("Failed to copy frame link. Please check clipboard permissions.")
} }
} }
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) => { export const setInitialCameraFromUrl = (editor: Editor) => {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const x = url.searchParams.get("x") const x = url.searchParams.get("x")
@ -230,7 +295,6 @@ export const setInitialCameraFromUrl = (editor: Editor) => {
const zoom = url.searchParams.get("zoom") const zoom = url.searchParams.get("zoom")
const shapeId = url.searchParams.get("shapeId") const shapeId = url.searchParams.get("shapeId")
const frameId = url.searchParams.get("frameId") 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 }) console.log('Setting initial camera from URL:', { x, y, zoom, shapeId, frameId })
@ -238,9 +302,9 @@ export const setInitialCameraFromUrl = (editor: Editor) => {
editor.stopCameraAnimation() editor.stopCameraAnimation()
editor.setCamera( editor.setCamera(
{ {
x: parseFloat(x), x: Math.round(parseFloat(x)),
y: parseFloat(y), y: Math.round(parseFloat(y)),
z: parseFloat(zoom) z: Math.round(parseFloat(zoom))
}, },
{ animation: { duration: 0 } } { 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) => { export const zoomToFrame = (editor: Editor, frameId: string) => {
@ -288,3 +348,17 @@ export const copyFrameLink = (_editor: Editor, frameId: string) => {
url.searchParams.set("frameId", frameId) url.searchParams.set("frameId", frameId)
navigator.clipboard.writeText(url.toString()) 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()
}
})
})
}

View File

@ -1,3 +1,4 @@
import { Editor, useDefaultHelpers } from "tldraw"
import { import {
shapeIdValidator, shapeIdValidator,
TLArrowShape, TLArrowShape,
@ -7,8 +8,9 @@ import {
import { import {
cameraHistory, cameraHistory,
copyLinkToCurrentView, copyLinkToCurrentView,
lockCameraToFrame, lockElement,
revertCamera, revertCamera,
unlockElement,
zoomToSelection, zoomToSelection,
} from "./cameraUtils" } from "./cameraUtils"
import { saveToPdf } from "../utils/pdfUtils" import { saveToPdf } from "../utils/pdfUtils"
@ -158,8 +160,7 @@ export const overrides: TLUiOverrides = {
} }
}, },
actions(editor, actions) { actions(editor, actions) {
return { const customActions = {
...actions,
"zoom-in": { "zoom-in": {
...actions["zoom-in"], ...actions["zoom-in"],
kbd: "ctrl+up", kbd: "ctrl+up",
@ -183,9 +184,7 @@ export const overrides: TLUiOverrides = {
id: "copy-link-to-current-view", id: "copy-link-to-current-view",
label: "Copy Link to Current View", label: "Copy Link to Current View",
kbd: "alt+c", kbd: "alt+c",
onSelect: () => { onSelect: () => copyLinkToCurrentView(editor),
copyLinkToCurrentView(editor)
},
readonlyOk: true, readonlyOk: true,
}, },
revertCamera: { revertCamera: {
@ -199,11 +198,26 @@ export const overrides: TLUiOverrides = {
}, },
readonlyOk: true, readonlyOk: true,
}, },
lockToFrame: { lockElement: {
id: "lock-to-frame", id: "lock-element",
label: "Lock to Frame", label: "Lock Element",
kbd: "shift+l", 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: { saveToPdf: {
id: "save-to-pdf", 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: { searchShapes: {
id: "search-shapes", id: "search-shapes",
label: "Search Shapes", label: "Search Shapes",
@ -326,7 +311,7 @@ export const overrides: TLUiOverrides = {
}, },
llm: { llm: {
id: "llm", id: "llm",
label: "LLM", label: "Run LLM Prompt",
kbd: "g", kbd: "g",
readonlyOk: true, readonlyOk: true,
onSelect: () => { 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) ?? {}
}