updated website copy, installed locked-view function (coordinates break when locked tho), trying to get video transcripts working

This commit is contained in:
Jeff-Emmett 2025-04-08 14:32:15 -07:00
parent 9a3ad9a1ab
commit db4ae0c766
10 changed files with 446 additions and 175 deletions

View File

@ -13,21 +13,21 @@
<!-- Social Meta Tags --> <!-- Social Meta Tags -->
<meta name="description" <meta name="description"
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation."> content="Mycelial experimentation in the digital realm.">
<meta property="og:url" content="https://jeffemmett.com"> <meta property="og:url" content="https://jeffemmett.com">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="Jeff Emmett"> <meta property="og:title" content="A MycoPunk Website">
<meta property="og:description" <meta property="og:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation."> content="Mycelial knowledge and economic experimentation in the digital realm.">
<meta property="og:image" content="/website-embed.png"> <meta property="og:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="jeffemmett.com"> <meta property="twitter:domain" content="jeffemmett.com">
<meta property="twitter:url" content="https://jeffemmett.com"> <meta property="twitter:url" content="https://jeffemmett.com">
<meta name="twitter:title" content="Jeff Emmett"> <meta name="twitter:title" content="A MycoPunk Website">
<meta name="twitter:description" <meta name="twitter:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation."> content="Mycelial knowledge and economic experimentation in the digital realm.">
<meta name="twitter:image" content="/website-embed.png"> <meta name="twitter:image" content="/website-embed.png">
<!-- Analytics --> <!-- Analytics -->

View File

@ -64,9 +64,9 @@ export function useCameraControls(editor: Editor | null) {
if (!editor) return if (!editor) return
const camera = editor.getCamera() const camera = editor.getCamera()
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.set("x", Math.round(camera.x).toString()) url.searchParams.set("x", camera.x.toFixed(2))
url.searchParams.set("y", Math.round(camera.y).toString()) url.searchParams.set("y", camera.y.toFixed(2))
url.searchParams.set("zoom", Math.round(camera.z).toString()) url.searchParams.set("zoom", camera.z.toFixed(2))
navigator.clipboard.writeText(url.toString()) navigator.clipboard.writeText(url.toString())
}, },

View File

