cleanup
This commit is contained in:
parent
bfe6b238e9
commit
95307ed453
|
|
@ -7,7 +7,8 @@ import { Contact } from "@/routes/Contact"
|
||||||
import { Board } from "./routes/Board"
|
import { Board } from "./routes/Board"
|
||||||
import { Inbox } from "./routes/Inbox"
|
import { Inbox } from "./routes/Inbox"
|
||||||
import { Editor, Tldraw, TLShapeId } from "tldraw"
|
import { Editor, Tldraw, TLShapeId } from "tldraw"
|
||||||
import { components, overrides } from "./ui-overrides"
|
import { components } from "./ui/components"
|
||||||
|
import { overrides } from "./ui/overrides"
|
||||||
import { ChatBoxShape } from "./shapes/ChatBoxShapeUtil"
|
import { ChatBoxShape } from "./shapes/ChatBoxShapeUtil"
|
||||||
import { VideoChatShape } from "./shapes/VideoChatShapeUtil"
|
import { VideoChatShape } from "./shapes/VideoChatShapeUtil"
|
||||||
import { ChatBoxTool } from "./tools/ChatBoxTool"
|
import { ChatBoxTool } from "./tools/ChatBoxTool"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { useSync } from "@tldraw/sync"
|
import { useSync } from "@tldraw/sync"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import {
|
import { Tldraw, Editor } from "tldraw"
|
||||||
AssetRecordType,
|
|
||||||
getHashForString,
|
|
||||||
TLBookmarkAsset,
|
|
||||||
Tldraw,
|
|
||||||
Editor,
|
|
||||||
} 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"
|
||||||
|
|
@ -17,7 +11,9 @@ import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
||||||
import { EmbedTool } from "@/tools/EmbedTool"
|
import { EmbedTool } from "@/tools/EmbedTool"
|
||||||
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
|
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { components, overrides } from "@/ui-overrides"
|
import { components } from "@/ui/components"
|
||||||
|
import { overrides } from "@/ui/overrides"
|
||||||
|
import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl"
|
||||||
|
|
||||||
// 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"
|
||||||
|
|
@ -59,45 +55,3 @@ export function Board() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// How does our server handle bookmark unfurling?
|
|
||||||
async function unfurlBookmarkUrl({
|
|
||||||
url,
|
|
||||||
}: {
|
|
||||||
url: string
|
|
||||||
}): Promise<TLBookmarkAsset> {
|
|
||||||
const asset: TLBookmarkAsset = {
|
|
||||||
id: AssetRecordType.createId(getHashForString(url)),
|
|
||||||
typeName: "asset",
|
|
||||||
type: "bookmark",
|
|
||||||
meta: {},
|
|
||||||
props: {
|
|
||||||
src: url,
|
|
||||||
description: "",
|
|
||||||
image: "",
|
|
||||||
favicon: "",
|
|
||||||
title: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`,
|
|
||||||
)
|
|
||||||
const data = (await response.json()) as {
|
|
||||||
description: string
|
|
||||||
image: string
|
|
||||||
favicon: string
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
asset.props.description = data?.description ?? ""
|
|
||||||
asset.props.image = data?.image ?? ""
|
|
||||||
asset.props.favicon = data?.favicon ?? ""
|
|
||||||
asset.props.title = data?.title ?? ""
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return asset
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
import {
|
|
||||||
DefaultToolbar,
|
|
||||||
DefaultToolbarContent,
|
|
||||||
TLComponents,
|
|
||||||
TLUiOverrides,
|
|
||||||
TldrawUiMenuItem,
|
|
||||||
useEditor,
|
|
||||||
useTools,
|
|
||||||
DefaultContextMenu,
|
|
||||||
DefaultContextMenuContent,
|
|
||||||
TLUiContextMenuProps,
|
|
||||||
TldrawUiMenuGroup,
|
|
||||||
} from 'tldraw'
|
|
||||||
import { CustomMainMenu } from './components/CustomMainMenu'
|
|
||||||
import { Editor } from 'tldraw'
|
|
||||||
|
|
||||||
let cameraHistory: { x: number; y: number; z: number }[] = [];
|
|
||||||
const MAX_HISTORY = 10; // Keep last 10 camera positions
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
console.log('Stored camera position:', currentCamera);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
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 8x
|
|
||||||
targetZoom = Math.min(
|
|
||||||
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
|
||||||
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
|
||||||
8 // Max zoom of 8x 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 4x zoom
|
|
||||||
targetZoom = Math.min(
|
|
||||||
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
|
||||||
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
|
||||||
4 // 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.toString());
|
|
||||||
url.searchParams.set('y', newCamera.y.toString());
|
|
||||||
url.searchParams.set('zoom', newCamera.z.toString());
|
|
||||||
window.history.replaceState(null, '', url.toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyLinkToCurrentView = async (editor: Editor) => {
|
|
||||||
console.log('Starting copyLinkToCurrentView');
|
|
||||||
|
|
||||||
if (!editor.store.serialize()) {
|
|
||||||
console.warn('Store not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
url.searchParams.set('x', camera.x.toString());
|
|
||||||
url.searchParams.set('y', camera.y.toString());
|
|
||||||
url.searchParams.set('zoom', camera.z.toString());
|
|
||||||
|
|
||||||
const finalUrl = url.toString();
|
|
||||||
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);
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(textArea.value);
|
|
||||||
console.log('URL copied successfully');
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback for older browsers
|
|
||||||
textArea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
console.log('URL copied using fallback method');
|
|
||||||
}
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy to clipboard:', error);
|
|
||||||
alert('Failed to copy link. Please check clipboard permissions.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 a function that creates the uiOverrides
|
|
||||||
export const overrides: TLUiOverrides = ({
|
|
||||||
tools(editor, tools) {
|
|
||||||
return {
|
|
||||||
...tools,
|
|
||||||
VideoChat: {
|
|
||||||
id: 'VideoChat',
|
|
||||||
icon: 'video',
|
|
||||||
label: 'Video Chat',
|
|
||||||
kbd: 'v',
|
|
||||||
readonlyOk: true,
|
|
||||||
onSelect: () => editor.setCurrentTool('VideoChat'),
|
|
||||||
},
|
|
||||||
ChatBox: {
|
|
||||||
id: 'ChatBox',
|
|
||||||
icon: 'chat',
|
|
||||||
label: 'Chat',
|
|
||||||
kbd: 'c',
|
|
||||||
readonlyOk: true,
|
|
||||||
onSelect: () => editor.setCurrentTool('ChatBox'),
|
|
||||||
},
|
|
||||||
Embed: {
|
|
||||||
id: 'Embed',
|
|
||||||
icon: 'embed',
|
|
||||||
label: 'Embed',
|
|
||||||
kbd: 'e',
|
|
||||||
readonlyOk: true,
|
|
||||||
onSelect: () => editor.setCurrentTool('Embed'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions(editor, actions) {
|
|
||||||
return {
|
|
||||||
...actions,
|
|
||||||
'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: 's',
|
|
||||||
onSelect: () => {
|
|
||||||
copyLinkToCurrentView(editor);
|
|
||||||
},
|
|
||||||
readonlyOk: true,
|
|
||||||
},
|
|
||||||
'revertCamera': {
|
|
||||||
id: 'revert-camera',
|
|
||||||
label: 'Revert Camera',
|
|
||||||
kbd: 'b',
|
|
||||||
onSelect: () => {
|
|
||||||
if (cameraHistory.length > 0) {
|
|
||||||
revertCamera(editor);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
readonlyOk: true,
|
|
||||||
},
|
|
||||||
'lockToFrame': {
|
|
||||||
id: 'lock-to-frame',
|
|
||||||
label: 'Lock to Frame',
|
|
||||||
kbd: 'l',
|
|
||||||
onSelect: () => {
|
|
||||||
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
|
|
||||||
|
|
||||||
editor.zoomToBounds(bounds, {
|
|
||||||
animation: { duration: 300 },
|
|
||||||
targetZoom: 1
|
|
||||||
})
|
|
||||||
editor.updateInstanceState({
|
|
||||||
meta: { ...editor.getInstanceState().meta, lockedFrameId: selectedShape.id }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const components: TLComponents = {
|
|
||||||
Toolbar: function Toolbar() {
|
|
||||||
const editor = useEditor()
|
|
||||||
const tools = useTools()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DefaultToolbar>
|
|
||||||
<DefaultToolbarContent />
|
|
||||||
{tools['VideoChat'] && (
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
{...tools['VideoChat']}
|
|
||||||
icon="video"
|
|
||||||
label="Video Chat"
|
|
||||||
isSelected={tools['VideoChat'].id === editor.getCurrentToolId()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tools['ChatBox'] && (
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
{...tools['ChatBox']}
|
|
||||||
icon="chat"
|
|
||||||
label="Chat"
|
|
||||||
isSelected={tools['ChatBox'].id === editor.getCurrentToolId()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tools['Embed'] && (
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
{...tools['Embed']}
|
|
||||||
icon="embed"
|
|
||||||
label="Embed"
|
|
||||||
isSelected={tools['Embed'].id === editor.getCurrentToolId()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DefaultToolbar>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
MainMenu: CustomMainMenu,
|
|
||||||
ContextMenu: function CustomContextMenu(props: TLUiContextMenuProps) {
|
|
||||||
const editor = useEditor()
|
|
||||||
const hasSelection = editor.getSelectedShapeIds().length > 0
|
|
||||||
const hasCameraHistory = cameraHistory.length > 0
|
|
||||||
const selectedShape = editor.getSelectedShapes()[0]
|
|
||||||
const isFrame = selectedShape?.type === 'frame'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DefaultContextMenu {...props}>
|
|
||||||
<DefaultContextMenuContent />
|
|
||||||
|
|
||||||
{/* Camera Controls Group */}
|
|
||||||
<TldrawUiMenuGroup id="camera-controls">
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
id="zoom-to-selection"
|
|
||||||
label="Zoom to Selection"
|
|
||||||
icon="zoom-in"
|
|
||||||
kbd="z"
|
|
||||||
disabled={!hasSelection}
|
|
||||||
onSelect={() => zoomToSelection(editor)}
|
|
||||||
/>
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
id="copy-link-to-current-view"
|
|
||||||
label="Copy Link to Current View"
|
|
||||||
icon="link"
|
|
||||||
kbd="s"
|
|
||||||
onSelect={() => copyLinkToCurrentView(editor)}
|
|
||||||
/>
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
id="revert-camera"
|
|
||||||
label="Revert Camera"
|
|
||||||
icon="undo"
|
|
||||||
kbd="b"
|
|
||||||
disabled={!hasCameraHistory}
|
|
||||||
onSelect={() => revertCamera(editor)}
|
|
||||||
/>
|
|
||||||
</TldrawUiMenuGroup>
|
|
||||||
|
|
||||||
{/* Creation Tools Group */}
|
|
||||||
<TldrawUiMenuGroup id="creation-tools">
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
id="video-chat"
|
|
||||||
label="Create Video Chat"
|
|
||||||
icon="video"
|
|
||||||
kbd="v"
|
|
||||||
onSelect={() => { editor.setCurrentTool('VideoChat'); }}
|
|
||||||
/>
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
id="chat-box"
|
|
||||||
label="Create Chat Box"
|
|
||||||
icon="chat"
|
|
||||||
kbd="c"
|
|
||||||
onSelect={() => { editor.setCurrentTool('ChatBox'); }}
|
|
||||||
/>
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
id="embed"
|
|
||||||
label="Create Embed"
|
|
||||||
icon="embed"
|
|
||||||
kbd="e"
|
|
||||||
onSelect={() => { editor.setCurrentTool('Embed'); }}
|
|
||||||
/>
|
|
||||||
</TldrawUiMenuGroup>
|
|
||||||
|
|
||||||
{/* Frame Controls */}
|
|
||||||
{isFrame && (
|
|
||||||
<TldrawUiMenuGroup id="frame-controls">
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
id="lock-to-frame"
|
|
||||||
label="Lock to Frame"
|
|
||||||
icon="lock"
|
|
||||||
kbd="l"
|
|
||||||
onSelect={() => {
|
|
||||||
console.warn('lock to frame NOT IMPLEMENTED')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TldrawUiMenuGroup>
|
|
||||||
)}
|
|
||||||
</DefaultContextMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { TldrawUiMenuItem } from "tldraw"
|
||||||
|
|
||||||
|
import { TldrawUiMenuGroup } from "tldraw"
|
||||||
|
|
||||||
|
import { DefaultContextMenuContent } from "tldraw"
|
||||||
|
|
||||||
|
import { DefaultContextMenu } from "tldraw"
|
||||||
|
|
||||||
|
import { TLUiContextMenuProps, useEditor } from "tldraw"
|
||||||
|
import {
|
||||||
|
cameraHistory,
|
||||||
|
copyLinkToCurrentView,
|
||||||
|
revertCamera,
|
||||||
|
zoomToSelection,
|
||||||
|
} from "./cameraUtils"
|
||||||
|
|
||||||
|
export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
|
const editor = useEditor()
|
||||||
|
const hasSelection = editor.getSelectedShapeIds().length > 0
|
||||||
|
const hasCameraHistory = cameraHistory.length > 0
|
||||||
|
const selectedShape = editor.getSelectedShapes()[0]
|
||||||
|
const isFrame = selectedShape?.type === "frame"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultContextMenu {...props}>
|
||||||
|
<DefaultContextMenuContent />
|
||||||
|
|
||||||
|
{/* Camera Controls Group */}
|
||||||
|
<TldrawUiMenuGroup id="camera-controls">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="zoom-to-selection"
|
||||||
|
label="Zoom to Selection"
|
||||||
|
icon="zoom-in"
|
||||||
|
kbd="z"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onSelect={() => zoomToSelection(editor)}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="copy-link-to-current-view"
|
||||||
|
label="Copy Link to Current View"
|
||||||
|
icon="link"
|
||||||
|
kbd="s"
|
||||||
|
onSelect={() => copyLinkToCurrentView(editor)}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="revert-camera"
|
||||||
|
label="Revert Camera"
|
||||||
|
icon="undo"
|
||||||
|
kbd="b"
|
||||||
|
disabled={!hasCameraHistory}
|
||||||
|
onSelect={() => revertCamera(editor)}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
|
{/* Creation Tools Group */}
|
||||||
|
<TldrawUiMenuGroup id="creation-tools">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="video-chat"
|
||||||
|
label="Create Video Chat"
|
||||||
|
icon="video"
|
||||||
|
kbd="v"
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("VideoChat")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="chat-box"
|
||||||
|
label="Create Chat Box"
|
||||||
|
icon="chat"
|
||||||
|
kbd="c"
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("ChatBox")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="embed"
|
||||||
|
label="Create Embed"
|
||||||
|
icon="embed"
|
||||||
|
kbd="e"
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("Embed")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
|
{/* Frame Controls */}
|
||||||
|
{isFrame && (
|
||||||
|
<TldrawUiMenuGroup id="frame-controls">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="lock-to-frame"
|
||||||
|
label="Lock to Frame"
|
||||||
|
icon="lock"
|
||||||
|
kbd="l"
|
||||||
|
onSelect={() => {
|
||||||
|
console.warn("lock to frame NOT IMPLEMENTED")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
)}
|
||||||
|
</DefaultContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { TldrawUiMenuItem } from "tldraw"
|
||||||
|
import { DefaultToolbar, DefaultToolbarContent } from "tldraw"
|
||||||
|
import { useTools } from "tldraw"
|
||||||
|
import { useEditor } from "tldraw"
|
||||||
|
|
||||||
|
export function CustomToolbar() {
|
||||||
|
const editor = useEditor()
|
||||||
|
const tools = useTools()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultToolbar>
|
||||||
|
<DefaultToolbarContent />
|
||||||
|
{tools["VideoChat"] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools["VideoChat"]}
|
||||||
|
icon="video"
|
||||||
|
label="Video Chat"
|
||||||
|
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tools["ChatBox"] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools["ChatBox"]}
|
||||||
|
icon="chat"
|
||||||
|
label="Chat"
|
||||||
|
isSelected={tools["ChatBox"].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tools["Embed"] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools["Embed"]}
|
||||||
|
icon="embed"
|
||||||
|
label="Embed"
|
||||||
|
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DefaultToolbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { Editor } from "tldraw"
|
||||||
|
|
||||||
|
export const cameraHistory: { x: number; y: number; z: number }[] = []
|
||||||
|
const MAX_HISTORY = 10 // Keep last 10 camera positions
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
console.log("Stored camera position:", currentCamera)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 8x
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
8, // Max zoom of 8x 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 4x zoom
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
4, // 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.toString())
|
||||||
|
url.searchParams.set("y", newCamera.y.toString())
|
||||||
|
url.searchParams.set("zoom", newCamera.z.toString())
|
||||||
|
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) => {
|
||||||
|
console.log("Starting copyLinkToCurrentView")
|
||||||
|
|
||||||
|
if (!editor.store.serialize()) {
|
||||||
|
console.warn("Store not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
url.searchParams.set("x", camera.x.toString())
|
||||||
|
url.searchParams.set("y", camera.y.toString())
|
||||||
|
url.searchParams.set("zoom", camera.z.toString())
|
||||||
|
|
||||||
|
const finalUrl = url.toString()
|
||||||
|
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)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textArea.value)
|
||||||
|
console.log("URL copied successfully")
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback for older browsers
|
||||||
|
textArea.select()
|
||||||
|
document.execCommand("copy")
|
||||||
|
console.log("URL copied using fallback method")
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to copy to clipboard:", error)
|
||||||
|
alert("Failed to copy link. Please check clipboard permissions.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: doesnt lock permanently
|
||||||
|
export const lockCameraToFrame = (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
|
||||||
|
|
||||||
|
editor.zoomToBounds(bounds, {
|
||||||
|
animation: { duration: 300 },
|
||||||
|
targetZoom: 1,
|
||||||
|
})
|
||||||
|
editor.updateInstanceState({
|
||||||
|
meta: {
|
||||||
|
...editor.getInstanceState().meta,
|
||||||
|
lockedFrameId: selectedShape.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { CustomMainMenu } from "./CustomMainMenu"
|
||||||
|
import { CustomToolbar } from "./CustomToolbar"
|
||||||
|
import { CustomContextMenu } from "./CustomContextMenu"
|
||||||
|
import { TLComponents } from "tldraw"
|
||||||
|
|
||||||
|
export const components: TLComponents = {
|
||||||
|
Toolbar: CustomToolbar,
|
||||||
|
MainMenu: CustomMainMenu,
|
||||||
|
ContextMenu: CustomContextMenu,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { TLUiOverrides } from "tldraw"
|
||||||
|
import {
|
||||||
|
cameraHistory,
|
||||||
|
copyLinkToCurrentView,
|
||||||
|
lockCameraToFrame,
|
||||||
|
revertCamera,
|
||||||
|
zoomToSelection,
|
||||||
|
} from "./cameraUtils"
|
||||||
|
|
||||||
|
export const overrides: TLUiOverrides = {
|
||||||
|
tools(editor, tools) {
|
||||||
|
return {
|
||||||
|
...tools,
|
||||||
|
VideoChat: {
|
||||||
|
id: "VideoChat",
|
||||||
|
icon: "video",
|
||||||
|
label: "Video Chat",
|
||||||
|
kbd: "v",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("VideoChat"),
|
||||||
|
},
|
||||||
|
ChatBox: {
|
||||||
|
id: "ChatBox",
|
||||||
|
icon: "chat",
|
||||||
|
label: "Chat",
|
||||||
|
kbd: "c",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("ChatBox"),
|
||||||
|
},
|
||||||
|
Embed: {
|
||||||
|
id: "Embed",
|
||||||
|
icon: "embed",
|
||||||
|
label: "Embed",
|
||||||
|
kbd: "e",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("Embed"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions(editor, actions) {
|
||||||
|
return {
|
||||||
|
...actions,
|
||||||
|
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: "s",
|
||||||
|
onSelect: () => {
|
||||||
|
copyLinkToCurrentView(editor)
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
},
|
||||||
|
revertCamera: {
|
||||||
|
id: "revert-camera",
|
||||||
|
label: "Revert Camera",
|
||||||
|
kbd: "b",
|
||||||
|
onSelect: () => {
|
||||||
|
if (cameraHistory.length > 0) {
|
||||||
|
revertCamera(editor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
},
|
||||||
|
lockToFrame: {
|
||||||
|
id: "lock-to-frame",
|
||||||
|
label: "Lock to Frame",
|
||||||
|
kbd: "l",
|
||||||
|
onSelect: () => lockCameraToFrame(editor),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { TLBookmarkAsset, AssetRecordType, getHashForString } from "tldraw"
|
||||||
|
import { WORKER_URL } from "../routes/Board"
|
||||||
|
|
||||||
|
export async function unfurlBookmarkUrl({
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
url: string
|
||||||
|
}): Promise<TLBookmarkAsset> {
|
||||||
|
const asset: TLBookmarkAsset = {
|
||||||
|
id: AssetRecordType.createId(getHashForString(url)),
|
||||||
|
typeName: "asset",
|
||||||
|
type: "bookmark",
|
||||||
|
meta: {},
|
||||||
|
props: {
|
||||||
|
src: url,
|
||||||
|
description: "",
|
||||||
|
image: "",
|
||||||
|
favicon: "",
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`,
|
||||||
|
)
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
description: string
|
||||||
|
image: string
|
||||||
|
favicon: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
asset.props.description = data?.description ?? ""
|
||||||
|
asset.props.image = data?.image ?? ""
|
||||||
|
asset.props.favicon = data?.favicon ?? ""
|
||||||
|
asset.props.title = data?.title ?? ""
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue