sync: Focus Panel UX Refresh from private repo
- Three-tab interface: Notes | Desc | Source - Markdown preview with node tokens (react-markdown + remark-gfm) - Formatting toolbar (H1/H2/H3/Bold/Italic) - @mention functionality for linking nodes - Description edit with 280 char limit Run `npm install` to get new dependencies.
This commit is contained in:
@@ -43,6 +43,8 @@
|
|||||||
"pdfjs-dist": "^5.4.296",
|
"pdfjs-dist": "^5.4.296",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"youtube-captions-scraper": "^2.0.3",
|
"youtube-captions-scraper": "^2.0.3",
|
||||||
|
|||||||
+1209
-493
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Bold, Italic, Heading1, Heading2, Heading3 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FormattingToolbarProps {
|
||||||
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
|
value: string;
|
||||||
|
onChange: (newValue: string) => void;
|
||||||
|
inline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormattingToolbar({ textareaRef, value, onChange, inline = false }: FormattingToolbarProps) {
|
||||||
|
const applyFormatting = (
|
||||||
|
type: 'h1' | 'h2' | 'h3' | 'bold' | 'italic'
|
||||||
|
) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = value.substring(start, end);
|
||||||
|
|
||||||
|
let newText = value;
|
||||||
|
let newCursorPos = end;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'h1':
|
||||||
|
case 'h2':
|
||||||
|
case 'h3': {
|
||||||
|
const prefix = type === 'h1' ? '# ' : type === 'h2' ? '## ' : '### ';
|
||||||
|
// Find the start of the current line
|
||||||
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const lineEnd = value.indexOf('\n', end);
|
||||||
|
const actualLineEnd = lineEnd === -1 ? value.length : lineEnd;
|
||||||
|
const currentLine = value.substring(lineStart, actualLineEnd);
|
||||||
|
|
||||||
|
// Check if line already has a heading prefix
|
||||||
|
const headingMatch = currentLine.match(/^(#{1,3})\s/);
|
||||||
|
if (headingMatch) {
|
||||||
|
// Replace existing heading
|
||||||
|
const newLine = prefix + currentLine.replace(/^#{1,3}\s/, '');
|
||||||
|
newText = value.substring(0, lineStart) + newLine + value.substring(actualLineEnd);
|
||||||
|
newCursorPos = lineStart + newLine.length;
|
||||||
|
} else {
|
||||||
|
// Add heading prefix
|
||||||
|
newText = value.substring(0, lineStart) + prefix + value.substring(lineStart);
|
||||||
|
newCursorPos = start + prefix.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'bold': {
|
||||||
|
if (selectedText) {
|
||||||
|
// Wrap selection with **
|
||||||
|
newText = value.substring(0, start) + '**' + selectedText + '**' + value.substring(end);
|
||||||
|
newCursorPos = end + 4;
|
||||||
|
} else {
|
||||||
|
// Insert ** and place cursor in middle
|
||||||
|
newText = value.substring(0, start) + '****' + value.substring(end);
|
||||||
|
newCursorPos = start + 2;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'italic': {
|
||||||
|
if (selectedText) {
|
||||||
|
// Wrap selection with *
|
||||||
|
newText = value.substring(0, start) + '*' + selectedText + '*' + value.substring(end);
|
||||||
|
newCursorPos = end + 2;
|
||||||
|
} else {
|
||||||
|
// Insert ** and place cursor in middle
|
||||||
|
newText = value.substring(0, start) + '**' + value.substring(end);
|
||||||
|
newCursorPos = start + 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(newText);
|
||||||
|
|
||||||
|
// Restore focus and cursor position
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonStyle: React.CSSProperties = {
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #2a2a2a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#888',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.style.background = '#1a1a1a';
|
||||||
|
e.currentTarget.style.color = '#ccc';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
e.currentTarget.style.color = '#888';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
alignItems: 'center',
|
||||||
|
...(inline ? {} : {
|
||||||
|
marginBottom: '8px',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
borderBottom: '1px solid #1a1a1a'
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormatting('h1')}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={buttonStyle}
|
||||||
|
title="Heading 1 (# )"
|
||||||
|
>
|
||||||
|
<Heading1 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormatting('h2')}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={buttonStyle}
|
||||||
|
title="Heading 2 (## )"
|
||||||
|
>
|
||||||
|
<Heading2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormatting('h3')}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={buttonStyle}
|
||||||
|
title="Heading 3 (### )"
|
||||||
|
>
|
||||||
|
<Heading3 size={14} />
|
||||||
|
</button>
|
||||||
|
<div style={{ width: '1px', background: '#2a2a2a', margin: '0 4px' }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormatting('bold')}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={buttonStyle}
|
||||||
|
title="Bold (**text**)"
|
||||||
|
>
|
||||||
|
<Bold size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormatting('italic')}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={buttonStyle}
|
||||||
|
title="Italic (*text*)"
|
||||||
|
>
|
||||||
|
<Italic size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface NodeLabelInlineProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
onNodeClick?: (nodeId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeLabelInline({ id, title, onNodeClick }: NodeLabelInlineProps) {
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onNodeClick) {
|
||||||
|
onNodeClick(parseInt(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxTitleLength = 40;
|
||||||
|
const truncatedTitle = title.length > maxTitleLength
|
||||||
|
? `${title.substring(0, maxTitleLength)}...`
|
||||||
|
: title;
|
||||||
|
const showTooltip = title.length > maxTitleLength;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={handleClick}
|
||||||
|
title={showTooltip ? title : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'inline',
|
||||||
|
cursor: 'pointer',
|
||||||
|
verticalAlign: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#22c55e',
|
||||||
|
color: '#000',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginRight: '4px',
|
||||||
|
lineHeight: '1',
|
||||||
|
verticalAlign: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{id}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
color: '#e5e5e5'
|
||||||
|
}}>
|
||||||
|
{truncatedTitle}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern to match [NODE:id:"title"]
|
||||||
|
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
|
||||||
|
|
||||||
|
interface MarkdownWithNodeTokensProps {
|
||||||
|
content: string;
|
||||||
|
onNodeClick?: (nodeId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarkdownWithNodeTokens({ content, onNodeClick }: MarkdownWithNodeTokensProps) {
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
// Store placeholders and their node data
|
||||||
|
const placeholders: { id: string; title: string }[] = [];
|
||||||
|
|
||||||
|
// Replace node tokens with placeholders before markdown parsing
|
||||||
|
nodePattern.lastIndex = 0;
|
||||||
|
const contentWithPlaceholders = content.replace(nodePattern, (_match, id, title) => {
|
||||||
|
const index = placeholders.length;
|
||||||
|
placeholders.push({ id, title });
|
||||||
|
return `%%NODE_PLACEHOLDER_${index}%%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to process text and replace placeholders with components
|
||||||
|
const processText = (text: string, keyPrefix: string): React.ReactNode => {
|
||||||
|
const placeholderPattern = /%%NODE_PLACEHOLDER_(\d+)%%/g;
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIdx = 0;
|
||||||
|
let m;
|
||||||
|
let matchCount = 0;
|
||||||
|
|
||||||
|
placeholderPattern.lastIndex = 0;
|
||||||
|
|
||||||
|
while ((m = placeholderPattern.exec(text)) !== null) {
|
||||||
|
if (m.index > lastIdx) {
|
||||||
|
parts.push(text.substring(lastIdx, m.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderIndex = parseInt(m[1]);
|
||||||
|
const nodeData = placeholders[placeholderIndex];
|
||||||
|
if (nodeData) {
|
||||||
|
parts.push(
|
||||||
|
<NodeLabelInline
|
||||||
|
key={`${keyPrefix}-node-${nodeData.id}-${matchCount}`}
|
||||||
|
id={nodeData.id}
|
||||||
|
title={nodeData.title}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIdx = m.index + m[0].length;
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIdx < text.length) {
|
||||||
|
parts.push(text.substring(lastIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? <>{parts}</> : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recursively process children to replace placeholders
|
||||||
|
const processChildren = (children: React.ReactNode, keyPrefix: string): React.ReactNode => {
|
||||||
|
return React.Children.map(children, (child, index) => {
|
||||||
|
if (typeof child === 'string') {
|
||||||
|
return processText(child, `${keyPrefix}-${index}`);
|
||||||
|
}
|
||||||
|
if (React.isValidElement(child)) {
|
||||||
|
const childElement = child as React.ReactElement<{ children?: React.ReactNode }>;
|
||||||
|
if (childElement.props.children !== undefined) {
|
||||||
|
return React.cloneElement(childElement, {
|
||||||
|
children: processChildren(childElement.props.children, `${keyPrefix}-${index}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
// Style headings
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 style={{ fontSize: '1.5em', fontWeight: 'bold', marginTop: '16px', marginBottom: '8px', color: '#e5e5e5' }}>
|
||||||
|
{processChildren(children, 'h1')}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 style={{ fontSize: '1.3em', fontWeight: 'bold', marginTop: '14px', marginBottom: '6px', color: '#e5e5e5' }}>
|
||||||
|
{processChildren(children, 'h2')}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 style={{ fontSize: '1.1em', fontWeight: 'bold', marginTop: '12px', marginBottom: '4px', color: '#e5e5e5' }}>
|
||||||
|
{processChildren(children, 'h3')}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
// Style paragraphs
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p style={{ marginTop: '8px', marginBottom: '8px', lineHeight: '1.7' }}>
|
||||||
|
{processChildren(children, 'p')}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
// Style bold/italic
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong style={{ fontWeight: 'bold', color: '#f5f5f5' }}>
|
||||||
|
{processChildren(children, 'strong')}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
|
em: ({ children }) => (
|
||||||
|
<em style={{ fontStyle: 'italic' }}>
|
||||||
|
{processChildren(children, 'em')}
|
||||||
|
</em>
|
||||||
|
),
|
||||||
|
// Style lists
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul style={{ marginLeft: '20px', marginTop: '8px', marginBottom: '8px', listStyleType: 'disc' }}>
|
||||||
|
{processChildren(children, 'ul')}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol style={{ marginLeft: '20px', marginTop: '8px', marginBottom: '8px', listStyleType: 'decimal' }}>
|
||||||
|
{processChildren(children, 'ol')}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => (
|
||||||
|
<li style={{ marginBottom: '4px' }}>
|
||||||
|
{processChildren(children, 'li')}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
// Style links
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: '#22c55e', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{processChildren(children, 'a')}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// Style code
|
||||||
|
code: ({ children, className }) => {
|
||||||
|
const isInline = !className;
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
padding: '2px 4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}>
|
||||||
|
{processChildren(children, 'code')}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code style={{ fontFamily: 'monospace' }}>
|
||||||
|
{processChildren(children, 'code-block')}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'auto',
|
||||||
|
marginTop: '8px',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
// Style blockquotes
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote style={{
|
||||||
|
borderLeft: '3px solid #333',
|
||||||
|
paddingLeft: '12px',
|
||||||
|
marginLeft: '0',
|
||||||
|
marginTop: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: '#999'
|
||||||
|
}}>
|
||||||
|
{processChildren(children, 'blockquote')}
|
||||||
|
</blockquote>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contentWithPlaceholders}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user