@ -33,9 +33,10 @@ import { llm } from "@/utils/llmUtils"
import { import {
lockElement, lockElement,
unlockElement, unlockElement,
setInitialCameraFromUrl, //setInitialCameraFromUrl,
initLockIndicators, initLockIndicators,
watchForLockedShapes, watchForLockedShapes,
zoomToSelection,
} from "@/ui/cameraUtils" } from "@/ui/cameraUtils"
// Default to production URL if env var isn't available // Default to production URL if env var isn't available
@ -77,6 +78,8 @@ export function Board() {
const store = useSync(storeConfig) const store = useSync(storeConfig)
const [editor, setEditor] = useState<Editor | null>(null) const [editor, setEditor] = useState<Editor | null>(null)
const [isCameraLocked, setIsCameraLocked] = useState(false)
useEffect(() => { useEffect(() => {
const value = localStorage.getItem("makereal_settings_2") const value = localStorage.getItem("makereal_settings_2")
if (value) { if (value) {
@ -97,6 +100,71 @@ export function Board() {
watchForLockedShapes(editor) watchForLockedShapes(editor)
}, [editor]) }, [editor])
useEffect(() => {
if (!editor) return
// First set the camera position
const url = new URL(window.location.href)
const x = url.searchParams.get("x")
const y = url.searchParams.get("y")
const zoom = url.searchParams.get("zoom")
const shapeId = url.searchParams.get("shapeId")
const frameId = url.searchParams.get("frameId")
const isLocked = url.searchParams.get("isLocked") === "true"
const initializeCamera = async () => {
// Start with camera unlocked
setIsCameraLocked(false)
if (x && y && zoom) {
editor.stopCameraAnimation()
// Set camera position immediately when editor is available
editor.setCamera(
{
x: parseFloat(parseFloat(x).toFixed(2)),
y: parseFloat(parseFloat(y).toFixed(2)),
z: parseFloat(parseFloat(zoom).toFixed(2))
},
{ animation: { duration: 0 } }
)
// Ensure camera update is applied
editor.updateInstanceState({ ...editor.getInstanceState() })
}
// Handle shape/frame selection after camera position is set
if (shapeId) {
editor.select(shapeId as TLShapeId)
const bounds = editor.getSelectionPageBounds()
if (bounds && !x && !y && !zoom) {
zoomToSelection(editor)
}
} else if (frameId) {
editor.select(frameId as TLShapeId)
const frame = editor.getShape(frameId as TLShapeId)
if (frame && !x && !y && !zoom) {
const bounds = editor.getShapePageBounds(frame)
if (bounds) {
editor.zoomToBounds(bounds, {
targetZoom: 1,
animation: { duration: 0 },
})
}
}
}
// Lock camera after all initialization is complete
if (isLocked) {
requestAnimationFrame(() => {
setIsCameraLocked(true)
})
}
}
initializeCamera()
}, [editor])
return ( return (
<div style={{ position: "fixed", inset: 0 }}> <div style={{ position: "fixed", inset: 0 }}>
<Tldraw <Tldraw
@ -115,6 +183,7 @@ export function Board() {
} }
}} }}
cameraOptions={{ cameraOptions={{
isLocked: isCameraLocked,
zoomSteps: [ zoomSteps: [
0.001, // Min zoom 0.001, // Min zoom
0.0025, 0.0025,
@ -138,8 +207,7 @@ export function Board() {
setEditor(editor) setEditor(editor)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl) editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand") editor.setCurrentTool("hand")
setInitialCameraFromUrl(editor)
handleInitialPageLoad(editor)
registerPropagators(editor, [ registerPropagators(editor, [
TickPropagator, TickPropagator,
ChangePropagator, ChangePropagator,

View File

@ -4,34 +4,38 @@ export function Default() {
<header>Jeff Emmett</header> <header>Jeff Emmett</header>
<h2>Hello! 👋🍄</h2> <h2>Hello! 👋🍄</h2>
<p> <p>
My research investigates the intersection of mycelium and emancipatory My research investigates the intersection of mycelial patterns and emancipatory
technologies. I am interested in the potential of new convivial tooling technologies. I am interested in the potential of new convivial tooling
as a medium for group consensus building and collective action, in order as a medium for group consensus building and collective action, in order
to empower communities of practice to address their own challenges. to empower communities of practice to address their local challenges in an
age of ecological and instititutional collapse.
</p> </p>
<p> <p>
My current focus is basic research into the nature of digital I let my curiosity about mushrooms guide me, taking inspiration from their
organisation, developing prototype toolkits to improve shared willingness to playfully experiment and adapt, even in the most chaotic environments.
infrastructure, and applying this research to the design of new systems I am fascinated by the potential of mycelial networks to create new forms of bottoms-up
and protocols which support the self-organisation of knowledge and sensing, collective cohereing around sensible directions, and emergent dynamic action
emergent response to local needs. towards addressing local challenges.
</p> </p>
<h2>My work</h2> <h2>My work</h2>
<p> <p>
Alongside my independent work, I am a researcher and engineering I am fortunate enough to collaborate with some pretty incredible groups of
communicator at <a href="https://block.science/">Block Science</a>, an researchers and builders. I am a research communicator at
advisor to the Active Inference Lab, Commons Stack, and the Trusted <a href="https://block.science/">Block Science</a>, an
Seed. I am also an occasional collaborator with{" "} advisor to the <a href= "https://activeinference.org/">Active Inference Lab</a>,
<a href="https://economicspace.agency/">ECSA</a>. co-founder of <a href="https://commonsstack.org/">Commons Stack</a>, and
board member of the <a href="https://trustedseed.org/">Trusted Seed</a>. I am also
a collaborator with <a href="https://economicspace.agency/">The Economic Space Agency</a>.
</p> </p>
<h2>Get in touch</h2> <h2>Get in Touch to Collaborate</h2>
<p> <p>
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a> I am on Substack <a href="https://allthingsdecent.substack.com/">@All Things Decent</a>,
, Mastodon{" "} Bluesky <a href="https://bsky.app/profile/jeffemmett.com">@jeffemmett</a>,
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "} Twitter <a href="https://x.com/jeffemmett">@jeffemmett</a>,
Mastodon<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>
and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>. and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
</p> </p>
@ -42,34 +46,29 @@ export function Default() {
<li> <li>
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett"> <a href="https://www.teamhuman.fm/episodes/238-jeff-emmett">
MycoPunk Futures on Team Human with Douglas Rushkoff MycoPunk Futures on Team Human with Douglas Rushkoff
</a>{" "} </a>
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li> </li>
<li> <li>
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg"> <a href="https://www.youtube.com/watch?v=AFJFDajuCSg">
Exploring MycoFi on the Greenpill Network with Kevin Owocki Exploring MycoFi on the Greenpill Network with Kevin Owocki
</a>{" "} </a>
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li> </li>
<li> <li>
<a href="https://youtu.be/9ad2EJhMbZ8"> <a href="https://youtu.be/9ad2EJhMbZ8">
Re-imagining Human Value on the Telos Podcast with Rieki & Re-imagining Human Value on the Telos Podcast with Rieki &
Brandonfrom SEEDS Brandon from SEEDS
</a>{" "} </a>
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li> </li>
<li> <li>
<a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s"> <a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">
Move Slow & Fix Things: Design Patterns from Nature Move Slow & Fix Things: Design Patterns from Nature
</a>{" "} </a>
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li> </li>
<li> <li>
<a href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo"> <a href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">
Localized Democracy and Public Goods with Token Engineering on the Localized Democracy and Public Goods with Token Engineering on the
Ownership Economy Ownership Economy
</a>{" "} </a>
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li> </li>
<li> <li>
<a href="https://youtu.be/kxcat-XBWas"> <a href="https://youtu.be/kxcat-XBWas">

View File

@ -5,8 +5,11 @@ interface DailyApiResponse {
url: string; url: string;
} }
interface DailyRecordingResponse { interface DailyTranscriptResponse {
id: string; id: string;
transcriptionId: string;
text?: string;
link?: string;
} }
export type IVideoChatShape = TLBaseShape< export type IVideoChatShape = TLBaseShape<
@ -17,8 +20,9 @@ export type IVideoChatShape = TLBaseShape<
roomUrl: string | null roomUrl: string | null
allowCamera: boolean allowCamera: boolean
allowMicrophone: boolean allowMicrophone: boolean
enableRecording: boolean enableTranscription: boolean
recordingId: string | null // Track active recording transcriptionId: string | null
isTranscribing: boolean
} }
> >
@ -36,8 +40,9 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
h: 600, h: 600,
allowCamera: false, allowCamera: false,
allowMicrophone: false, allowMicrophone: false,
enableRecording: true, enableTranscription: true,
recordingId: null transcriptionId: null,
isTranscribing: false
} }
} }
@ -145,80 +150,196 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
} }
} }
async startRecording(shape: IVideoChatShape) { async startTranscription(shape: IVideoChatShape) {
if (!shape.props.roomUrl) return; if (!shape.props.roomUrl) return;
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY; const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) {
throw new Error('Daily.co API key not configured');
}
try { try {
const response = await fetch(`${workerUrl}/daily/recordings/start`, { // Extract room name from the room URL
const roomName = new URL(shape.props.roomUrl).pathname.split('/').pop();
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/start-transcription`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${apiKey}`, 'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, }
body: JSON.stringify({
room_name: shape.id,
layout: {
preset: "active-speaker"
}
})
}); });
if (!response.ok) throw new Error('Failed to start recording'); if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to start transcription: ${JSON.stringify(error)}`);
}
const data = await response.json() as DailyRecordingResponse; const data = await response.json() as DailyTranscriptResponse;
await this.editor.updateShape<IVideoChatShape>({ await this.editor.updateShape<IVideoChatShape>({
id: shape.id, id: shape.id,
type: shape.type, type: shape.type,
props: { props: {
...shape.props, ...shape.props,
recordingId: data.id transcriptionId: data.transcriptionId || data.id,
isTranscribing: true
} }
}); });
} catch (error) { } catch (error) {
console.error('Error starting recording:', error); console.error('Error starting transcription:', error);
throw error; throw error;
} }
} }
async stopRecording(shape: IVideoChatShape) { async stopTranscription(shape: IVideoChatShape) {
if (!shape.props.recordingId) return; if (!shape.props.roomUrl) return;
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY; const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) {
throw new Error('Daily.co API key not configured');
}
try { try {
await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, { // Extract room name from the room URL
const roomName = new URL(shape.props.roomUrl).pathname.split('/').pop();
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/stop-transcription`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${apiKey}` 'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
} }
}); });
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to stop transcription: ${JSON.stringify(error)}`);
}
const data = await response.json() as DailyTranscriptResponse;
console.log('Stop transcription response:', data);
// Update both transcriptionId and isTranscribing state
await this.editor.updateShape<IVideoChatShape>({ await this.editor.updateShape<IVideoChatShape>({
id: shape.id, id: shape.id,
type: shape.type, type: shape.type,
props: { props: {
...shape.props, ...shape.props,
recordingId: null transcriptionId: data.transcriptionId || data.id || 'completed',
isTranscribing: false
} }
}); });
} catch (error) { } catch (error) {
console.error('Error stopping recording:', error); console.error('Error stopping transcription:', error);
throw error; throw error;
} }
} }
async getTranscriptionText(transcriptId: string): Promise<string> {
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) {
throw new Error('Daily.co API key not configured');
}
console.log('Fetching transcript for ID:', transcriptId); // Debug log
const response = await fetch(`${workerUrl}/transcript/${transcriptId}`, { // Remove 'daily' from path
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const error = await response.json();
console.error('Transcript API response:', error); // Debug log
throw new Error(`Failed to get transcription: ${JSON.stringify(error)}`);
}
const data = await response.json() as DailyTranscriptResponse;
console.log('Transcript data received:', data); // Debug log
return data.text || 'No transcription available';
}
async getTranscriptAccessLink(transcriptId: string): Promise<string> {
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) {
throw new Error('Daily.co API key not configured');
}
console.log('Fetching transcript access link for ID:', transcriptId); // Debug log
const response = await fetch(`${workerUrl}/transcript/${transcriptId}/access-link`, { // Remove 'daily' from path
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const error = await response.json();
console.error('Transcript link API response:', error); // Debug log
throw new Error(`Failed to get transcript access link: ${JSON.stringify(error)}`);
}
const data = await response.json() as DailyTranscriptResponse;
console.log('Transcript link data received:', data); // Debug log
return data.link || 'No transcript link available';
}
component(shape: IVideoChatShape) { component(shape: IVideoChatShape) {
const [hasPermissions, setHasPermissions] = useState(false) const [hasPermissions, setHasPermissions] = useState(false)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [roomUrl, setRoomUrl] = useState<string | null>(shape.props.roomUrl) const [roomUrl, setRoomUrl] = useState<string | null>(shape.props.roomUrl)
const [isCallActive, setIsCallActive] = useState(false)
const handleIframeMessage = (event: MessageEvent) => {
// Check if message is from Daily.co
if (!event.origin.includes('daily.co')) return;
console.log('Daily message received:', event.data);
// Check for call state updates
if (event.data?.action === 'daily-method-result') {
// Handle join success
if (event.data.method === 'join' && !event.data.error) {
console.log('Join successful - setting call as active');
setIsCallActive(true);
}
}
// Also check for participant events
if (event.data?.action === 'participant-joined') {
console.log('Participant joined - setting call as active');
setIsCallActive(true);
}
// Check for call ended
if (event.data?.action === 'left-meeting' ||
event.data?.action === 'participant-left') {
console.log('Call ended - setting call as inactive');
setIsCallActive(false);
}
};
useEffect(() => {
window.addEventListener('message', handleIframeMessage);
return () => {
window.removeEventListener('message', handleIframeMessage);
};
}, []);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -250,7 +371,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [shape.id]); // Only re-run if shape.id changes }, [shape.id]);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -282,6 +403,28 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
} }
}, [shape.props.allowCamera, shape.props.allowMicrophone]) }, [shape.props.allowCamera, shape.props.allowMicrophone])
const handleTranscriptionClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isCallActive) {
console.log('Cannot control transcription when call is not active');
return;
}
try {
if (shape.props.isTranscribing) {
console.log('Stopping transcription');
await this.stopTranscription(shape);
} else {
console.log('Starting transcription');
await this.startTranscription(shape);
}
} catch (err) {
console.error('Transcription error:', err);
}
};
if (error) { if (error) {
return <div>Error creating room: {error.message}</div> return <div>Error creating room: {error.message}</div>
} }
@ -317,6 +460,16 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
console.log(roomUrl) console.log(roomUrl)
// Debug log for render
console.log('Current call state:', { isCallActive, roomUrl });
// Add debug log before render
console.log('Rendering component with states:', {
isCallActive,
isTranscribing: shape.props.isTranscribing,
roomUrl
});
return ( return (
<div <div
style={{ style={{
@ -324,7 +477,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
height: `${shape.props.h}px`, height: `${shape.props.h}px`,
position: "relative", position: "relative",
pointerEvents: "all", pointerEvents: "all",
overflow: "hidden", overflow: "visible",
}} }}
> >
<iframe <iframe
@ -339,58 +492,80 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
right: 0, right: 0,
bottom: 0, bottom: 0,
}} }}
allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${ allow="camera *; microphone *; display-capture *; clipboard-read; clipboard-write"
shape.props.allowMicrophone ? "self" : "" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals"
}`} />
></iframe>
{shape.props.enableRecording && ( {/* Add data-testid to help debug iframe messages */}
<button <div data-testid="call-status">
onClick={async () => { Call Active: {isCallActive ? 'Yes' : 'No'}
try { </div>
if (shape.props.recordingId) {
await this.stopRecording(shape);
} else {
await this.startRecording(shape);
}
} catch (err) {
console.error('Recording error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: "8px",
padding: "4px 8px",
background: shape.props.recordingId ? "#ff4444" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.recordingId ? "Stop Recording" : "Start Recording"}
</button>
)}
<p <div
style={{ style={{
position: "absolute", position: "absolute",
bottom: 0, bottom: -48,
left: 0, left: 0,
right: 0,
margin: "8px", margin: "8px",
padding: "4px 8px", padding: "8px 12px",
background: "rgba(255, 255, 255, 0.9)", background: "rgba(255, 255, 255, 0.95)",
borderRadius: "4px", borderRadius: "6px",
fontSize: "12px", fontSize: "12px",
pointerEvents: "all", pointerEvents: "all",
cursor: "text", touchAction: "manipulation",
userSelect: "text", display: "flex",
zIndex: 1, alignItems: "center",
justifyContent: "space-between",
zIndex: 999,
border: "1px solid #ccc",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
userSelect: "none",
}} }}
> >
url: {roomUrl} <span style={{
</p> cursor: "text",
userSelect: "text",
maxWidth: "60%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
pointerEvents: "all",
touchAction: "auto"
}}>
url: {roomUrl}
</span>
<button
onClick={handleTranscriptionClick}
disabled={!isCallActive}
style={{
marginLeft: "12px",
padding: "6px 12px",
background: shape.props.isTranscribing ? "#ff4444" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: isCallActive ? "pointer" : "not-allowed",
whiteSpace: "nowrap",
flexShrink: 0,
pointerEvents: isCallActive ? "all" : "none", // Add explicit pointer-events control
touchAction: "manipulation",
WebkitTapHighlightColor: "transparent",
userSelect: "none",
minHeight: "32px",
minWidth: "44px",
zIndex: 1000,
position: "relative",
opacity: isCallActive ? 1 : 0.5
}}
>
{!isCallActive
? "Join call to enable transcription"
: shape.props.isTranscribing
? "Stop Transcription"
: "Start Transcription"
}
</button>
</div>
</div> </div>
) )
} }

View File

@ -12,6 +12,7 @@ import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw"
import { TLUiContextMenuProps, useEditor } from "tldraw" import { TLUiContextMenuProps, useEditor } from "tldraw"
import { import {
cameraHistory, cameraHistory,
copyLinkToLockedView,
} from "./cameraUtils" } from "./cameraUtils"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { saveToPdf } from "../utils/pdfUtils" import { saveToPdf } from "../utils/pdfUtils"
@ -95,11 +96,13 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
<TldrawUiMenuGroup id="camera-controls"> <TldrawUiMenuGroup id="camera-controls">
<TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} /> <TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.copyLinkToCurrentView} /> <TldrawUiMenuItem {...customActions.copyLinkToCurrentView} />
<TldrawUiMenuItem {...customActions.copyLockedLink} />
<TldrawUiMenuItem {...customActions.revertCamera} disabled={!hasCameraHistory} /> <TldrawUiMenuItem {...customActions.revertCamera} disabled={!hasCameraHistory} />
<TldrawUiMenuItem {...customActions.lockElement} disabled={!hasSelection} /> <TldrawUiMenuItem {...customActions.lockElement} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} /> <TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.saveToPdf} disabled={!hasSelection} /> <TldrawUiMenuItem {...customActions.saveToPdf} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.llm} disabled={!hasSelection} /> <TldrawUiMenuItem {...customActions.llm} disabled={!hasSelection} />
</TldrawUiMenuGroup> </TldrawUiMenuGroup>
{/* Creation Tools Group */} {/* Creation Tools Group */}

View File

@ -81,9 +81,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", Math.round(newCamera.x).toString()) url.searchParams.set("x", newCamera.x.toFixed(2))
url.searchParams.set("y", Math.round(newCamera.y).toString()) url.searchParams.set("y", newCamera.y.toFixed(2))
url.searchParams.set("zoom", Math.round(newCamera.z).toString()) url.searchParams.set("zoom", newCamera.z.toFixed(2))
window.history.replaceState(null, "", url.toString()) window.history.replaceState(null, "", url.toString())
} }
@ -119,41 +119,49 @@ export const revertCamera = (editor: Editor) => {
} }
export const copyLinkToCurrentView = async (editor: Editor) => { export const copyLinkToCurrentView = async (editor: Editor) => {
if (!editor.store.serialize()) return
if (!editor.store.serialize()) {
//console.warn("Store not ready")
return
}
try { try {
const baseUrl = `${window.location.origin}${window.location.pathname}` const baseUrl = `${window.location.origin}${window.location.pathname}`
const url = new URL(baseUrl) const url = new URL(baseUrl)
const camera = editor.getCamera() const camera = editor.getCamera()
// Round camera values to integers // Round camera values to 2 decimal places
url.searchParams.set("x", Math.round(camera.x).toString()) url.searchParams.set("x", camera.x.toFixed(2))
url.searchParams.set("y", Math.round(camera.y).toString()) url.searchParams.set("y", camera.y.toFixed(2))
url.searchParams.set("zoom", Math.round(camera.z).toString()) url.searchParams.set("zoom", camera.z.toFixed(2))
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())
} }
const finalUrl = url.toString() await navigator.clipboard.writeText(url.toString())
} catch (error) {
alert("Failed to copy link. Please check clipboard permissions.")
}
}
if (navigator.clipboard && window.isSecureContext) { export const copyLinkToLockedView = async (editor: Editor) => {
await navigator.clipboard.writeText(finalUrl) if (!editor.store.serialize()) return
} else {
const textArea = document.createElement("textarea") try {
textArea.value = finalUrl const baseUrl = `${window.location.origin}${window.location.pathname}`
document.body.appendChild(textArea) const url = new URL(baseUrl)
try { const camera = editor.getCamera()
await navigator.clipboard.writeText(textArea.value)
} catch (err) { // Round camera values to 2 decimal places
} url.searchParams.set("x", camera.x.toFixed(2))
document.body.removeChild(textArea) url.searchParams.set("y", camera.y.toFixed(2))
url.searchParams.set("zoom", camera.z.toFixed(2))
url.searchParams.set("isLocked", "true")
const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length > 0) {
url.searchParams.set("shapeId", selectedIds[0].toString())
} }
await navigator.clipboard.writeText(url.toString())
} catch (error) { } catch (error) {
alert("Failed to copy link. Please check clipboard permissions.") alert("Failed to copy link. Please check clipboard permissions.")
} }
@ -283,47 +291,55 @@ export const initLockIndicators = (editor: Editor) => {
}) })
} }
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")
const y = url.searchParams.get("y") // const y = url.searchParams.get("y")
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"
if (x && y && zoom) { // // Always set camera position first if coordinates exist
editor.stopCameraAnimation() // if (x && y && zoom) {
editor.setCamera( // editor.stopCameraAnimation()
{ // // Force camera position update
x: Math.round(parseFloat(x)), // editor.setCamera(
y: Math.round(parseFloat(y)), // {
z: Math.round(parseFloat(zoom)) // x: Math.round(parseFloat(x)),
}, // y: Math.round(parseFloat(y)),
{ animation: { duration: 0 } } // z: Math.round(parseFloat(zoom))
) // },
} // { animation: { duration: 0 } }
// )
// // Ensure camera update is applied
// editor.updateInstanceState({ ...editor.getInstanceState() })
// }
// Handle shape/frame selection and zoom // // Handle other camera operations after position is set
if (shapeId) { // if (shapeId) {
editor.select(shapeId as TLShapeId) // editor.select(shapeId as TLShapeId)
const bounds = editor.getSelectionPageBounds() // const bounds = editor.getSelectionPageBounds()
if (bounds && !x && !y && !zoom) { // if (bounds && !x && !y && !zoom) {
zoomToSelection(editor) // zoomToSelection(editor)
} // }
} else if (frameId) { // } else if (frameId) {
editor.select(frameId as TLShapeId) // editor.select(frameId as TLShapeId)
const frame = editor.getShape(frameId as TLShapeId) // const frame = editor.getShape(frameId as TLShapeId)
if (frame && !x && !y && !zoom) { // if (frame && !x && !y && !zoom) {
const bounds = editor.getShapePageBounds(frame as TLShape) // const bounds = editor.getShapePageBounds(frame as TLShape)
if (bounds) { // if (bounds) {
editor.zoomToBounds(bounds, { // editor.zoomToBounds(bounds, {
targetZoom: 1, // targetZoom: 1,
animation: { duration: 0 }, // animation: { duration: 0 },
}) // })
} // }
} // }
} // }
}
// return isLocked
// }
export const zoomToFrame = (editor: Editor, frameId: string) => { export const zoomToFrame = (editor: Editor, frameId: string) => {
if (!editor) return if (!editor) return

View File

@ -12,6 +12,7 @@ import {
revertCamera, revertCamera,
unlockElement, unlockElement,
zoomToSelection, zoomToSelection,
copyLinkToLockedView,
} from "./cameraUtils" } from "./cameraUtils"
import { saveToPdf } from "../utils/pdfUtils" import { saveToPdf } from "../utils/pdfUtils"
import { searchText } from "../utils/searchUtils" import { searchText } from "../utils/searchUtils"
@ -348,6 +349,16 @@ export const overrides: TLUiOverrides = {
} }
}, },
}, },
//TODO: FIX COPY LOCKED LINK
copyLockedLink: {
id: "copy-locked-link",
label: "Copy Locked View Link",
kbd: "alt+shift+c",
onSelect() {
copyLinkToLockedView(editor)
},
readonlyOk: true,
},
//TODO: FIX PREV & NEXT SLIDE KEYBOARD COMMANDS //TODO: FIX PREV & NEXT SLIDE KEYBOARD COMMANDS
// "next-slide": { // "next-slide": {
// id: "next-slide", // id: "next-slide",

View File

@ -77,9 +77,9 @@ export const searchText = (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", matchingShapes[0].id) url.searchParams.set("shapeId", matchingShapes[0].id)
url.searchParams.set("x", newCamera.x.toString()) url.searchParams.set("x", newCamera.x.toFixed(2))
url.searchParams.set("y", newCamera.y.toString()) url.searchParams.set("y", newCamera.y.toFixed(2))
url.searchParams.set("zoom", newCamera.z.toString()) url.searchParams.set("zoom", newCamera.z.toFixed(2))
window.history.replaceState(null, "", url.toString()) window.history.replaceState(null, "", url.toString())
} else { } else {
alert("No matches found") alert("No matches found")

View File

@ -9,13 +9,12 @@ export { TldrawDurableObject } from "./TldrawDurableObject"
// Define security headers // Define security headers
const securityHeaders = { const securityHeaders = {
"Content-Security-Policy": "Content-Security-Policy":
"default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';", "default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://*.daily.co; child-src 'self' https://*.daily.co;",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block", "X-XSS-Protection": "1; mode=block",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains", "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin", "Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Permissions-Policy": "camera=*, microphone=*, geolocation=()",
} }
// we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because // we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because