updated website copy, installed locked-view function (coordinates break when locked tho), trying to get video transcripts working
This commit is contained in:
parent
2a3b79df15
commit
d7b1e348e9
10
index.html
10
index.html
|
|
@ -13,21 +13,21 @@
|
|||
|
||||
<!-- Social Meta Tags -->
|
||||
<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:type" content="website">
|
||||
<meta property="og:title" content="Jeff Emmett">
|
||||
<meta property="og:title" content="A MycoPunk Website">
|
||||
<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 name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:domain" content="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"
|
||||
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">
|
||||
|
||||
<!-- Analytics -->
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@ export function useCameraControls(editor: Editor | null) {
|
|||
if (!editor) return
|
||||
const camera = editor.getCamera()
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("x", Math.round(camera.x).toString())
|
||||
url.searchParams.set("y", Math.round(camera.y).toString())
|
||||
url.searchParams.set("zoom", Math.round(camera.z).toString())
|
||||
url.searchParams.set("x", camera.x.toFixed(2))
|
||||
url.searchParams.set("y", camera.y.toFixed(2))
|
||||
url.searchParams.set("zoom", camera.z.toFixed(2))
|
||||
navigator.clipboard.writeText(url.toString())
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ import { llm } from "@/utils/llmUtils"
|
|||
import {
|
||||
lockElement,
|
||||
unlockElement,
|
||||
setInitialCameraFromUrl,
|
||||
//setInitialCameraFromUrl,
|
||||
initLockIndicators,
|
||||
watchForLockedShapes,
|
||||
zoomToSelection,
|
||||
} from "@/ui/cameraUtils"
|
||||
|
||||
// Default to production URL if env var isn't available
|
||||
|
|
@ -77,6 +78,8 @@ export function Board() {
|
|||
const store = useSync(storeConfig)
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
|
||||
const [isCameraLocked, setIsCameraLocked] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const value = localStorage.getItem("makereal_settings_2")
|
||||
if (value) {
|
||||
|
|
@ -97,6 +100,71 @@ export function Board() {
|
|||
watchForLockedShapes(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 (
|
||||
<div style={{ position: "fixed", inset: 0 }}>
|
||||
<Tldraw
|
||||
|
|
@ -115,6 +183,7 @@ export function Board() {
|
|||
}
|
||||
}}
|
||||
cameraOptions={{
|
||||
isLocked: isCameraLocked,
|
||||
zoomSteps: [
|
||||
0.001, // Min zoom
|
||||
0.0025,
|
||||
|
|
@ -138,8 +207,7 @@ export function Board() {
|
|||
setEditor(editor)
|
||||
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
||||
editor.setCurrentTool("hand")
|
||||
setInitialCameraFromUrl(editor)
|
||||
handleInitialPageLoad(editor)
|
||||
|
||||
registerPropagators(editor, [
|
||||
TickPropagator,
|
||||
ChangePropagator,
|
||||
|
|
|
|||
|
|
@ -4,34 +4,38 @@ export function Default() {
|
|||
<header>Jeff Emmett</header>
|
||||
<h2>Hello! 👋🍄</h2>
|
||||
<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
|
||||
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>
|
||||
My current focus is basic research into the nature of digital
|
||||
organisation, developing prototype toolkits to improve shared
|
||||
infrastructure, and applying this research to the design of new systems
|
||||
and protocols which support the self-organisation of knowledge and
|
||||
emergent response to local needs.
|
||||
I let my curiosity about mushrooms guide me, taking inspiration from their
|
||||
willingness to playfully experiment and adapt, even in the most chaotic environments.
|
||||
I am fascinated by the potential of mycelial networks to create new forms of bottoms-up
|
||||
sensing, collective cohereing around sensible directions, and emergent dynamic action
|
||||
towards addressing local challenges.
|
||||
</p>
|
||||
|
||||
<h2>My work</h2>
|
||||
<p>
|
||||
Alongside my independent work, I am a researcher and engineering
|
||||
communicator at <a href="https://block.science/">Block Science</a>, an
|
||||
advisor to the Active Inference Lab, Commons Stack, and the Trusted
|
||||
Seed. I am also an occasional collaborator with{" "}
|
||||
<a href="https://economicspace.agency/">ECSA</a>.
|
||||
I am fortunate enough to collaborate with some pretty incredible groups of
|
||||
researchers and builders. I am a research communicator at
|
||||
<a href="https://block.science/">Block Science</a>, an
|
||||
advisor to the <a href= "https://activeinference.org/">Active Inference Lab</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>
|
||||
|
||||
<h2>Get in touch</h2>
|
||||
<h2>Get in Touch to Collaborate</h2>
|
||||
<p>
|
||||
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
|
||||
, Mastodon{" "}
|
||||
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "}
|
||||
I am on Substack <a href="https://allthingsdecent.substack.com/">@All Things Decent</a>,
|
||||
Bluesky <a href="https://bsky.app/profile/jeffemmett.com">@jeffemmett</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>.
|
||||
</p>
|
||||
|
||||
|
|
@ -42,34 +46,29 @@ export function Default() {
|
|||
<li>
|
||||
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett">
|
||||
MycoPunk Futures on Team Human with Douglas Rushkoff
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg">
|
||||
Exploring MycoFi on the Greenpill Network with Kevin Owocki
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://youtu.be/9ad2EJhMbZ8">
|
||||
Re-imagining Human Value on the Telos Podcast with Rieki &
|
||||
Brandonfrom SEEDS
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
Brandon from SEEDS
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">
|
||||
Move Slow & Fix Things: Design Patterns from Nature
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</a>
|
||||
</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">
|
||||
Localized Democracy and Public Goods with Token Engineering on the
|
||||
Ownership Economy
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://youtu.be/kxcat-XBWas">
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ interface DailyApiResponse {
|
|||
url: string;
|
||||
}
|
||||
|
||||
interface DailyRecordingResponse {
|
||||
interface DailyTranscriptResponse {
|
||||
id: string;
|
||||
transcriptionId: string;
|
||||
text?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export type IVideoChatShape = TLBaseShape<
|
||||
|
|
@ -17,8 +20,9 @@ export type IVideoChatShape = TLBaseShape<
|
|||
roomUrl: string | null
|
||||
allowCamera: boolean
|
||||
allowMicrophone: boolean
|
||||
enableRecording: boolean
|
||||
recordingId: string | null // Track active recording
|
||||
enableTranscription: boolean
|
||||
transcriptionId: string | null
|
||||
isTranscribing: boolean
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -36,8 +40,9 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
h: 600,
|
||||
allowCamera: false,
|
||||
allowMicrophone: false,
|
||||
enableRecording: true,
|
||||
recordingId: null
|
||||
enableTranscription: true,
|
||||
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;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'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>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
recordingId: data.id
|
||||
transcriptionId: data.transcriptionId || data.id,
|
||||
isTranscribing: true
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
console.error('Error starting transcription:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording(shape: IVideoChatShape) {
|
||||
if (!shape.props.recordingId) return;
|
||||
async stopTranscription(shape: IVideoChatShape) {
|
||||
if (!shape.props.roomUrl) return;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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',
|
||||
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>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
recordingId: null
|
||||
transcriptionId: data.transcriptionId || data.id || 'completed',
|
||||
isTranscribing: false
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error stopping recording:', error);
|
||||
console.error('Error stopping transcription:', 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) {
|
||||
const [hasPermissions, setHasPermissions] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
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(() => {
|
||||
let mounted = true;
|
||||
|
|
@ -250,7 +371,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [shape.id]); // Only re-run if shape.id changes
|
||||
}, [shape.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
|
@ -282,6 +403,28 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
}
|
||||
}, [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) {
|
||||
return <div>Error creating room: {error.message}</div>
|
||||
}
|
||||
|
|
@ -317,6 +460,16 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -324,7 +477,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
height: `${shape.props.h}px`,
|
||||
position: "relative",
|
||||
pointerEvents: "all",
|
||||
overflow: "hidden",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
|
|
@ -339,58 +492,80 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${
|
||||
shape.props.allowMicrophone ? "self" : ""
|
||||
}`}
|
||||
></iframe>
|
||||
allow="camera *; microphone *; display-capture *; clipboard-read; clipboard-write"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals"
|
||||
/>
|
||||
|
||||
{shape.props.enableRecording && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
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>
|
||||
)}
|
||||
{/* Add data-testid to help debug iframe messages */}
|
||||
<div data-testid="call-status">
|
||||
Call Active: {isCallActive ? 'Yes' : 'No'}
|
||||
</div>
|
||||
|
||||
<p
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
bottom: -48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
margin: "8px",
|
||||
padding: "4px 8px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
background: "rgba(255, 255, 255, 0.95)",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
pointerEvents: "all",
|
||||
cursor: "text",
|
||||
userSelect: "text",
|
||||
zIndex: 1,
|
||||
touchAction: "manipulation",
|
||||
display: "flex",
|
||||
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}
|
||||
</p>
|
||||
<span style={{
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw"
|
|||
import { TLUiContextMenuProps, useEditor } from "tldraw"
|
||||
import {
|
||||
cameraHistory,
|
||||
copyLinkToLockedView,
|
||||
} from "./cameraUtils"
|
||||
import { useState, useEffect } from "react"
|
||||
import { saveToPdf } from "../utils/pdfUtils"
|
||||
|
|
@ -95,11 +96,13 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuGroup id="camera-controls">
|
||||
<TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.copyLinkToCurrentView} />
|
||||
<TldrawUiMenuItem {...customActions.copyLockedLink} />
|
||||
<TldrawUiMenuItem {...customActions.revertCamera} disabled={!hasCameraHistory} />
|
||||
<TldrawUiMenuItem {...customActions.lockElement} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.saveToPdf} disabled={!hasSelection} />
|
||||
<TldrawUiMenuItem {...customActions.llm} disabled={!hasSelection} />
|
||||
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
{/* Creation Tools Group */}
|
||||
|
|
|
|||
|
|
@ -81,9 +81,9 @@ export const zoomToSelection = (editor: Editor) => {
|
|||
const newCamera = editor.getCamera()
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("shapeId", selectedIds[0].toString())
|
||||
url.searchParams.set("x", Math.round(newCamera.x).toString())
|
||||
url.searchParams.set("y", Math.round(newCamera.y).toString())
|
||||
url.searchParams.set("zoom", Math.round(newCamera.z).toString())
|
||||
url.searchParams.set("x", newCamera.x.toFixed(2))
|
||||
url.searchParams.set("y", newCamera.y.toFixed(2))
|
||||
url.searchParams.set("zoom", newCamera.z.toFixed(2))
|
||||
window.history.replaceState(null, "", url.toString())
|
||||
}
|
||||
|
||||
|
|
@ -119,41 +119,49 @@ export const revertCamera = (editor: Editor) => {
|
|||
}
|
||||
|
||||
export const copyLinkToCurrentView = async (editor: Editor) => {
|
||||
|
||||
if (!editor.store.serialize()) {
|
||||
//console.warn("Store not ready")
|
||||
return
|
||||
}
|
||||
if (!editor.store.serialize()) return
|
||||
|
||||
try {
|
||||
const baseUrl = `${window.location.origin}${window.location.pathname}`
|
||||
const url = new URL(baseUrl)
|
||||
const camera = editor.getCamera()
|
||||
|
||||
// Round camera values to integers
|
||||
url.searchParams.set("x", Math.round(camera.x).toString())
|
||||
url.searchParams.set("y", Math.round(camera.y).toString())
|
||||
url.searchParams.set("zoom", Math.round(camera.z).toString())
|
||||
// Round camera values to 2 decimal places
|
||||
url.searchParams.set("x", camera.x.toFixed(2))
|
||||
url.searchParams.set("y", camera.y.toFixed(2))
|
||||
url.searchParams.set("zoom", camera.z.toFixed(2))
|
||||
|
||||
const selectedIds = editor.getSelectedShapeIds()
|
||||
if (selectedIds.length > 0) {
|
||||
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) {
|
||||
await navigator.clipboard.writeText(finalUrl)
|
||||
} else {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = finalUrl
|
||||
document.body.appendChild(textArea)
|
||||
try {
|
||||
await navigator.clipboard.writeText(textArea.value)
|
||||
} catch (err) {
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
export const copyLinkToLockedView = async (editor: Editor) => {
|
||||
if (!editor.store.serialize()) return
|
||||
|
||||
try {
|
||||
const baseUrl = `${window.location.origin}${window.location.pathname}`
|
||||
const url = new URL(baseUrl)
|
||||
const camera = editor.getCamera()
|
||||
|
||||
// Round camera values to 2 decimal places
|
||||
url.searchParams.set("x", camera.x.toFixed(2))
|
||||
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) {
|
||||
alert("Failed to copy link. Please check clipboard permissions.")
|
||||
}
|
||||
|
|
@ -283,47 +291,55 @@ export const initLockIndicators = (editor: Editor) => {
|
|||
})
|
||||
}
|
||||
|
||||
export const setInitialCameraFromUrl = (editor: Editor) => {
|
||||
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")
|
||||
// export const setInitialCameraFromUrl = (editor: Editor) => {
|
||||
// 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"
|
||||
|
||||
if (x && y && zoom) {
|
||||
editor.stopCameraAnimation()
|
||||
editor.setCamera(
|
||||
{
|
||||
x: Math.round(parseFloat(x)),
|
||||
y: Math.round(parseFloat(y)),
|
||||
z: Math.round(parseFloat(zoom))
|
||||
},
|
||||
{ animation: { duration: 0 } }
|
||||
)
|
||||
}
|
||||
// // Always set camera position first if coordinates exist
|
||||
// if (x && y && zoom) {
|
||||
// editor.stopCameraAnimation()
|
||||
// // Force camera position update
|
||||
// editor.setCamera(
|
||||
// {
|
||||
// x: Math.round(parseFloat(x)),
|
||||
// y: Math.round(parseFloat(y)),
|
||||
// z: Math.round(parseFloat(zoom))
|
||||
// },
|
||||
// { animation: { duration: 0 } }
|
||||
// )
|
||||
|
||||
// // Ensure camera update is applied
|
||||
// editor.updateInstanceState({ ...editor.getInstanceState() })
|
||||
// }
|
||||
|
||||
// Handle shape/frame selection and zoom
|
||||
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 as TLShape)
|
||||
if (bounds) {
|
||||
editor.zoomToBounds(bounds, {
|
||||
targetZoom: 1,
|
||||
animation: { duration: 0 },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// // Handle other camera operations after 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 as TLShape)
|
||||
// if (bounds) {
|
||||
// editor.zoomToBounds(bounds, {
|
||||
// targetZoom: 1,
|
||||
// animation: { duration: 0 },
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return isLocked
|
||||
// }
|
||||
|
||||
export const zoomToFrame = (editor: Editor, frameId: string) => {
|
||||
if (!editor) return
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
revertCamera,
|
||||
unlockElement,
|
||||
zoomToSelection,
|
||||
copyLinkToLockedView,
|
||||
} from "./cameraUtils"
|
||||
import { saveToPdf } from "../utils/pdfUtils"
|
||||
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
|
||||
// "next-slide": {
|
||||
// id: "next-slide",
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@ export const searchText = (editor: Editor) => {
|
|||
const newCamera = editor.getCamera()
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("shapeId", matchingShapes[0].id)
|
||||
url.searchParams.set("x", newCamera.x.toString())
|
||||
url.searchParams.set("y", newCamera.y.toString())
|
||||
url.searchParams.set("zoom", newCamera.z.toString())
|
||||
url.searchParams.set("x", newCamera.x.toFixed(2))
|
||||
url.searchParams.set("y", newCamera.y.toFixed(2))
|
||||
url.searchParams.set("zoom", newCamera.z.toFixed(2))
|
||||
window.history.replaceState(null, "", url.toString())
|
||||
} else {
|
||||
alert("No matches found")
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@ export { TldrawDurableObject } from "./TldrawDurableObject"
|
|||
// Define security headers
|
||||
const securityHeaders = {
|
||||
"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-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
"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
|
||||
|
|
|
|||
Loading…
Reference in New Issue