668 lines
21 KiB
TypeScript
668 lines
21 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { BaseBoxShapeUtil, TLBaseShape, createShapeId, IndexKey, TLParentId, HTMLContainer } from '@tldraw/tldraw'
|
|
import type { JSX } from 'react'
|
|
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
|
|
import { usePinnedToView } from '../hooks/usePinnedToView'
|
|
import { useMaximize } from '../hooks/useMaximize'
|
|
|
|
export type IFathomNoteShape = TLBaseShape<
|
|
'FathomNote',
|
|
{
|
|
w: number
|
|
h: number
|
|
title: string
|
|
content: string
|
|
tags: string[]
|
|
noteId: string
|
|
pinnedToView: boolean
|
|
primaryColor: string // Blue shade for the header
|
|
}
|
|
>
|
|
|
|
export class FathomNoteShape extends BaseBoxShapeUtil<IFathomNoteShape> {
|
|
static override type = 'FathomNote' as const
|
|
|
|
// Default blue color (can be overridden per shape)
|
|
static readonly PRIMARY_COLOR = "#3b82f6"
|
|
|
|
getDefaultProps(): IFathomNoteShape['props'] {
|
|
return {
|
|
w: 500,
|
|
h: 600,
|
|
title: 'Fathom Note',
|
|
content: '',
|
|
tags: [],
|
|
noteId: '',
|
|
pinnedToView: false,
|
|
primaryColor: FathomNoteShape.PRIMARY_COLOR,
|
|
}
|
|
}
|
|
|
|
component(shape: IFathomNoteShape) {
|
|
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
|
const [isMinimized, setIsMinimized] = useState(false)
|
|
const [isCopied, setIsCopied] = useState(false)
|
|
|
|
// Use the pinning hook
|
|
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
|
|
|
// Use the maximize hook for fullscreen functionality
|
|
const { isMaximized, toggleMaximize } = useMaximize({
|
|
editor: this.editor,
|
|
shapeId: shape.id,
|
|
currentW: shape.props.w,
|
|
currentH: shape.props.h,
|
|
shapeType: 'FathomNote',
|
|
})
|
|
|
|
const handleClose = () => {
|
|
this.editor.deleteShape(shape.id)
|
|
}
|
|
|
|
const handleMinimize = () => {
|
|
setIsMinimized(!isMinimized)
|
|
}
|
|
|
|
const handlePinToggle = () => {
|
|
this.editor.updateShape<IFathomNoteShape>({
|
|
id: shape.id,
|
|
type: 'FathomNote',
|
|
props: {
|
|
...shape.props,
|
|
pinnedToView: !shape.props.pinnedToView,
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
// Extract plain text from content (remove HTML tags and markdown formatting)
|
|
let textToCopy = shape.props.content || ''
|
|
|
|
// Remove HTML tags if present
|
|
const tempDiv = document.createElement('div')
|
|
tempDiv.innerHTML = textToCopy
|
|
textToCopy = tempDiv.textContent || tempDiv.innerText || textToCopy
|
|
|
|
// Clean up markdown formatting for better plain text output
|
|
// Remove markdown headers
|
|
textToCopy = textToCopy.replace(/^#+\s+/gm, '')
|
|
// Remove markdown bold/italic
|
|
textToCopy = textToCopy.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
textToCopy = textToCopy.replace(/\*([^*]+)\*/g, '$1')
|
|
// Remove markdown links but keep text
|
|
textToCopy = textToCopy.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
// Remove markdown code blocks
|
|
textToCopy = textToCopy.replace(/```[\s\S]*?```/g, '')
|
|
// Remove inline code
|
|
textToCopy = textToCopy.replace(/`([^`]+)`/g, '$1')
|
|
|
|
// Clean up extra whitespace
|
|
textToCopy = textToCopy.trim().replace(/\n{3,}/g, '\n\n')
|
|
|
|
if (!textToCopy.trim()) {
|
|
console.warn('No content to copy')
|
|
return
|
|
}
|
|
|
|
await navigator.clipboard.writeText(textToCopy)
|
|
setIsCopied(true)
|
|
setTimeout(() => {
|
|
setIsCopied(false)
|
|
}, 2000)
|
|
} catch (error) {
|
|
console.error('Failed to copy text:', error)
|
|
}
|
|
}
|
|
|
|
const contentStyle: React.CSSProperties = {
|
|
padding: '16px',
|
|
overflow: 'auto',
|
|
flex: 1,
|
|
backgroundColor: '#ffffff',
|
|
color: '#000000',
|
|
fontSize: '13px',
|
|
lineHeight: '1.6',
|
|
fontFamily: 'Inter, sans-serif',
|
|
userSelect: 'text', // Enable text selection
|
|
cursor: 'text', // Show text cursor
|
|
WebkitUserSelect: 'text', // Safari support
|
|
MozUserSelect: 'text', // Firefox support
|
|
msUserSelect: 'text', // IE/Edge support
|
|
}
|
|
|
|
// Format markdown content for display
|
|
const formatContent = (content: string) => {
|
|
if (!content) return null
|
|
|
|
// Check if content starts with HTML (for the header with date)
|
|
if (content.trim().startsWith('<div')) {
|
|
// Find where the HTML div ends
|
|
const divEndIndex = content.indexOf('</div>')
|
|
if (divEndIndex !== -1) {
|
|
const htmlHeader = content.substring(0, divEndIndex + 6) // Include </div>
|
|
const markdownContent = content.substring(divEndIndex + 6).trim()
|
|
|
|
return (
|
|
<>
|
|
<div dangerouslySetInnerHTML={{ __html: htmlHeader }} />
|
|
{markdownContent ? formatMarkdownContent(markdownContent) : null}
|
|
</>
|
|
)
|
|
}
|
|
}
|
|
|
|
return formatMarkdownContent(content)
|
|
}
|
|
|
|
// Format markdown content (extracted to separate function)
|
|
const formatMarkdownContent = (content: string) => {
|
|
const lines = content.split('\n')
|
|
const elements: JSX.Element[] = []
|
|
let i = 0
|
|
let inCodeBlock = false
|
|
let codeBlockLines: string[] = []
|
|
let listItems: string[] = []
|
|
let listType: 'ul' | 'ol' | null = null
|
|
|
|
const processInlineMarkdown = (text: string): (string | JSX.Element)[] => {
|
|
const parts: (string | JSX.Element)[] = []
|
|
let lastIndex = 0
|
|
let keyCounter = 0
|
|
|
|
// Process inline code, links, bold, italic in order of precedence
|
|
// We need to process them in a way that handles overlapping patterns correctly
|
|
// Process bold first, then italic (to avoid conflicts)
|
|
const patterns: Array<{
|
|
regex: RegExp
|
|
render: (...args: any[]) => JSX.Element
|
|
groupCount: number
|
|
}> = [
|
|
{
|
|
regex: /`([^`]+)`/g,
|
|
groupCount: 1,
|
|
render: (code: string, key: number) => (
|
|
<code key={key} style={{
|
|
backgroundColor: '#f4f4f4',
|
|
padding: '2px 4px',
|
|
borderRadius: '3px',
|
|
fontFamily: 'monospace',
|
|
fontSize: '12px'
|
|
}}>{code}</code>
|
|
)
|
|
},
|
|
{
|
|
regex: /\[([^\]]+)\]\(([^)]+)\)/g,
|
|
groupCount: 2,
|
|
render: (linkText: string, url: string, key: number) => (
|
|
<a key={key} href={url} target="_blank" rel="noopener noreferrer" style={{ color: '#2563eb', textDecoration: 'underline' }}>{linkText}</a>
|
|
)
|
|
},
|
|
{
|
|
regex: /\*\*([^*]+)\*\*/g,
|
|
groupCount: 1,
|
|
render: (boldText: string, key: number) => (
|
|
<strong key={key} style={{ fontWeight: 'bold' }}>{boldText}</strong>
|
|
)
|
|
},
|
|
]
|
|
|
|
// Process italic separately after bold to avoid conflicts
|
|
// Match single asterisks that aren't part of double asterisks
|
|
// Use a simpler approach: match *text* where text doesn't contain *
|
|
const italicPattern = /\*([^*\n]+?)\*/g
|
|
|
|
// Find all matches and sort by position
|
|
const matches: Array<{ index: number; length: number; render: () => JSX.Element }> = []
|
|
|
|
patterns.forEach(({ regex, render, groupCount }) => {
|
|
regex.lastIndex = 0
|
|
let match
|
|
while ((match = regex.exec(text)) !== null) {
|
|
const matchKey = keyCounter++
|
|
// Store the match data to avoid closure issues
|
|
const matchIndex = match.index
|
|
const matchLength = match[0].length
|
|
// Extract captured groups immediately and store them
|
|
const matchGroups: string[] = []
|
|
for (let i = 1; i <= groupCount; i++) {
|
|
if (match[i] !== undefined) {
|
|
matchGroups.push(match[i])
|
|
}
|
|
}
|
|
|
|
matches.push({
|
|
index: matchIndex,
|
|
length: matchLength,
|
|
render: () => {
|
|
// Call render with the stored groups and key
|
|
// Safety check: ensure we have the required groups
|
|
if (matchGroups.length < groupCount) {
|
|
return <span key={matchKey}>{text.substring(matchIndex, matchIndex + matchLength)}</span>
|
|
}
|
|
if (groupCount === 1) {
|
|
return render(matchGroups[0], matchKey)
|
|
} else if (groupCount === 2) {
|
|
return render(matchGroups[0], matchGroups[1], matchKey)
|
|
} else {
|
|
return render(...matchGroups, matchKey)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// Process italic separately (after bold to avoid conflicts)
|
|
// First, create a set of positions that are already covered by bold
|
|
const boldPositions = new Set<number>()
|
|
matches.forEach(m => {
|
|
for (let pos = m.index; pos < m.index + m.length; pos++) {
|
|
boldPositions.add(pos)
|
|
}
|
|
})
|
|
|
|
italicPattern.lastIndex = 0
|
|
let italicMatch
|
|
while ((italicMatch = italicPattern.exec(text)) !== null) {
|
|
// Safety check: ensure we have a captured group
|
|
if (!italicMatch[1]) continue
|
|
|
|
// Check if this italic match overlaps with any bold match
|
|
let overlapsBold = false
|
|
for (let pos = italicMatch.index; pos < italicMatch.index + italicMatch[0].length; pos++) {
|
|
if (boldPositions.has(pos)) {
|
|
overlapsBold = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!overlapsBold) {
|
|
const matchKey = keyCounter++
|
|
// Store the italic text to avoid closure issues
|
|
const italicText = italicMatch[1]
|
|
const italicIndex = italicMatch.index
|
|
const italicLength = italicMatch[0].length
|
|
matches.push({
|
|
index: italicIndex,
|
|
length: italicLength,
|
|
render: () => (
|
|
<em key={matchKey} style={{ fontStyle: 'italic' }}>{italicText}</em>
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort matches by position, and remove overlapping matches (keep the first one)
|
|
matches.sort((a, b) => a.index - b.index)
|
|
|
|
// Remove overlapping matches - if two matches overlap, keep the one that starts first
|
|
const nonOverlapping: typeof matches = []
|
|
for (const match of matches) {
|
|
const overlaps = nonOverlapping.some(existing => {
|
|
const existingEnd = existing.index + existing.length
|
|
const matchEnd = match.index + match.length
|
|
return (match.index < existingEnd && matchEnd > existing.index)
|
|
})
|
|
if (!overlaps) {
|
|
nonOverlapping.push(match)
|
|
}
|
|
}
|
|
|
|
// Build parts array
|
|
nonOverlapping.forEach((match) => {
|
|
if (match.index > lastIndex) {
|
|
parts.push(text.substring(lastIndex, match.index))
|
|
}
|
|
parts.push(match.render())
|
|
lastIndex = match.index + match.length
|
|
})
|
|
|
|
if (lastIndex < text.length) {
|
|
parts.push(text.substring(lastIndex))
|
|
}
|
|
|
|
return parts.length > 0 ? parts : [text]
|
|
}
|
|
|
|
const flushList = () => {
|
|
if (listItems.length > 0) {
|
|
const ListTag = listType === 'ol' ? 'ol' : 'ul'
|
|
elements.push(
|
|
<ListTag key={`list-${i}`} style={{ margin: '8px 0', paddingLeft: '24px' }}>
|
|
{listItems.map((item, idx) => (
|
|
<li key={idx} style={{ margin: '4px 0' }}>
|
|
{processInlineMarkdown(item)}
|
|
</li>
|
|
))}
|
|
</ListTag>
|
|
)
|
|
listItems = []
|
|
listType = null
|
|
}
|
|
}
|
|
|
|
const flushCodeBlock = () => {
|
|
if (codeBlockLines.length > 0) {
|
|
elements.push(
|
|
<pre key={`codeblock-${i}`} style={{
|
|
backgroundColor: '#f4f4f4',
|
|
padding: '12px',
|
|
borderRadius: '4px',
|
|
overflow: 'auto',
|
|
margin: '12px 0',
|
|
fontSize: '12px',
|
|
fontFamily: 'monospace',
|
|
lineHeight: '1.5'
|
|
}}>
|
|
<code>{codeBlockLines.join('\n')}</code>
|
|
</pre>
|
|
)
|
|
codeBlockLines = []
|
|
}
|
|
}
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i]
|
|
const trimmed = line.trim()
|
|
|
|
// Code blocks
|
|
if (trimmed.startsWith('```')) {
|
|
if (inCodeBlock) {
|
|
flushCodeBlock()
|
|
inCodeBlock = false
|
|
} else {
|
|
flushList()
|
|
inCodeBlock = true
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if (inCodeBlock) {
|
|
codeBlockLines.push(line)
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Headers
|
|
if (trimmed.startsWith('# ')) {
|
|
flushList()
|
|
flushCodeBlock()
|
|
elements.push(
|
|
<h1 key={i} style={{ fontSize: '20px', fontWeight: 'bold', margin: '16px 0 8px 0' }}>
|
|
{processInlineMarkdown(trimmed.substring(2))}
|
|
</h1>
|
|
)
|
|
i++
|
|
continue
|
|
}
|
|
if (trimmed.startsWith('## ')) {
|
|
flushList()
|
|
flushCodeBlock()
|
|
elements.push(
|
|
<h2 key={i} style={{ fontSize: '18px', fontWeight: 'bold', margin: '12px 0 6px 0' }}>
|
|
{processInlineMarkdown(trimmed.substring(3))}
|
|
</h2>
|
|
)
|
|
i++
|
|
continue
|
|
}
|
|
if (trimmed.startsWith('### ')) {
|
|
flushList()
|
|
flushCodeBlock()
|
|
elements.push(
|
|
<h3 key={i} style={{ fontSize: '16px', fontWeight: 'bold', margin: '10px 0 4px 0' }}>
|
|
{processInlineMarkdown(trimmed.substring(4))}
|
|
</h3>
|
|
)
|
|
i++
|
|
continue
|
|
}
|
|
if (trimmed.startsWith('#### ')) {
|
|
flushList()
|
|
flushCodeBlock()
|
|
elements.push(
|
|
<h4 key={i} style={{ fontSize: '15px', fontWeight: 'bold', margin: '10px 0 4px 0' }}>
|
|
{processInlineMarkdown(trimmed.substring(5))}
|
|
</h4>
|
|
)
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Horizontal rule
|
|
if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
|
|
flushList()
|
|
flushCodeBlock()
|
|
elements.push(
|
|
<hr key={i} style={{ margin: '16px 0', border: 'none', borderTop: '1px solid #e0e0e0' }} />
|
|
)
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Blockquote
|
|
if (trimmed.startsWith('> ')) {
|
|
flushList()
|
|
flushCodeBlock()
|
|
elements.push(
|
|
<blockquote key={i} style={{
|
|
margin: '8px 0',
|
|
paddingLeft: '16px',
|
|
borderLeft: '3px solid #e0e0e0',
|
|
color: '#666',
|
|
fontStyle: 'italic'
|
|
}}>
|
|
{processInlineMarkdown(trimmed.substring(2))}
|
|
</blockquote>
|
|
)
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Unordered list
|
|
if (trimmed.match(/^[-*+]\s/)) {
|
|
flushCodeBlock()
|
|
if (listType !== 'ul') {
|
|
flushList()
|
|
listType = 'ul'
|
|
}
|
|
listItems.push(trimmed.substring(2))
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Ordered list
|
|
if (trimmed.match(/^\d+\.\s/)) {
|
|
flushCodeBlock()
|
|
if (listType !== 'ol') {
|
|
flushList()
|
|
listType = 'ol'
|
|
}
|
|
listItems.push(trimmed.replace(/^\d+\.\s/, ''))
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Empty line
|
|
if (trimmed === '') {
|
|
flushList()
|
|
flushCodeBlock()
|
|
elements.push(<br key={i} />)
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Regular paragraph
|
|
flushList()
|
|
flushCodeBlock()
|
|
const processed = processInlineMarkdown(trimmed)
|
|
elements.push(
|
|
<p key={i} style={{ margin: '8px 0' }}>
|
|
{processed}
|
|
</p>
|
|
)
|
|
i++
|
|
}
|
|
|
|
// Flush any remaining lists or code blocks
|
|
flushList()
|
|
flushCodeBlock()
|
|
|
|
return elements
|
|
}
|
|
|
|
return (
|
|
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
|
<StandardizedToolWrapper
|
|
title={shape.props.title}
|
|
primaryColor={shape.props.primaryColor}
|
|
isSelected={isSelected}
|
|
width={shape.props.w}
|
|
height={shape.props.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<IFathomNoteShape>({
|
|
id: shape.id,
|
|
type: 'FathomNote',
|
|
props: {
|
|
...shape.props,
|
|
tags: newTags,
|
|
}
|
|
})
|
|
}}
|
|
tagsEditable={true}
|
|
>
|
|
<div
|
|
style={contentStyle}
|
|
onPointerDown={(e) => {
|
|
// Allow text selection - don't stop propagation for text selection
|
|
// Only stop if clicking on interactive elements (links, etc.)
|
|
const target = e.target as HTMLElement
|
|
if (target.tagName === 'A' || target.closest('a')) {
|
|
// Let links work normally
|
|
return
|
|
}
|
|
// For text selection, allow the event to bubble but don't prevent default
|
|
// This allows text selection while still allowing shape selection
|
|
}}
|
|
onMouseDown={(e) => {
|
|
// Allow text selection on mouse down
|
|
// Don't prevent default to allow text selection
|
|
const target = e.target as HTMLElement
|
|
if (target.tagName === 'A' || target.closest('a')) {
|
|
return
|
|
}
|
|
}}
|
|
>
|
|
{formatContent(shape.props.content)}
|
|
</div>
|
|
{/* Copy button at bottom right */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleCopy()
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: '8px',
|
|
right: '8px',
|
|
backgroundColor: isCopied ? '#10b981' : 'rgba(0, 0, 0, 0.05)',
|
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
|
borderRadius: '4px',
|
|
padding: '6px',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '28px',
|
|
height: '28px',
|
|
transition: 'background-color 0.2s ease',
|
|
zIndex: 10,
|
|
opacity: 0.8,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isCopied) {
|
|
e.currentTarget.style.opacity = '1'
|
|
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.1)'
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isCopied) {
|
|
e.currentTarget.style.opacity = '0.8'
|
|
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.05)'
|
|
}
|
|
}}
|
|
title={isCopied ? 'Copied!' : 'Copy content to clipboard'}
|
|
>
|
|
{isCopied ? (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</StandardizedToolWrapper>
|
|
</HTMLContainer>
|
|
)
|
|
}
|
|
|
|
indicator(shape: IFathomNoteShape) {
|
|
return <rect width={shape.props.w} height={shape.props.h} />
|
|
}
|
|
|
|
/**
|
|
* Create a Fathom note shape from data
|
|
*/
|
|
static createFromData(
|
|
data: {
|
|
id: string
|
|
title: string
|
|
content: string
|
|
tags: string[]
|
|
primaryColor?: string
|
|
},
|
|
x: number = 0,
|
|
y: number = 0
|
|
): IFathomNoteShape {
|
|
return {
|
|
id: createShapeId(),
|
|
type: 'FathomNote',
|
|
x,
|
|
y,
|
|
rotation: 0,
|
|
index: 'a1' as IndexKey,
|
|
parentId: 'page:page' as TLParentId,
|
|
isLocked: false,
|
|
opacity: 1,
|
|
meta: {},
|
|
typeName: 'shape',
|
|
props: {
|
|
w: 500,
|
|
h: 600,
|
|
title: data.title,
|
|
content: data.content,
|
|
tags: data.tags,
|
|
noteId: data.id,
|
|
pinnedToView: false,
|
|
primaryColor: data.primaryColor || FathomNoteShape.PRIMARY_COLOR,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|