Add Meeting Intelligence browser shape for canvas integration

New canvas shape that connects to the self-hosted Meeting Intelligence API
(meets-api.rspace.online) to browse meetings, pull transcripts, summaries,
and speaker data onto the canvas as note shapes.

- MeetingIntelligencePanel: React panel listing meetings with pull buttons
- MeetingIntelligenceBrowserShapeUtil: Browser shape wrapping the panel
- MeetingIntelligenceTool: Tool for placing browser on canvas
- Registered in Board.tsx, overrides.tsx, automerge stores, and all shape registries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-22 18:50:33 -07:00
parent 007a25d3da
commit f5cf0bfb78
10 changed files with 772 additions and 8 deletions

View File

@ -682,6 +682,7 @@ export function sanitizeRecord(record: any): TLRecord {
'holon': 'Holon',
'obsidianBrowser': 'ObsidianBrowser',
'fathomMeetingsBrowser': 'FathomMeetingsBrowser',
'meetingIntelligenceBrowser': 'MeetingIntelligenceBrowser',
'imageGen': 'ImageGen',
'videoGen': 'VideoGen',
'multmux': 'Multmux',

View File

@ -40,6 +40,7 @@ const CUSTOM_SHAPE_TYPES = [
'Holon',
'ObsidianBrowser',
'FathomMeetingsBrowser',
'MeetingIntelligenceBrowser',
'ImageGen',
'VideoGen',
'Multmux',
@ -167,6 +168,7 @@ import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
import { HolonShape } from "@/shapes/HolonShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { MeetingIntelligenceBrowserShape } from "@/shapes/MeetingIntelligenceBrowserShapeUtil"
import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
@ -218,6 +220,7 @@ export function useAutomergeStoreV2({
HolonShape,
ObsidianBrowserShape,
FathomMeetingsBrowserShape,
MeetingIntelligenceBrowserShape,
ImageGenShape,
VideoGenShape,
MultmuxShape,

View File

@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react'
import { useEditor } from 'tldraw'
/** Shape of a meeting from the Meeting Intelligence API */
interface MIMeeting {
id: string
title: string
status: string
duration_seconds?: number
participants?: string[]
created_at: string
has_transcript?: boolean
has_summary?: boolean
speaker_count?: number
}
interface MeetingIntelligencePanelProps {
onClose?: () => void
onMeetingSelect?: (
meeting: MIMeeting,
options: { summary: boolean; transcript: boolean; speakers: boolean },
) => void
shapeMode?: boolean
}
// Meeting Intelligence API base URL
const MI_API_URL = 'https://meets-api.rspace.online'
function formatDuration(seconds?: number): string {
if (!seconds) return '—'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return dateStr
}
}
function statusColor(status: string): string {
switch (status) {
case 'completed': return '#10b981'
case 'recording': return '#ef4444'
case 'transcribing': case 'diarizing': return '#f59e0b'
case 'summarizing': return '#8b5cf6'
default: return '#6b7280'
}
}
export function MeetingIntelligencePanel({ onMeetingSelect }: MeetingIntelligencePanelProps) {
const editor = useEditor()
const [meetings, setMeetings] = useState<MIMeeting[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchMeetings = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`${MI_API_URL}/meetings`)
if (!res.ok) throw new Error(`API returned ${res.status}`)
const data = await res.json() as any
// API may return { meetings: [...] } or [...]
const list = Array.isArray(data) ? data : (data.meetings || [])
setMeetings(list)
} catch (err: any) {
setError(err.message || 'Failed to load meetings')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchMeetings()
}, [])
const panelStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
height: '100%',
fontFamily: 'Inter, -apple-system, sans-serif',
fontSize: '13px',
color: '#1a1a1a',
backgroundColor: '#fff',
}
const headerStyle: React.CSSProperties = {
padding: '12px 16px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}
const listStyle: React.CSSProperties = {
flex: 1,
overflow: 'auto',
padding: '8px',
}
const cardStyle: React.CSSProperties = {
padding: '12px',
marginBottom: '8px',
borderRadius: '8px',
border: '1px solid #e5e7eb',
backgroundColor: '#fafafa',
cursor: 'default',
}
const badgeStyle = (color: string): React.CSSProperties => ({
display: 'inline-block',
padding: '2px 8px',
borderRadius: '9999px',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
backgroundColor: color,
})
const btnStyle = (color: string): React.CSSProperties => ({
padding: '4px 10px',
borderRadius: '6px',
border: 'none',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
backgroundColor: color,
cursor: 'pointer',
transition: 'opacity 0.15s',
})
return (
<div style={panelStyle}>
{/* Header */}
<div style={headerStyle}>
<span style={{ fontWeight: 600, fontSize: '14px' }}>Meeting Intelligence</span>
<button
onClick={fetchMeetings}
onPointerDown={(e) => e.stopPropagation()}
style={{ ...btnStyle('#6b7280'), padding: '4px 8px' }}
title="Refresh"
>
Refresh
</button>
</div>
{/* Content */}
<div style={listStyle}>
{loading && (
<div style={{ textAlign: 'center', padding: '24px', color: '#6b7280' }}>
Loading meetings...
</div>
)}
{error && (
<div style={{ textAlign: 'center', padding: '24px', color: '#ef4444' }}>
{error}
<br />
<button
onClick={fetchMeetings}
onPointerDown={(e) => e.stopPropagation()}
style={{ ...btnStyle('#6b7280'), marginTop: '8px' }}
>
Retry
</button>
</div>
)}
{!loading && !error && meetings.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: '#6b7280' }}>
No meetings found
</div>
)}
{meetings.map((m) => (
<div key={m.id} style={cardStyle}>
{/* Meeting title + status */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
<span style={{ fontWeight: 600, fontSize: '13px', flex: 1 }}>
{m.title || 'Untitled Meeting'}
</span>
<span style={badgeStyle(statusColor(m.status))}>
{m.status}
</span>
</div>
{/* Meta row */}
<div style={{ display: 'flex', gap: '12px', fontSize: '11px', color: '#6b7280', marginBottom: '8px' }}>
<span>{formatDate(m.created_at)}</span>
<span>{formatDuration(m.duration_seconds)}</span>
{m.speaker_count != null && <span>{m.speaker_count} speakers</span>}
</div>
{/* Pull buttons */}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{m.has_summary && (
<button
style={btnStyle('#8b5cf6')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onMeetingSelect?.(m, { summary: true, transcript: false, speakers: false })
}}
>
Summary
</button>
)}
{m.has_transcript && (
<button
style={btnStyle('#2563eb')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onMeetingSelect?.(m, { summary: false, transcript: true, speakers: false })
}}
>
Transcript
</button>
)}
{(m.speaker_count ?? 0) > 0 && (
<button
style={btnStyle('#059669')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onMeetingSelect?.(m, { summary: false, transcript: false, speakers: true })
}}
>
Speakers
</button>
)}
{/* Pull All — only if multiple types available */}
{(m.has_summary || m.has_transcript) && (
<button
style={btnStyle('#1e293b')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onMeetingSelect?.(m, {
summary: !!m.has_summary,
transcript: !!m.has_transcript,
speakers: (m.speaker_count ?? 0) > 0,
})
}}
>
Pull All
</button>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -48,6 +48,7 @@ const SHAPE_DISPLAY_NAMES: Record<string, string> = {
'HolonBrowser': 'holon browser',
'ObsidianBrowser': 'Obsidian browser',
'FathomMeetingsBrowser': 'Fathom browser',
'MeetingIntelligenceBrowser': 'Meeting Intelligence browser',
'FathomNote': 'Fathom note',
'ImageGen': 'AI image',
'VideoGen': 'AI video',

View File

@ -43,6 +43,8 @@ import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
import { MeetingIntelligenceBrowserShape } from "@/shapes/MeetingIntelligenceBrowserShapeUtil"
import { MeetingIntelligenceTool } from "@/tools/MeetingIntelligenceTool"
import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
import { ImageGenTool } from "@/tools/ImageGenTool"
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
@ -162,6 +164,7 @@ const customShapeUtils = [
ObsidianBrowserShape,
FathomMeetingsBrowserShape,
FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser
MeetingIntelligenceBrowserShape, // Self-hosted meeting intelligence browser
ImageGenShape,
VideoGenShape,
BlenderGenShape, // Blender 3D procedural generation
@ -190,6 +193,7 @@ const customTools = [
TranscriptionTool,
HolonTool,
FathomMeetingsTool,
MeetingIntelligenceTool, // Self-hosted meeting intelligence tool
ImageGenTool,
VideoGenTool,
BlenderGenTool, // Blender 3D procedural generation
@ -1269,9 +1273,10 @@ export function Board() {
// Check if any selected shapes are browser shapes that should not be deleted
const selectedShapes = editor.getSelectedShapes();
const hasBrowserShape = selectedShapes.some(shape =>
shape.type === 'ObsidianBrowser' ||
shape.type === 'HolonBrowser' ||
shape.type === 'FathomMeetingsBrowser'
shape.type === 'ObsidianBrowser' ||
shape.type === 'HolonBrowser' ||
shape.type === 'FathomMeetingsBrowser' ||
shape.type === 'MeetingIntelligenceBrowser'
);
// Prevent deletion of browser shapes with Escape

View File

@ -0,0 +1,337 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
createShapeId,
Box,
IndexKey,
TLParentId,
} from "tldraw"
import React, { useState } from "react"
import { MeetingIntelligencePanel } from "../components/MeetingIntelligencePanel"
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
import { usePinnedToView } from "../hooks/usePinnedToView"
import { useMaximize } from "../hooks/useMaximize"
import { FathomNoteShape } from "./FathomNoteShapeUtil"
// Meeting Intelligence API base URL
const MI_API_URL = 'https://meets-api.rspace.online'
type IMeetingIntelligenceBrowser = TLBaseShape<
"MeetingIntelligenceBrowser",
{
w: number
h: number
pinnedToView: boolean
tags: string[]
}
>
export class MeetingIntelligenceBrowserShape extends BaseBoxShapeUtil<IMeetingIntelligenceBrowser> {
static override type = "MeetingIntelligenceBrowser" as const
getDefaultProps(): IMeetingIntelligenceBrowser["props"] {
return {
w: 800,
h: 600,
pinnedToView: false,
tags: ['meeting-intelligence', 'meetings', 'transcription'],
}
}
// Meeting Intelligence theme: purple
static readonly PRIMARY_COLOR = "#8b5cf6"
component(shape: IMeetingIntelligenceBrowser) {
const { w, h } = shape.props
const [isOpen, setIsOpen] = useState(true)
const [isMinimized, setIsMinimized] = useState(false)
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
const { isMaximized, toggleMaximize } = useMaximize({
editor: this.editor,
shapeId: shape.id,
currentW: w,
currentH: h,
shapeType: 'MeetingIntelligenceBrowser',
})
const handleClose = () => {
setIsOpen(false)
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<IMeetingIntelligenceBrowser>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
const handleMeetingSelect = async (
meeting: any,
options: { summary: boolean; transcript: boolean; speakers: boolean },
) => {
try {
const meetingId = meeting.id
if (!meetingId) {
console.error('No meeting ID found:', meeting)
return
}
const meetingTitle = meeting.title || 'Untitled Meeting'
// Helper to format date
const formatDate = (dateStr?: string): string => {
if (!dateStr) return ''
try {
const d = new Date(dateStr)
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
} catch { return '' }
}
const meetingDate = formatDate(meeting.created_at)
// Fetch requested data in parallel
const [summaryRes, transcriptRes, speakersRes]: [any, any, any] = await Promise.all([
options.summary
? fetch(`${MI_API_URL}/meetings/${meetingId}/summary`).then(r => r.ok ? r.json() : null).catch(() => null)
: Promise.resolve(null),
options.transcript
? fetch(`${MI_API_URL}/meetings/${meetingId}/transcript`).then(r => r.ok ? r.json() : null).catch(() => null)
: Promise.resolve(null),
options.speakers
? fetch(`${MI_API_URL}/meetings/${meetingId}/speakers`).then(r => r.ok ? r.json() : null).catch(() => null)
: Promise.resolve(null),
])
// Position notes next to the browser
const browserBounds = this.editor.getShapePageBounds(shape.id)
const spacing = 30
let startX = browserBounds ? browserBounds.x + browserBounds.w + spacing : 0
const startY = browserBounds ? browserBounds.y : 0
const shapeWidth = 500
const shapeHeight = 600
const hSpacing = 20
// Find existing MI shapes for this meeting to avoid overlap
const allShapes = this.editor.getCurrentPageShapes()
const existingForMeeting = allShapes.filter(s => {
if (s.type !== 'FathomNote') return false
const noteId = (s as any).props?.noteId || ''
return noteId.includes(`mi-${meetingId}`)
})
if (existingForMeeting.length > 0) {
let rightmost = startX
existingForMeeting.forEach(s => {
const b = this.editor.getShapePageBounds(s.id)
if (b) rightmost = Math.max(rightmost, b.x + b.w)
})
startX = rightmost + hSpacing
}
let currentX = startX
const shapesToCreate: any[] = []
// Summary note
if (summaryRes) {
const summaryText = summaryRes.summary_text || summaryRes.summary || summaryRes.overview || ''
const actionItems = summaryRes.action_items || []
const decisions = summaryRes.decisions || []
const keyPoints = summaryRes.key_points || []
let content = ''
if (meetingDate) {
content += `<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
<h1 style="margin:0;font-size:18px;font-weight:bold;flex:1;">${meetingTitle}: Summary</h1>
<span style="font-size:11px;color:#666;margin-left:12px;">${meetingDate}</span>
</div>\n\n`
} else {
content += `# ${meetingTitle}: Summary\n\n`
}
if (summaryText) content += `${summaryText}\n\n`
if (keyPoints.length > 0) {
content += `## Key Points\n${keyPoints.map((p: any) => `- ${typeof p === 'string' ? p : p.text || p.point || ''}`).join('\n')}\n\n`
}
if (decisions.length > 0) {
content += `## Decisions\n${decisions.map((d: any) => `- ${typeof d === 'string' ? d : d.text || d.decision || ''}`).join('\n')}\n\n`
}
if (actionItems.length > 0) {
content += `## Action Items\n${actionItems.map((a: any) => `- [ ] ${a.task || a.text || a.description || ''}`).join('\n')}\n\n`
}
shapesToCreate.push({
...FathomNoteShape.createFromData(
{ id: `mi-summary-${meetingId}`, title: 'Meeting Summary', content, tags: ['meeting-intelligence', 'summary'], primaryColor: '#8b5cf6' },
currentX, startY
),
props: {
...FathomNoteShape.createFromData(
{ id: `mi-summary-${meetingId}`, title: 'Meeting Summary', content, tags: ['meeting-intelligence', 'summary'], primaryColor: '#8b5cf6' },
currentX, startY
).props,
w: shapeWidth, h: shapeHeight,
},
})
currentX += shapeWidth + hSpacing
}
// Transcript note
if (transcriptRes) {
const segments = Array.isArray(transcriptRes) ? transcriptRes : (transcriptRes.segments || transcriptRes.transcript || [])
let content = ''
if (meetingDate) {
content += `<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
<h1 style="margin:0;font-size:18px;font-weight:bold;flex:1;">${meetingTitle}: Transcript</h1>
<span style="font-size:11px;color:#666;margin-left:12px;">${meetingDate}</span>
</div>\n\n`
} else {
content += `# ${meetingTitle}: Transcript\n\n`
}
if (segments.length > 0) {
content += segments.map((seg: any) => {
const speaker = seg.speaker_label || seg.speaker || seg.name || 'Unknown'
const text = seg.text || seg.content || ''
const start = seg.start_time != null ? `${Math.floor(seg.start_time / 60)}:${String(Math.floor(seg.start_time % 60)).padStart(2, '0')}` : ''
return start ? `**${speaker}** (${start}): ${text}` : `**${speaker}**: ${text}`
}).join('\n\n')
} else {
content += '*No transcript segments available.*'
}
shapesToCreate.push({
...FathomNoteShape.createFromData(
{ id: `mi-transcript-${meetingId}`, title: 'Meeting Transcript', content, tags: ['meeting-intelligence', 'transcript'], primaryColor: '#2563eb' },
currentX, startY
),
props: {
...FathomNoteShape.createFromData(
{ id: `mi-transcript-${meetingId}`, title: 'Meeting Transcript', content, tags: ['meeting-intelligence', 'transcript'], primaryColor: '#2563eb' },
currentX, startY
).props,
w: shapeWidth, h: shapeHeight,
},
})
currentX += shapeWidth + hSpacing
}
// Speakers note
if (speakersRes) {
const speakers = Array.isArray(speakersRes) ? speakersRes : (speakersRes.speakers || [])
let content = ''
if (meetingDate) {
content += `<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
<h1 style="margin:0;font-size:18px;font-weight:bold;flex:1;">${meetingTitle}: Speakers</h1>
<span style="font-size:11px;color:#666;margin-left:12px;">${meetingDate}</span>
</div>\n\n`
} else {
content += `# ${meetingTitle}: Speakers\n\n`
}
if (speakers.length > 0) {
content += speakers.map((s: any) => {
const name = s.speaker_label || s.name || s.label || 'Unknown'
const time = s.speaking_time || s.speaking_time_seconds || 0
const mins = Math.floor(time / 60)
const secs = Math.floor(time % 60)
const segments = s.segment_count || s.segments || '—'
return `### ${name}\n- Speaking time: ${mins}m ${secs}s\n- Segments: ${segments}`
}).join('\n\n')
} else {
content += '*No speaker data available.*'
}
shapesToCreate.push({
...FathomNoteShape.createFromData(
{ id: `mi-speakers-${meetingId}`, title: 'Meeting Speakers', content, tags: ['meeting-intelligence', 'speakers'], primaryColor: '#059669' },
currentX, startY
),
props: {
...FathomNoteShape.createFromData(
{ id: `mi-speakers-${meetingId}`, title: 'Meeting Speakers', content, tags: ['meeting-intelligence', 'speakers'], primaryColor: '#059669' },
currentX, startY
).props,
w: shapeWidth, h: shapeHeight,
},
})
}
// Create all shapes at once
if (shapesToCreate.length > 0) {
this.editor.createShapes(shapesToCreate)
// Zoom to show results
setTimeout(() => {
const firstBounds = this.editor.getShapePageBounds(shapesToCreate[0].id)
if (firstBounds && browserBounds) {
const combined = Box.Common([browserBounds, firstBounds])
this.editor.zoomToBounds(combined, {
inset: 50,
animation: { duration: 500, easing: (t) => t * (2 - t) },
})
}
this.editor.setSelectedShapes([shapesToCreate[0].id] as any)
this.editor.setCurrentTool('select')
}, 50)
}
} catch (error) {
console.error('Error creating Meeting Intelligence shapes:', error)
}
}
if (!isOpen) return null
return (
<HTMLContainer style={{ width: w, height: h }}>
<StandardizedToolWrapper
title="Meeting Intelligence"
primaryColor={MeetingIntelligenceBrowserShape.PRIMARY_COLOR}
isSelected={isSelected}
width={w}
height={h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IMeetingIntelligenceBrowser>({
id: shape.id,
type: 'MeetingIntelligenceBrowser',
props: { ...shape.props, tags: newTags },
})
}}
tagsEditable={true}
>
<MeetingIntelligencePanel
onClose={handleClose}
onMeetingSelect={handleMeetingSelect}
shapeMode={true}
/>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
indicator(shape: IMeetingIntelligenceBrowser) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

View File

@ -0,0 +1,148 @@
import { StateNode } from "tldraw"
export class MeetingIntelligenceTool extends StateNode {
static override id = "meeting-intelligence"
static override initial = "idle"
static override children = () => [MeetingIntelligenceIdle]
}
export class MeetingIntelligenceIdle extends StateNode {
static override id = "idle"
tooltipElement?: HTMLDivElement
mouseMoveHandler?: (e: MouseEvent) => void
isCreatingShape = false
override onEnter = () => {
this.editor.setCursor({ type: "cross", rotation: 0 })
this.tooltipElement = document.createElement('div')
this.tooltipElement.style.cssText = `
position: fixed;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
white-space: nowrap;
z-index: 10000;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
`
this.tooltipElement.textContent = 'Click anywhere to place Meeting Intelligence browser'
document.body.appendChild(this.tooltipElement)
this.mouseMoveHandler = (e: MouseEvent) => {
if (this.tooltipElement) {
let x = e.clientX + 15
let y = e.clientY - 35
const rect = this.tooltipElement.getBoundingClientRect()
if (x + rect.width > window.innerWidth) x = e.clientX - rect.width - 15
if (y + rect.height > window.innerHeight) y = e.clientY - rect.height - 15
x = Math.max(10, x)
y = Math.max(10, y)
this.tooltipElement.style.left = `${x}px`
this.tooltipElement.style.top = `${y}px`
}
}
document.addEventListener('mousemove', this.mouseMoveHandler)
}
override onPointerDown = (info?: any) => {
if (this.isCreatingShape) return
if (!info || !info.point || info.button === undefined) return
if (info.button !== 0) return
if (info.target && typeof info.target === 'object') {
const target = info.target as HTMLElement
if (target.closest('[data-tldraw-ui]') ||
target.closest('.tlui-menu') ||
target.closest('.tlui-toolbar') ||
target.closest('[role="menu"]') ||
target.closest('[role="toolbar"]')) {
return
}
}
let clickX: number | undefined
let clickY: number | undefined
if (info.point) {
try {
const pagePoint = this.editor.screenToPage(info.point)
clickX = pagePoint.x
clickY = pagePoint.y
} catch (e) {
console.error('MeetingIntelligenceTool: Failed to convert point', e)
}
}
if (clickX === undefined || clickY === undefined) return
if (clickX === 0 && clickY === 0) return
const currentTool = this.editor.getCurrentToolId()
if (currentTool !== 'meeting-intelligence') return
this.createShape(clickX, clickY)
}
override onExit = () => {
this.cleanupTooltip()
this.isCreatingShape = false
}
private cleanupTooltip = () => {
if (this.mouseMoveHandler) {
document.removeEventListener('mousemove', this.mouseMoveHandler)
this.mouseMoveHandler = undefined
}
if (this.tooltipElement) {
document.body.removeChild(this.tooltipElement)
this.tooltipElement = undefined
}
}
private createShape(clickX: number, clickY: number) {
this.isCreatingShape = true
try {
const currentCamera = this.editor.getCamera()
this.editor.stopCameraAnimation()
const shapeWidth = 800
const shapeHeight = 600
const finalX = clickX - shapeWidth / 2
const finalY = clickY - shapeHeight / 2
const browserShape = this.editor.createShape({
type: 'MeetingIntelligenceBrowser',
x: finalX,
y: finalY,
props: { w: shapeWidth, h: shapeHeight },
})
const newCamera = this.editor.getCamera()
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
}
const shapeId = browserShape.id.startsWith('shape:') ? browserShape.id : `shape:${browserShape.id}`
const camBefore = this.editor.getCamera()
this.editor.stopCameraAnimation()
this.editor.setSelectedShapes([shapeId] as any)
this.editor.setCurrentTool('select')
const camAfter = this.editor.getCamera()
if (camBefore.x !== camAfter.x || camBefore.y !== camAfter.y || camBefore.z !== camAfter.z) {
this.editor.setCamera(camBefore, { animation: { duration: 0 } })
}
setTimeout(() => { this.isCreatingShape = false }, 200)
} catch (error) {
console.error('Error creating MeetingIntelligenceBrowser shape:', error)
this.isCreatingShape = false
throw error
}
}
}

View File

@ -31,7 +31,7 @@ export function CustomMainMenu() {
const validateAndNormalizeShapeType = (shape: any): string => {
if (!shape || !shape.type) return 'text'
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'ImageGen', 'VideoGen', 'Multmux', 'FathomNote', 'GoogleItem', 'Map', 'PrivateWorkspace', 'SharedPiano', 'Drawfast', 'MycelialIntelligence']
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'MeetingIntelligenceBrowser', 'ImageGen', 'VideoGen', 'Multmux', 'FathomNote', 'GoogleItem', 'Map', 'PrivateWorkspace', 'SharedPiano', 'Drawfast', 'MycelialIntelligence']
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]

View File

@ -211,6 +211,13 @@ export const overrides: TLUiOverrides = {
// Shape creation is handled manually in FathomMeetingsTool.onPointerDown
onSelect: () => editor.setCurrentTool("fathom-meetings"),
},
MeetingIntelligence: {
id: "meeting-intelligence",
icon: "microphone",
label: "Meeting Intelligence",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("meeting-intelligence"),
},
ImageGen: {
id: "ImageGen",
icon: "image",

View File

@ -32,11 +32,11 @@ export function resolveOverlaps(editor: Editor, shapeId: string): void {
const allShapes = editor.getCurrentPageShapes()
const customShapeTypes = [
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'MeetingIntelligenceBrowser', 'Prompt',
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox',
'ImageGen', 'VideoGen', 'Multmux'
]
const shape = editor.getShape(shapeId as TLShapeId)
if (!shape || !customShapeTypes.includes(shape.type as string)) return
@ -120,11 +120,11 @@ export function findNonOverlappingPosition(
const allShapes = editor.getCurrentPageShapes()
const customShapeTypes = [
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'MeetingIntelligenceBrowser', 'Prompt',
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox',
'ImageGen', 'VideoGen', 'Multmux'
]
const existingShapes = allShapes.filter(
s => !excludeShapeIds.includes(s.id) && customShapeTypes.includes(s.type)
)