canvas-website/src/shapes/ObsNoteShapeUtil.tsx

1182 lines
36 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react'
import { BaseBoxShapeUtil, TLBaseShape, TLShapeId, createShapeId, IndexKey, TLParentId, HTMLContainer } from '@tldraw/tldraw'
import {
MDXEditor,
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
markdownShortcutPlugin,
linkPlugin,
linkDialogPlugin,
imagePlugin,
tablePlugin,
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
toolbarPlugin,
BoldItalicUnderlineToggles,
UndoRedo,
BlockTypeSelect,
CreateLink,
InsertTable,
ListsToggle,
Separator,
DiffSourceToggleWrapper,
type MDXEditorMethods,
} from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'
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'
import { useMaximize } from '../hooks/useMaximize'
// Main ObsNote component with full markdown editing
const ObsNoteComponent: React.FC<{
shape: IObsNoteShape
shapeUtil: ObsNoteShape
}> = ({ shape, shapeUtil }) => {
const isSelected = shapeUtil.editor.getSelectedShapeIds().includes(shape.id)
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)
const editorRef = useRef<MDXEditorMethods>(null)
// Use the pinning hook to keep the shape fixed to viewport when pinned
usePinnedToView(shapeUtil.editor, shape.id, shape.props.pinnedToView)
// Use the maximize hook for fullscreen functionality
const { isMaximized, toggleMaximize } = useMaximize({
editor: shapeUtil.editor,
shapeId: shape.id,
currentW: shape.props.w,
currentH: shape.props.h,
shapeType: 'ObsNote',
})
// Track content changes for sync button visibility
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(shape.props.isModified)
// Notification state for in-shape notifications
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
// Auto-hide notification after 3 seconds
useEffect(() => {
if (notification) {
const timer = setTimeout(() => {
setNotification(null)
}, 3000)
return () => clearTimeout(timer)
}
}, [notification])
// Sync external changes to editor (e.g., from vault refresh)
useEffect(() => {
if (editorRef.current) {
const currentMarkdown = editorRef.current.getMarkdown()
if (currentMarkdown !== shape.props.content) {
editorRef.current.setMarkdown(shape.props.content || '')
}
}
}, [shape.props.content])
// Update hasUnsavedChanges when shape.props.isModified changes
useEffect(() => {
setHasUnsavedChanges(shape.props.isModified)
}, [shape.props.isModified])
const handleContentChange = useCallback((newContent: string) => {
const hasChanged = newContent !== shape.props.originalContent
setHasUnsavedChanges(hasChanged)
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
content: newContent,
isModified: hasChanged,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
}, [shape.id, shape.props, shapeUtil.editor])
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
})
setHasUnsavedChanges(true)
}
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 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' })
setHasUnsavedChanges(false)
} 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
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 {
// Get the current content from the editor
const contentToSync = editorRef.current?.getMarkdown() || shape.props.content || ''
const titleToSync = isEditingTitle ? editingTitle : (shape.props.title || 'Untitled')
// If we're editing title, save that first
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')
}
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
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
})
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 Quartz sync system
try {
logGitHubSetupStatus()
// 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)
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)
const syncSuccess = await quartzSync.smartSync(quartzNote)
if (syncSuccess) {
setNotification({ message: '✅ Note synced to Quartz!', type: 'success' })
} 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 frontmatter = `---
title: "${titleToSync}"
tags: [${tags.map(tag => `"${tag.replace('#', '')}"`).join(', ')}]
created: ${new Date().toISOString()}
modified: ${new Date().toISOString()}
quartz_url: "${vaultPath}"
---
${contentToSync}`
localStorage.setItem(quartzStorageKey, frontmatter)
setNotification({ message: '⚠️ Stored locally as fallback', type: 'error' })
}
} else {
// For local vaults, try to write using File System Access API
let fileName: string
if (currentShape.props.filePath && currentShape.props.filePath.trim() !== '') {
const pathParts = currentShape.props.filePath.split('/')
fileName = pathParts[pathParts.length - 1]
if (!fileName.endsWith('.md')) {
fileName = `${fileName}.md`
}
} else {
fileName = `${titleToSync.replace(/[^a-zA-Z0-9]/g, '_')}.md`
}
const tags = currentShape.props.tags || []
const frontmatter = `---
title: "${titleToSync}"
tags: [${tags.map(tag => `"${tag.replace('#', '')}"`).join(', ')}]
created: ${new Date().toISOString()}
modified: ${new Date().toISOString()}
---
${contentToSync}`
try {
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()
setNotification({ message: `✅ Saved as ${fileName}`, type: 'success' })
} 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
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setNotification({ message: '✅ File downloaded!', type: 'success' })
}
} catch (error) {
console.error('❌ Failed to write local vault file:', error)
const localStorageKey = `local_vault_${vaultName}_${currentShape.props.noteId || titleToSync}`
localStorage.setItem(localStorageKey, frontmatter)
setNotification({ message: '⚠️ Stored locally as fallback', type: 'error' })
}
}
// Mark as synced
const finalShape = shapeUtil.editor.getShape(shape.id) as IObsNoteShape
if (finalShape) {
const sanitizedProps = ObsNoteShape.sanitizeProps({
...finalShape.props,
content: contentToSync,
isModified: false,
originalContent: contentToSync,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: finalShape.id,
type: 'ObsNote',
props: sanitizedProps
})
setHasUnsavedChanges(false)
}
} catch (error) {
console.error('❌ Sync failed:', error)
setNotification({
message: `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
type: '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
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={{
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={{
fontSize: '14px',
fontWeight: 'bold',
color: shape.props.textColor,
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
transition: 'background-color 0.2s ease',
}}
title={shape.props.title}
onClick={(e) => {
e.stopPropagation()
handleStartTitleEdit()
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.2)'
}}
onMouseLeave={(e) => {
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}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
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,
isModified: true,
})
shapeUtil.editor.updateShape<IObsNoteShape>({
id: shape.id,
type: 'ObsNote',
props: sanitizedProps
})
setHasUnsavedChanges(true)
}}
tagsEditable={true}
>
{/* MDXEditor container */}
<div
style={{
width: '100%',
flex: 1,
backgroundColor: '#FFFFFF',
pointerEvents: 'all',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
<MDXEditor
ref={editorRef}
markdown={shape.props.content || ''}
onChange={handleContentChange}
contentEditableClassName="obs-note-editor-content"
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
linkPlugin(),
linkDialogPlugin(),
tablePlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: 'javascript' }),
codeMirrorPlugin({
codeBlockLanguages: {
js: 'JavaScript',
javascript: 'JavaScript',
ts: 'TypeScript',
typescript: 'TypeScript',
jsx: 'JSX',
tsx: 'TSX',
css: 'CSS',
html: 'HTML',
json: 'JSON',
python: 'Python',
py: 'Python',
bash: 'Bash',
sh: 'Shell',
sql: 'SQL',
md: 'Markdown',
yaml: 'YAML',
go: 'Go',
rust: 'Rust',
'': 'Plain Text',
}
}),
imagePlugin({
imageUploadHandler: async () => {
return Promise.resolve('https://via.placeholder.com/400x300')
},
}),
markdownShortcutPlugin(),
diffSourcePlugin({
viewMode: 'rich-text',
diffMarkdown: shape.props.content || '',
}),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<Separator />
<BlockTypeSelect />
<Separator />
<ListsToggle />
<Separator />
<CreateLink />
<InsertTable />
<Separator />
<DiffSourceToggleWrapper>
<></>
</DiffSourceToggleWrapper>
</>
)
}),
]}
/>
{/* Notification display */}
{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: '50px',
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',
animation: 'obsNoteNotificationFade 3s ease-in-out forwards',
}}
>
{notification.message}
</div>
</>
)}
</div>
{/* Bottom action buttons */}
<div style={{
padding: '8px 12px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'center',
gap: '8px',
backgroundColor: '#f9fafb',
flexShrink: 0,
}}>
{/* Refresh from vault button */}
<button
onClick={(e) => {
e.stopPropagation()
handleRefresh()
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={isRefreshing}
style={{
fontSize: '11px',
padding: '6px 12px',
backgroundColor: isRefreshing ? '#6c757d' : '#6366f1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isRefreshing ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
pointerEvents: 'auto',
opacity: isRefreshing ? 0.7 : 1,
}}
title="Refresh content from vault"
>
{isRefreshing ? '⏳ Refreshing...' : '🔄 Refresh'}
</button>
{/* Copy button */}
<button
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={isCopying}
style={{
fontSize: '11px',
padding: '6px 12px',
backgroundColor: isCopying ? '#6c757d' : '#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,
}}
title="Copy content to clipboard"
>
{isCopying ? '⏳ Copying...' : '📋 Copy'}
</button>
{/* Save changes button - shown when there are modifications */}
{hasUnsavedChanges && (
<button
onClick={(e) => {
e.stopPropagation()
handleSync()
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={isSyncing}
style={{
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 changes to vault"
>
{isSyncing ? '⏳ Saving...' : '💾 Save to Vault'}
</button>
)}
</div>
{/* Custom styles for the MDXEditor */}
<style>{`
.mdxeditor {
height: 100%;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.mdxeditor [role="toolbar"] {
flex-shrink: 0;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
padding: 4px 8px;
gap: 2px;
flex-wrap: wrap;
}
.mdxeditor [role="toolbar"] button {
padding: 4px 6px;
border-radius: 4px;
font-size: 12px;
}
.mdxeditor [role="toolbar"] button:hover {
background: #e5e7eb;
}
.mdxeditor [role="toolbar"] button[data-state="on"] {
background: ${ObsNoteShape.PRIMARY_COLOR}20;
color: ${ObsNoteShape.PRIMARY_COLOR};
}
.mdxeditor .mdxeditor-root-contenteditable {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.obs-note-editor-content {
min-height: 100%;
outline: none;
}
.obs-note-editor-content h1 {
font-size: 1.75em;
font-weight: 700;
margin: 0.5em 0 0.25em;
color: #111827;
}
.obs-note-editor-content h2 {
font-size: 1.5em;
font-weight: 600;
margin: 0.5em 0 0.25em;
color: #1f2937;
}
.obs-note-editor-content h3 {
font-size: 1.25em;
font-weight: 600;
margin: 0.5em 0 0.25em;
color: #374151;
}
.obs-note-editor-content p {
margin: 0.5em 0;
line-height: 1.6;
}
.obs-note-editor-content ul, .obs-note-editor-content ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.obs-note-editor-content li {
margin: 0.25em 0;
}
.obs-note-editor-content blockquote {
border-left: 3px solid ${ObsNoteShape.PRIMARY_COLOR};
margin: 0.5em 0;
padding: 0.5em 1em;
background: #f3f4f6;
border-radius: 0 4px 4px 0;
}
.obs-note-editor-content code {
background: #f3f4f6;
padding: 0.15em 0.4em;
border-radius: 3px;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 0.9em;
}
.obs-note-editor-content pre {
background: #1e1e2e;
color: #cdd6f4;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 0.5em 0;
}
.obs-note-editor-content pre code {
background: none;
padding: 0;
color: inherit;
}
.obs-note-editor-content a {
color: ${ObsNoteShape.PRIMARY_COLOR};
text-decoration: underline;
}
.obs-note-editor-content table {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
}
.obs-note-editor-content th, .obs-note-editor-content td {
border: 1px solid #e5e7eb;
padding: 8px 12px;
text-align: left;
}
.obs-note-editor-content th {
background: #f9fafb;
font-weight: 600;
}
.obs-note-editor-content hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1em 0;
}
.obs-note-editor-content img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
/* Source mode styling */
.mdxeditor-source-editor {
flex: 1;
overflow: auto;
}
.mdxeditor-source-editor .cm-editor {
height: 100%;
}
.mdxeditor-source-editor .cm-scroller {
padding: 12px 16px;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
}
/* Block type select */
.mdxeditor [data-radix-popper-content-wrapper] {
z-index: 100000 !important;
}
`}</style>
</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
pinnedToView: boolean
primaryColor?: string
}
>
export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
static override type = 'ObsNote'
// Obsidian Note theme color: Purple (matches Obsidian branding)
static readonly PRIMARY_COLOR = "#9333ea"
/**
* Sanitize props to ensure all values are JSON serializable
*/
public static sanitizeProps(props: Partial<IObsNoteShape['props']>): IObsNoteShape['props'] {
const tags = Array.isArray(props.tags)
? props.tags.filter(tag => typeof tag === 'string').map(tag => String(tag))
: []
const sanitized: IObsNoteShape['props'] = {
w: typeof props.w === 'number' ? props.w : 500,
h: typeof props.h === 'number' ? props.h : 400,
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: tags.length > 0 ? tags : ['obsidian note', 'markdown'],
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,
}
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} />
}
/**
* 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 {
const obsidianTags = obs_note.tags && obs_note.tags.length > 0
? obs_note.tags
: ['obsidian note', 'markdown']
const props = ObsNoteShape.sanitizeProps({
w: 500,
h: 400,
color: 'black',
size: 'm',
font: 'sans',
textAlign: 'start',
scale: 1,
noteId: obs_note.id || '',
title: obs_note.title || 'Untitled',
content: obs_note.content || '',
tags: obsidianTags,
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
})
}
/**
* 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()
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
}
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)
}
const matchingNotes = vault.obs_notes.filter((note: any) => note.id === shape.props.noteId)
if (matchingNotes.length === 0) {
return false
}
let updatedNote = matchingNotes[0]
if (matchingNotes.length > 1) {
const withoutQuotes = matchingNotes.find((note: any) => !note.filePath?.includes('"'))
if (withoutQuotes) {
updatedNote = withoutQuotes
} else {
updatedNote = matchingNotes.reduce((best: any, current: any) =>
current.content?.length > best.content?.length ? current : best
)
}
}
const sanitizedProps = ObsNoteShape.sanitizeProps({
...shape.props,
title: updatedNote.title,
content: updatedNote.content,
tags: updatedNote.tags,
originalContent: updatedNote.content,
isModified: false,
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 }
}
}