canvas-website/src/shapes/ObsNoteShapeUtil.tsx

1348 lines
44 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react'
import { BaseBoxShapeUtil, TLBaseShape, TLShapeId, createShapeId, IndexKey, TLParentId, HTMLContainer } from '@tldraw/tldraw'
import { ObsidianObsNote } from '@/lib/obsidianImporter'
import { QuartzSync, createQuartzNoteFromShape, QuartzSyncConfig } from '@/lib/quartzSync'
import { logGitHubSetupStatus } from '@/lib/githubSetupValidator'
import { getClientConfig } from '@/lib/clientConfig'
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
import { usePinnedToView } from '../hooks/usePinnedToView'
// Auto-resizing textarea component
const AutoResizeTextarea: React.FC<{
value: string
onChange: (value: string) => void
onBlur: () => void
onKeyDown: (e: React.KeyboardEvent) => void
style: React.CSSProperties
placeholder?: string
onPointerDown?: (e: React.PointerEvent) => void
}> = ({ value, onChange, onBlur, onKeyDown, style, placeholder, onPointerDown }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const adjustHeight = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}
}
useEffect(() => {
adjustHeight()
// Focus the textarea when it mounts
if (textareaRef.current) {
textareaRef.current.focus()
}
}, [value])
return (
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
onChange(e.target.value)
adjustHeight()
}}
onBlur={onBlur}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
style={style}
placeholder={placeholder}
rows={1}
autoFocus
/>
)
}
// Main ObsNote component with editable content
const ObsNoteComponent: React.FC<{
shape: IObsNoteShape
shapeUtil: ObsNoteShape
}> = ({ shape, shapeUtil }) => {
const isSelected = shapeUtil.editor.getSelectedShapeIds().includes(shape.id)
const [isEditing, setIsEditing] = useState(shape.props.isEditing)
const [editingContent, setEditingContent] = useState(shape.props.editingContent || shape.props.content)
const [isEditingTitle, setIsEditingTitle] = useState(false)
const [editingTitle, setEditingTitle] = useState(shape.props.title || 'Untitled')
const [isSyncing, setIsSyncing] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const [isCopying, setIsCopying] = useState(false)
// Use the pinning hook to keep the shape fixed to viewport when pinned
usePinnedToView(shapeUtil.editor, shape.id, shape.props.pinnedToView)
// Store the content at the start of editing to revert to on cancel
const [contentAtEditStart, setContentAtEditStart] = useState<string | null>(null)
// Notification state for in-shape notifications
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
// Sync editingContent with shape content when shape changes (but not when editing)
// This ensures the component stays in sync with the shape's content
// Note: We don't sync during editing or immediately after cancel (when contentAtEditStart might be set)
useEffect(() => {
if (!isEditing && contentAtEditStart === null && shape.props.content !== editingContent) {
setEditingContent(shape.props.content)
}
}, [shape.props.content, isEditing, contentAtEditStart, editingContent])
// Auto-hide notification after 3 seconds
useEffect(() => {
if (notification) {
const timer = setTimeout(() => {
setNotification(null)
}, 3000)
return () => clearTimeout(timer)
}
}, [notification])
const titleStyle: React.CSSProperties = {
fontSize: '14px',
fontWeight: 'bold',
color: shape.props.textColor,
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
cursor: isEditingTitle ? 'text' : 'pointer',
padding: '4px 8px',
borderRadius: '4px',
transition: 'background-color 0.2s ease',
}
const tagsStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
marginTop: '4px',
}
const tagStyle: React.CSSProperties = {
backgroundColor: '#007acc',
color: 'white',
padding: '2px 6px',
borderRadius: '12px',
fontSize: '10px',
fontWeight: '500',
}
const contentStyle: React.CSSProperties = {
padding: '12px',
flex: 1,
overflow: 'auto',
color: shape.props.textColor,
fontSize: '12px',
lineHeight: '1.4',
cursor: isEditing ? 'text' : 'pointer',
transition: 'background-color 0.2s ease',
}
const previewStyle: React.CSSProperties = {
height: '100%',
overflow: 'auto',
}
const fullContentStyle: React.CSSProperties = {
height: '100%',
overflow: 'auto',
}
const emptyStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
border: '2px dashed #ddd',
borderRadius: '4px',
padding: '8px',
textAlign: 'center',
}
const textareaStyle: React.CSSProperties = {
width: '100%',
minHeight: '60px',
border: 'none',
outline: 'none',
resize: 'none',
fontFamily: 'inherit',
fontSize: '12px',
lineHeight: '1.4',
color: shape.props.textColor,
backgroundColor: 'transparent',
padding: 0,
margin: 0,
position: 'relative',
zIndex: 1000,
pointerEvents: 'auto',
}
const editControlsStyle: React.CSSProperties = {
display: 'flex',
gap: '8px',
padding: '8px 12px',
backgroundColor: '#f8f9fa',
borderTop: '1px solid #e0e0e0',
position: 'relative',
zIndex: 1000,
pointerEvents: 'auto',
}
const buttonStyle: React.CSSProperties = {
padding: '4px 8px',
fontSize: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
}
const handleStartEdit = () => {
try {
// Capture the current content as the baseline for cancel
// This is the content that exists BEFORE editing starts
const currentContent = shape.props.content || ''
setContentAtEditStart(currentContent)
setIsEditing(true)
setEditingContent(currentContent)
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
isEditing: true,
editingContent: currentContent,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
} catch (error) {
console.error('❌ Error in handleStartEdit:', error)
}
}
const handleStartTitleEdit = () => {
setIsEditingTitle(true)
setEditingTitle(shape.props.title || 'Untitled')
}
const handleSaveTitleEdit = () => {
if (editingTitle.trim() !== shape.props.title) {
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
title: editingTitle.trim(),
isModified: true
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
}
setIsEditingTitle(false)
}
const handleCancelTitleEdit = () => {
setEditingTitle(shape.props.title || 'Untitled')
setIsEditingTitle(false)
}
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveTitleEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelTitleEdit()
}
}
const handleSaveEdit = () => {
try {
const hasChanged = editingContent !== shape.props.originalContent
setIsEditing(false)
setContentAtEditStart(null) // Clear the stored baseline
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
content: editingContent, // Save the edited content
isEditing: false,
editingContent: '',
isModified: hasChanged,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
} catch (error) {
console.error('❌ Error in handleSaveEdit:', error)
}
}
const handleCancelEdit = () => {
try {
// Revert to the content that was there when editing started
// Priority: contentAtEditStart > originalContent > current content
const contentToRevert = contentAtEditStart !== null
? contentAtEditStart
: (shape.props.originalContent || shape.props.content || '')
// Update state first to exit editing mode
setIsEditing(false)
setEditingContent(contentToRevert)
// Update the shape with the reverted content
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
content: contentToRevert, // Revert content to what it was when editing started
isEditing: false,
editingContent: '',
// Reset isModified only if we're reverting all the way back to originalContent
isModified: contentToRevert === shape.props.originalContent ? false : shape.props.isModified,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
// Clear the stored baseline after reverting
setContentAtEditStart(null)
} catch (error) {
console.error('❌ Error in handleCancelEdit:', error)
// Fallback: just exit editing mode
setIsEditing(false)
setContentAtEditStart(null)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
handleSaveEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
const handleRefresh = async () => {
if (isRefreshing) return
setIsRefreshing(true)
try {
const success = await shapeUtil.refreshFromVault(shape.id)
if (success) {
setNotification({ message: '✅ Note restored from vault', type: 'success' })
} else {
setNotification({ message: '❌ Failed to restore note', type: 'error' })
}
} catch (error) {
console.error('❌ Refresh failed:', error)
setNotification({
message: `Refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: 'error'
})
} finally {
setIsRefreshing(false)
}
}
const handleCopy = async () => {
if (isCopying) return
if (!isSelected) {
setNotification({ message: '⚠️ Please select this note to copy', type: 'error' })
return
}
setIsCopying(true)
try {
const contentToCopy = shape.props.content || ''
if (!contentToCopy.trim()) {
setNotification({ message: '⚠️ No content to copy', type: 'error' })
setIsCopying(false)
return
}
await navigator.clipboard.writeText(contentToCopy)
setNotification({ message: '✅ Content copied to clipboard', type: 'success' })
} catch (error) {
console.error('❌ Copy failed:', error)
setNotification({
message: `Copy failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: 'error'
})
} finally {
setIsCopying(false)
}
}
const handleSync = async () => {
if (isSyncing) return
setIsSyncing(true)
try {
// Capture the content to sync BEFORE any state changes
// This ensures we have the absolute latest content
const contentToSync = isEditing ? editingContent : (shape.props.content || '')
const titleToSync = isEditingTitle ? editingTitle : (shape.props.title || 'Untitled')
// If we're editing, save the edit FIRST to ensure the shape has the latest content
if (isEditing) {
// Save the edit synchronously
const hasChanged = editingContent !== shape.props.originalContent
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
content: editingContent, // Save the edited content
isEditing: false,
editingContent: '',
isModified: hasChanged,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
// Update local state
setIsEditing(false)
setContentAtEditStart(null)
}
// If we're editing title, save that too
if (isEditingTitle) {
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
title: editingTitle,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
setIsEditingTitle(false)
}
// Get fresh shape reference for vault info and other properties
const currentShape = shapeUtil.editor.getShape(shape.id) as IObsNoteShape
if (!currentShape) {
throw new Error('Shape not found')
}
// Use the captured contentToSync and titleToSync (which are the latest)
let vaultPath = currentShape.props.vaultPath
let vaultName = currentShape.props.vaultName
if (!vaultPath || !vaultName) {
// Try to get vault info from session if not in shape props
// This is a fallback for existing shapes that don't have vault info
const sessionData = localStorage.getItem('canvas_auth_session')
if (sessionData) {
try {
const session = JSON.parse(sessionData)
if (session.obsidianVaultPath && session.obsidianVaultName) {
// Update the shape with vault info for future syncs
const sanitizedProps = ObsNoteShape.sanitizeProps({
...currentShape.props,
vaultPath: session.obsidianVaultPath,
vaultName: session.obsidianVaultName,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: currentShape.id,
type: 'ObsNote',
props: sanitizedProps
})
// Use the session vault info
vaultPath = session.obsidianVaultPath
vaultName = session.obsidianVaultName
}
} catch (e) {
console.error('Failed to parse session data:', e)
}
}
if (!vaultPath || !vaultName) {
throw new Error('No vault configured for sync. Please configure a vault in settings first.')
}
}
// Determine if this is a Quartz URL or local vault
const isQuartzVault = vaultPath.startsWith('http') || vaultPath.includes('quartz') || vaultPath.includes('.xyz') || vaultPath.includes('.com')
if (isQuartzVault) {
// Use the new Quartz sync system
try {
// Validate GitHub setup first
logGitHubSetupStatus()
// Create Quartz note with the latest content
// Create a temporary shape object with the latest content for sync
const shapeForSync = {
...currentShape,
props: {
...currentShape.props,
content: contentToSync,
title: titleToSync,
}
}
const quartzNote = createQuartzNoteFromShape(shapeForSync)
// Configure Quartz sync
const config = getClientConfig()
const syncConfig: QuartzSyncConfig = {
githubToken: config.githubToken,
githubRepo: config.quartzRepo,
quartzUrl: vaultPath,
cloudflareApiKey: config.cloudflareApiKey,
cloudflareAccountId: config.cloudflareAccountId
}
const quartzSync = new QuartzSync(syncConfig)
// Try smart sync (tries multiple approaches)
const syncSuccess = await quartzSync.smartSync(quartzNote)
if (syncSuccess) {
alert('✅ Note synced to Quartz successfully! Check your GitHub repository for changes.')
} else {
throw new Error('All sync methods failed')
}
} catch (error) {
console.error('❌ Quartz sync failed:', error)
// Fallback to local storage
const quartzStorageKey = `quartz_vault_${vaultName}_${currentShape.props.noteId || titleToSync}`
const tags = currentShape.props.tags || []
const title = titleToSync
const content = contentToSync
const frontmatter = `---
title: "${title}"
tags: [${tags.map(tag => `"${tag.replace('#', '')}"`).join(', ')}]
created: ${new Date().toISOString()}
modified: ${new Date().toISOString()}
quartz_url: "${vaultPath}"
---
${content}`
localStorage.setItem(quartzStorageKey, frontmatter)
alert(`Quartz sync: Stored locally as fallback. Check console for details.`)
}
} else {
// For local vaults, try to write using File System Access API
// Use stored filePath if available to maintain filename consistency
// Otherwise, generate from title or noteId
let fileName: string
if (currentShape.props.filePath && currentShape.props.filePath.trim() !== '') {
// Extract just the filename from the full path
const pathParts = currentShape.props.filePath.split('/')
fileName = pathParts[pathParts.length - 1]
// Ensure it ends with .md
if (!fileName.endsWith('.md')) {
fileName = `${fileName}.md`
}
} else {
// Generate from title or noteId
fileName = `${titleToSync.replace(/[^a-zA-Z0-9]/g, '_')}.md`
}
// Create the markdown content with frontmatter using the latest content
const tags = currentShape.props.tags || []
const title = titleToSync
const content = contentToSync
const frontmatter = `---
title: "${title}"
tags: [${tags.map(tag => `"${tag.replace('#', '')}"`).join(', ')}]
created: ${new Date().toISOString()}
modified: ${new Date().toISOString()}
---
${content}`
try {
// Try to write using File System Access API
if ('showSaveFilePicker' in window) {
const fileHandle = await (window as any).showSaveFilePicker({
suggestedName: fileName,
types: [{
description: 'Markdown files',
accept: { 'text/markdown': ['.md'] }
}]
})
const writable = await fileHandle.createWritable()
await writable.write(frontmatter)
await writable.close()
alert(`Local vault sync: File saved successfully as ${fileName}`)
} else {
// Fallback: download the file
const blob = new Blob([frontmatter], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName // Use the calculated fileName (preserves original filePath if available)
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
alert('Local vault sync: File downloaded! Please save it to your vault folder.')
}
} catch (error) {
console.error('❌ Failed to write local vault file:', error)
// Fallback: store locally and show instructions
const localStorageKey = `local_vault_${vaultName}_${currentShape.props.noteId || titleToSync}`
localStorage.setItem(localStorageKey, frontmatter)
alert(`Local vault sync: Failed to write directly. Content stored locally and logged to console. Key: ${localStorageKey}`)
}
}
// Mark as synced - get fresh shape to ensure we have the latest
const finalShape = shapeUtil.editor.getShape(shape.id) as IObsNoteShape
if (finalShape) {
const sanitizedProps = ObsNoteShape.sanitizeProps({
...finalShape.props,
isModified: false,
originalContent: finalShape.props.content, // Update originalContent to current content after successful sync
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: finalShape.id,
type: 'ObsNote',
props: sanitizedProps
})
}
} catch (error) {
console.error('❌ Sync failed:', error)
alert(`Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
setIsSyncing(false)
}
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handleClose = () => {
shapeUtil.editor.deleteShape(shape.id)
}
const handlePinToggle = () => {
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
// Custom header content with editable title and action buttons
const headerContent = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '8px' }}>
{isEditingTitle ? (
<input
type="text"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onBlur={handleSaveTitleEdit}
onKeyDown={handleTitleKeyDown}
onPointerDown={(e) => e.stopPropagation()}
style={{
...titleStyle,
border: '1px solid #007acc',
borderRadius: '4px',
padding: '4px 8px',
backgroundColor: 'white',
outline: 'none',
fontSize: '14px',
fontWeight: 'bold',
color: shape.props.textColor,
flex: 1,
minWidth: 0,
}}
autoFocus
/>
) : (
<h3
style={{...titleStyle, margin: 0, padding: 0}}
title={shape.props.title}
onClick={(e) => {
e.stopPropagation()
handleStartTitleEdit()
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseEnter={(e) => {
if (!isEditingTitle) {
e.currentTarget.style.backgroundColor = '#f0f0f0'
}
}}
onMouseLeave={(e) => {
if (!isEditingTitle) {
e.currentTarget.style.backgroundColor = 'transparent'
}
}}
>
{shape.props.title}
</h3>
)}
</div>
)
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<StandardizedToolWrapper
title="Obsidian Note"
primaryColor={shape.props.primaryColor || ObsNoteShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
headerContent={headerContent}
editor={shapeUtil.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
tags: newTags,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
}}
tagsEditable={true}
>
<div
style={{
...contentStyle,
backgroundColor: isEditing ? 'transparent' : 'transparent',
position: 'relative',
zIndex: isEditing ? 1000 : 'auto',
pointerEvents: isEditing ? 'auto' : 'auto',
}}
onMouseEnter={(e) => {
if (!isEditing) {
e.currentTarget.style.backgroundColor = '#f8f9fa'
}
}}
onMouseLeave={(e) => {
if (!isEditing) {
e.currentTarget.style.backgroundColor = 'transparent'
}
}}
onClick={(e) => {
if (!isEditing) {
e.stopPropagation()
handleStartEdit()
}
}}
onPointerDown={(e) => {
if (!isEditing) {
e.stopPropagation()
}
}}
onWheel={(e) => {
// Allow mouse wheel scrolling within the obsnote content
e.stopPropagation()
}}
onTouchStart={(e) => {
// Allow touch scrolling within the obsnote content
e.stopPropagation()
}}
onTouchMove={(e) => {
// Allow touch scrolling within the obsnote content
e.stopPropagation()
}}
onTouchEnd={(e) => {
// Allow touch scrolling within the obsnote content
e.stopPropagation()
}}
>
{isEditing ? (
<AutoResizeTextarea
value={editingContent}
onChange={setEditingContent}
onBlur={handleSaveEdit}
onKeyDown={handleKeyDown}
style={textareaStyle}
placeholder="Enter your note content..."
onPointerDown={(e) => e.stopPropagation()}
/>
) : shape.props.content ? (
<div
style={shape.props.showPreview ? previewStyle : fullContentStyle}
onWheel={(e) => {
// Allow mouse wheel scrolling within the content area
e.stopPropagation()
}}
onTouchStart={(e) => {
// Allow touch scrolling within the content area
e.stopPropagation()
}}
onTouchMove={(e) => {
// Allow touch scrolling within the content area
e.stopPropagation()
}}
onTouchEnd={(e) => {
// Allow touch scrolling within the content area
e.stopPropagation()
}}
>
{shape.props.showPreview ? (
<div
dangerouslySetInnerHTML={{
__html: shapeUtil.formatMarkdownPreview(shape.props.content)
}}
onWheel={(e) => {
// Allow mouse wheel scrolling within the preview content
e.stopPropagation()
}}
onTouchStart={(e) => {
// Allow touch scrolling within the preview content
e.stopPropagation()
}}
onTouchMove={(e) => {
// Allow touch scrolling within the preview content
e.stopPropagation()
}}
onTouchEnd={(e) => {
// Allow touch scrolling within the preview content
e.stopPropagation()
}}
/>
) : (
<pre
style={{
margin: 0,
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit'
}}
onWheel={(e) => {
// Allow mouse wheel scrolling within the pre content
e.stopPropagation()
}}
onTouchStart={(e) => {
// Allow touch scrolling within the pre content
e.stopPropagation()
}}
onTouchMove={(e) => {
// Allow touch scrolling within the pre content
e.stopPropagation()
}}
onTouchEnd={(e) => {
// Allow touch scrolling within the pre content
e.stopPropagation()
}}
>
{shape.props.content}
</pre>
)}
</div>
) : (
<div style={emptyStyle}>
Click to add content
</div>
)}
{/* Notification display - positioned at top of content area */}
{notification && (
<>
<style>{`
@keyframes obsNoteNotificationFade {
0% { opacity: 0; transform: translateX(-50%) translateY(-8px); }
10% { opacity: 1; transform: translateX(-50%) translateY(0); }
90% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-8px); }
}
`}</style>
<div
style={{
position: 'absolute',
top: '12px',
left: '50%',
zIndex: 10001,
padding: '8px 16px',
borderRadius: '6px',
fontSize: '12px',
fontWeight: '500',
color: 'white',
backgroundColor: notification.type === 'success' ? '#22c55e' : '#ef4444',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
maxWidth: 'calc(100% - 24px)',
overflow: 'hidden',
textOverflow: 'ellipsis',
animation: 'obsNoteNotificationFade 3s ease-in-out forwards',
}}
>
{notification.message}
</div>
</>
)}
</div>
{/* Bottom action buttons - always visible */}
<div style={{
padding: '8px 12px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'center',
gap: '8px',
marginTop: 'auto',
}}>
{/* Copy button - always clickable */}
<button
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
disabled={isCopying}
style={{
...buttonStyle,
fontSize: '11px',
padding: '6px 12px',
backgroundColor: isCopying ? '#6c757d' : isSelected ? '#004d85' : '#005a9e',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isCopying ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
pointerEvents: 'auto',
opacity: isCopying ? 0.7 : 1,
transition: 'background-color 0.2s ease',
}}
title={!isSelected ? "Select this note to copy" : "Copy transcript content to clipboard"}
>
{isCopying ? '⏳ Copying...' : '📋 Copy'}
</button>
{/* Save changes button - shown when there are modifications or when editing with changes */}
{(() => {
// Check if there are changes: either already modified, or currently editing with different content
const hasChanges = shape.props.isModified ||
(isEditing && editingContent !== (contentAtEditStart !== null ? contentAtEditStart : shape.props.originalContent))
return hasChanges
})() && (
<button
onClick={(e) => {
e.stopPropagation()
// Sync will handle saving if editing
handleSync()
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
disabled={isSyncing}
style={{
...buttonStyle,
fontSize: '11px',
padding: '6px 12px',
backgroundColor: isSyncing ? '#ccc' : '#22c55e',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isSyncing ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
pointerEvents: 'auto',
opacity: isSyncing ? 0.7 : 1,
}}
title="save new content to vault"
>
{isSyncing ? '⏳ Saving...' : '💾 Save changes'}
</button>
)}
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
export type IObsNoteShape = TLBaseShape<
'ObsNote',
{
w: number
h: number
color: string
size: string
font: string
textAlign: string
scale: number
noteId: string
title: string
content: string
tags: string[]
showPreview: boolean
backgroundColor: string
textColor: string
isEditing: boolean
editingContent: string
isModified: boolean
originalContent: string
vaultPath?: string
vaultName?: string
filePath?: string // Original file path from vault - used to maintain filename consistency
pinnedToView: boolean
primaryColor?: string // Optional custom primary color for the header
}
>
export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
static override type = 'ObsNote'
// Obsidian Note theme color: Indigo (similar to ObsidianBrowser)
static readonly PRIMARY_COLOR = "#9333ea"
/**
* Sanitize props to ensure all values are JSON serializable
*/
public static sanitizeProps(props: Partial<IObsNoteShape['props']>): IObsNoteShape['props'] {
// Ensure tags is a proper string array
const tags = Array.isArray(props.tags)
? props.tags.filter(tag => typeof tag === 'string').map(tag => String(tag))
: []
// Build sanitized props object
const sanitized: IObsNoteShape['props'] = {
w: typeof props.w === 'number' ? props.w : 300,
h: typeof props.h === 'number' ? props.h : 200,
color: typeof props.color === 'string' ? props.color : 'black',
size: typeof props.size === 'string' ? props.size : 'm',
font: typeof props.font === 'string' ? props.font : 'sans',
textAlign: typeof props.textAlign === 'string' ? props.textAlign : 'start',
scale: typeof props.scale === 'number' ? props.scale : 1,
noteId: typeof props.noteId === 'string' ? props.noteId : '',
title: typeof props.title === 'string' ? props.title : 'Untitled ObsNote',
content: typeof props.content === 'string' ? props.content : '',
tags,
showPreview: typeof props.showPreview === 'boolean' ? props.showPreview : true,
backgroundColor: typeof props.backgroundColor === 'string' ? props.backgroundColor : '#ffffff',
textColor: typeof props.textColor === 'string' ? props.textColor : '#000000',
isEditing: typeof props.isEditing === 'boolean' ? props.isEditing : false,
editingContent: typeof props.editingContent === 'string' ? props.editingContent : '',
isModified: typeof props.isModified === 'boolean' ? props.isModified : false,
originalContent: typeof props.originalContent === 'string' ? props.originalContent : '',
pinnedToView: typeof props.pinnedToView === 'boolean' ? props.pinnedToView : false,
}
// Only add optional properties if they're defined and are strings
if (props.vaultPath !== undefined && typeof props.vaultPath === 'string') {
sanitized.vaultPath = props.vaultPath
}
if (props.vaultName !== undefined && typeof props.vaultName === 'string') {
sanitized.vaultName = props.vaultName
}
if (props.filePath !== undefined && typeof props.filePath === 'string') {
sanitized.filePath = props.filePath
}
if (props.primaryColor !== undefined && typeof props.primaryColor === 'string') {
sanitized.primaryColor = props.primaryColor
}
return sanitized
}
getDefaultProps(): IObsNoteShape['props'] {
return ObsNoteShape.sanitizeProps({})
}
component(shape: IObsNoteShape) {
return <ObsNoteComponent shape={shape} shapeUtil={this} />
}
indicator(shape: IObsNoteShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
/**
* Format markdown content for preview
* Simplified conversion that avoids extra characters
*/
formatMarkdownPreview(content: string): string {
if (!content) return ''
// Escape HTML first to prevent injection
const escapeHtml = (text: string) => {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Split into lines for line-based processing
const lines = content.split('\n')
const processedLines: string[] = []
for (const line of lines) {
let processed = escapeHtml(line)
// Headers (must be at start of line)
if (processed.match(/^### /)) {
processed = processed.replace(/^### (.*)$/, '<h3 style="font-size: 13px; margin: 0 0 4px 0; font-weight: bold;">$1</h3>')
} else if (processed.match(/^## /)) {
processed = processed.replace(/^## (.*)$/, '<h2 style="font-size: 14px; margin: 0 0 6px 0; font-weight: bold;">$1</h2>')
} else if (processed.match(/^# /)) {
processed = processed.replace(/^# (.*)$/, '<h1 style="font-size: 16px; margin: 0 0 8px 0; font-weight: bold;">$1</h1>')
}
// Lists (only if not already a header)
else if (processed.match(/^- /) && !processed.startsWith('<h')) {
processed = processed.replace(/^- (.*)$/, '<div style="margin: 2px 0;">• $1</div>')
} else if (processed.match(/^\d+\. /) && !processed.startsWith('<h')) {
processed = processed.replace(/^(\d+)\. (.*)$/, '<div style="margin: 2px 0;">$1. $2</div>')
}
// Regular line - apply inline formatting
else {
// Process bold first (before italic to avoid conflicts)
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Then italic (single asterisks that aren't part of bold - use a simple pattern)
// Match *text* but not **text** by checking it's not preceded or followed by *
processed = processed.replace(/\b\*([^*]+?)\*\b/g, '<em>$1</em>')
// Code blocks
processed = processed.replace(/`([^`]+?)`/g, '<code style="background: #f0f0f0; padding: 1px 3px; border-radius: 3px; font-family: monospace;">$1</code>')
// Wikilinks
processed = processed.replace(/\[\[([^\]]+)\]\]/g, '<span style="color: #007acc; text-decoration: underline;">$1</span>')
}
processedLines.push(processed)
}
return processedLines.join('<br>')
}
/**
* Create an obs_note shape from an ObsidianObsNote
*/
static createFromObsidianObsNote(obs_note: ObsidianObsNote, x: number = 0, y: number = 0, id?: TLShapeId, vaultPath?: string, vaultName?: string): IObsNoteShape {
// Use sanitizeProps to ensure all values are JSON serializable
const props = ObsNoteShape.sanitizeProps({
w: 300,
h: 200,
color: 'black',
size: 'm',
font: 'sans',
textAlign: 'start',
scale: 1,
noteId: obs_note.id || '',
title: obs_note.title || 'Untitled',
content: obs_note.content || '',
tags: obs_note.tags || [],
showPreview: true,
backgroundColor: '#ffffff',
textColor: '#000000',
isEditing: false,
editingContent: '',
isModified: false,
originalContent: obs_note.content || '',
vaultPath: vaultPath,
vaultName: vaultName,
filePath: obs_note.filePath,
})
return {
id: id || createShapeId(),
type: 'ObsNote',
x,
y,
rotation: 0,
index: 'a1' as IndexKey,
parentId: 'page:page' as TLParentId,
isLocked: false,
opacity: 1,
meta: {},
typeName: 'shape',
props
}
}
/**
* Update obs_note content
*/
updateObsNoteContent(shapeId: string, content: string) {
const shape = this.editor.getShape(shapeId as TLShapeId) as IObsNoteShape
if (!shape) return
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
content,
})
this.editor.updateShape<IObsNoteShape>({
id: shapeId as TLShapeId,
type: 'ObsNote',
props: sanitizedProps
})
}
/**
* Toggle preview mode
*/
togglePreview(shapeId: string) {
const shape = this.editor.getShape(shapeId as TLShapeId) as IObsNoteShape
if (shape) {
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
showPreview: !shape.props.showPreview,
})
this.editor.updateShape<IObsNoteShape>({
id: shapeId as TLShapeId,
type: 'ObsNote',
props: sanitizedProps
})
}
}
/**
* Update obs_note styling
*/
updateStyling(shapeId: string, backgroundColor: string, textColor: string) {
const shape = this.editor.getShape(shapeId as TLShapeId) as IObsNoteShape
if (!shape) return
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
backgroundColor,
textColor,
})
this.editor.updateShape<IObsNoteShape>({
id: shapeId as TLShapeId,
type: 'ObsNote',
props: sanitizedProps
})
}
/**
* Refresh obs_note content from vault
*/
async refreshFromVault(shapeId: string): Promise<boolean> {
const shape = this.editor.getShape(shapeId as TLShapeId) as IObsNoteShape
if (!shape) return false
try {
const { ObsidianImporter } = await import('@/lib/obsidianImporter')
const importer = new ObsidianImporter()
// Get vault info from shape or session
let vaultPath = shape.props.vaultPath
let vaultName = shape.props.vaultName
if (!vaultPath || !vaultName) {
const sessionData = localStorage.getItem('canvas_auth_session')
if (sessionData) {
const session = JSON.parse(sessionData)
vaultPath = session.obsidianVaultPath
vaultName = session.obsidianVaultName
}
}
if (!vaultPath || !vaultName) {
console.error('No vault configured for refresh')
return false
}
// Load latest content from vault
let vault: any
if (vaultPath.startsWith('http') || vaultPath.includes('quartz') || vaultPath.includes('.xyz') || vaultPath.includes('.com')) {
vault = await importer.importFromQuartzUrl(vaultPath)
} else {
vault = await importer.importFromDirectory(vaultPath)
}
// Find the updated note by ID, preferring notes without quotes in filename
const matchingNotes = vault.obs_notes.filter((note: any) => note.id === shape.props.noteId)
if (matchingNotes.length === 0) {
return false
}
// If there are multiple notes with the same ID (duplicates), prefer the one without quotes
let updatedNote = matchingNotes[0]
if (matchingNotes.length > 1) {
const withoutQuotes = matchingNotes.find((note: any) => !note.filePath?.includes('"'))
if (withoutQuotes) {
updatedNote = withoutQuotes
} else {
// If all have quotes, pick the one with the most content
updatedNote = matchingNotes.reduce((best: any, current: any) =>
current.content?.length > best.content?.length ? current : best
)
}
}
// Sanitize and update the shape with latest content
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
title: updatedNote.title,
content: updatedNote.content,
tags: updatedNote.tags,
originalContent: updatedNote.content,
isModified: false, // Reset modified flag since we're updating from source
// Preserve filePath from updated note if available, otherwise keep existing
filePath: updatedNote.filePath || shape.props.filePath,
})
this.editor.updateShape<IObsNoteShape>({
id: shapeId as TLShapeId,
type: 'ObsNote',
props: sanitizedProps
})
return true
} catch (error) {
console.error('❌ Failed to refresh ObsNote from vault:', error)
return false
}
}
/**
* Refresh all ObsNote shapes on the current page
*/
async refreshAllFromVault(): Promise<{ success: number; failed: number }> {
const allShapes = this.editor.getCurrentPageShapes()
const obsNoteShapes = allShapes.filter(shape => shape.type === 'ObsNote')
let success = 0
let failed = 0
for (const shape of obsNoteShapes) {
const result = await this.refreshFromVault(shape.id)
if (result) {
success++
} else {
failed++
}
}
return { success, failed }
}
}