Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/FolderOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const DOC_TYPE_LABELS: Record<DocType, string> = {
source_code: 'Source code',
pdf: 'PDFs',
image: 'Images',
rich_text: 'Rich documents',
};

const formatDocTypeLabel = (docType: DocType) => DOC_TYPE_LABELS[docType] ?? docType.replace(/_/g, ' ');
Expand Down
155 changes: 123 additions & 32 deletions components/PromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import LanguageDropdown from './LanguageDropdown';
import PythonExecutionPanel from './PythonExecutionPanel';
import ScriptExecutionPanel from './ScriptExecutionPanel';
import EmojiPickerOverlay from './EmojiPickerOverlay';
import RichTextEditor, { type RichTextEditorHandle } from './RichTextEditor';
import RichTextDiffView from './RichTextDiffView';

interface DocumentEditorProps {
documentNode: DocumentOrFolder;
Expand Down Expand Up @@ -149,6 +151,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [isCopied, setIsCopied] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>(resolveDefaultViewMode(documentNode.default_view_mode, documentNode.language_hint));
const [splitSize, setSplitSize] = useState(50);
const [editorEngine, setEditorEngine] = useState<'tiptap' | 'monaco'>(documentNode.doc_type === 'rich_text' ? 'tiptap' : 'monaco');
const isLocked = Boolean(documentNode.locked);
const [isLocking, setIsLocking] = useState(false);
const { addLog } = useLogger();
Expand Down Expand Up @@ -197,7 +200,10 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
const titleInputRef = useRef<HTMLInputElement>(null);
const languageButtonRef = useRef<HTMLButtonElement | null>(null);
const isContentInitialized = useRef(false);
const editorRef = useRef<CodeEditorHandle>(null);
const editorRef = useRef<CodeEditorHandle | RichTextEditorHandle | null>(null);
const codeEditorRef = useRef<CodeEditorHandle | null>(null);
const richTextEditorRef = useRef<RichTextEditorHandle | null>(null);
const editorEnginePreferenceRef = useRef<Map<string, 'tiptap' | 'monaco'>>(new Map());
const previewScrollRef = useRef<HTMLDivElement>(null);
const isSyncing = useRef(false);
const syncTimeout = useRef<number | null>(null);
Expand Down Expand Up @@ -239,6 +245,12 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
return () => window.removeEventListener('resize', handleWindowResize);
}, [scriptPanelMinHeight]);

useEffect(() => {
const defaultEngine = documentNode.doc_type === 'rich_text' ? 'tiptap' : 'monaco';
const storedPreference = editorEnginePreferenceRef.current.get(documentNode.id);
setEditorEngine(storedPreference ?? defaultEngine);
}, [documentNode.id, documentNode.doc_type]);

// Keep local editor state in sync with document updates without clobbering unsaved edits.
useEffect(() => {
const nextContent = documentNode.content ?? '';
Expand Down Expand Up @@ -277,6 +289,12 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
}
}, [viewMode, isDiffMode]);

useEffect(() => {
if (isDiffMode) {
editorRef.current = null;
}
}, [isDiffMode]);

useEffect(() => {
const normalizedHint = documentNode.language_hint?.toLowerCase();
if ((normalizedHint === 'pdf' || normalizedHint === 'application/pdf') && !documentNode.default_view_mode && viewMode === 'edit') {
Expand Down Expand Up @@ -472,18 +490,60 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
onDelete(documentNode.id);
};

const isRichDocument = documentNode.doc_type === 'rich_text';
const language = isRichDocument ? 'html' : (documentNode.language_hint || 'plaintext');
const normalizedLanguage = language.toLowerCase();
const supportsAiTools = ['markdown', 'plaintext', 'html'].includes(normalizedLanguage);
const canAddEmojiToTitle = documentNode.type === 'document';
const supportsPreview = isRichDocument || PREVIEWABLE_LANGUAGES.has(normalizedLanguage);
const supportsFormatting = isRichDocument || ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(normalizedLanguage);
const isUsingTiptapEngine = isRichDocument && editorEngine === 'tiptap';
const handleMonacoRef = useCallback((instance: CodeEditorHandle | null) => {
codeEditorRef.current = instance;
editorRef.current = instance;
}, []);
const handleRichTextRef = useCallback((instance: RichTextEditorHandle | null) => {
richTextEditorRef.current = instance;
editorRef.current = instance;
}, []);

const handleViewModeButton = useCallback((newMode: ViewMode) => {
setViewMode(newMode);
onViewModeChange(newMode);
}, [onViewModeChange]);

const handleEditorEngineChange = useCallback((engine: 'tiptap' | 'monaco') => {
if (!isRichDocument) {
return;
}
if (engine === editorEngine) {
return;
}
if (engine === 'monaco' && richTextEditorRef.current) {
const html = richTextEditorRef.current.getHTML();
if (html !== content) {
setContent(html);
}
}
editorEnginePreferenceRef.current.set(documentNode.id, engine);
setEditorEngine(engine);
}, [content, documentNode.id, editorEngine, isRichDocument]);

const handleFormatDocument = () => {
if (isLocked) {
setError('Document is locked and cannot be modified.');
addLog('WARNING', `Format request blocked for locked document "${title}".`);
return;
}
editorRef.current?.format();
const editor = editorRef.current;
if (!editor) {
return;
}
if ('getHTML' in editor) {
(editor as RichTextEditorHandle).format();
} else {
(editor as CodeEditorHandle).format();
}
};

const handleRefine = useCallback(async () => {
Expand Down Expand Up @@ -657,12 +717,6 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
setTimeout(() => setIsCopied(false), 2000);
}, [content, addLog]);

const language = documentNode.language_hint || 'plaintext';
const normalizedLanguage = language.toLowerCase();
const supportsAiTools = ['markdown', 'plaintext'].includes(normalizedLanguage);
const canAddEmojiToTitle = documentNode.type === 'document';
const supportsPreview = PREVIEWABLE_LANGUAGES.has(normalizedLanguage);
const supportsFormatting = ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(normalizedLanguage);
const scriptBridgeAvailable =
typeof window !== 'undefined' && (!!window.electronAPI || !!window.__DOCFORGE_SCRIPT_PREVIEW__);
const isPythonDocument = typeof window !== 'undefined' && !!window.electronAPI && (normalizedLanguage === 'python');
Expand Down Expand Up @@ -820,8 +874,13 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, [onZoomTargetChange]);

const renderContent = () => {
const editor = isDiffMode
? (
let editorElement: React.ReactNode;

if (isDiffMode) {
if (isRichDocument && editorEngine === 'tiptap') {
editorElement = <RichTextDiffView baseline={baselineContent} current={content} />;
} else {
editorElement = (
<MonacoDiffEditor
oldText={baselineContent}
newText={content}
Expand All @@ -836,23 +895,37 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
activeLineHighlightColorDark={settings.editorActiveLineHighlightColorDark}
onFocusChange={handleEditorFocusChange}
/>
)
: (
<MonacoEditor
ref={editorRef}
content={content}
language={language}
onChange={setContent}
onScroll={handleEditorScroll}
customShortcuts={settings.customShortcuts}
fontFamily={settings.editorFontFamily}
fontSize={scaledEditorFontSize}
activeLineHighlightColorLight={settings.editorActiveLineHighlightColor}
activeLineHighlightColorDark={settings.editorActiveLineHighlightColorDark}
readOnly={isLocked}
onFocusChange={handleEditorFocusChange}
/>
);
}
} else if (isUsingTiptapEngine) {
editorElement = (
<RichTextEditor
ref={handleRichTextRef}
content={content}
onChange={setContent}
readOnly={isLocked}
onScroll={handleEditorScroll}
onFocusChange={handleEditorFocusChange}
/>
);
} else {
editorElement = (
<MonacoEditor
ref={handleMonacoRef}
content={content}
language={language}
onChange={setContent}
onScroll={handleEditorScroll}
customShortcuts={settings.customShortcuts}
fontFamily={settings.editorFontFamily}
fontSize={scaledEditorFontSize}
activeLineHighlightColorLight={settings.editorActiveLineHighlightColor}
activeLineHighlightColorDark={settings.editorActiveLineHighlightColorDark}
readOnly={isLocked}
onFocusChange={handleEditorFocusChange}
/>
);
}
const preview = (
<div
className="h-full w-full"
Expand All @@ -877,28 +950,28 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
);

switch(viewMode) {
case 'edit': return editor;
case 'preview': return supportsPreview ? preview : editor;
case 'edit': return editorElement;
case 'preview': return supportsPreview ? preview : editorElement;
case 'split-vertical':
return (
<div ref={splitContainerRef} className="grid h-full" style={{ gridTemplateColumns: `${splitSize}% auto minmax(0, 1fr)` }}>
<div className="h-full overflow-hidden min-w-0">{editor}</div>
<div className="h-full overflow-hidden min-w-0">{editorElement}</div>
<div
onMouseDown={handleSplitterMouseDown}
className="w-1.5 h-full cursor-col-resize flex-shrink-0 bg-border-color/50 hover:bg-primary transition-colors duration-200"
/>
<div className="h-full overflow-hidden min-w-0">{supportsPreview ? preview : editor}</div>
<div className="h-full overflow-hidden min-w-0">{supportsPreview ? preview : editorElement}</div>
</div>
);
case 'split-horizontal':
return (
<div ref={splitContainerRef} className="grid w-full h-full" style={{ gridTemplateRows: `${splitSize}% auto minmax(0, 1fr)` }}>
<div className="w-full overflow-hidden min-h-0">{editor}</div>
<div className="w-full overflow-hidden min-h-0">{editorElement}</div>
<div
onMouseDown={handleSplitterMouseDown}
className="w-full h-1.5 cursor-row-resize flex-shrink-0 bg-border-color/50 hover:bg-primary transition-colors duration-200"
/>
<div className="w-full overflow-hidden min-h-0">{supportsPreview ? preview : editor}</div>
<div className="w-full overflow-hidden min-h-0">{supportsPreview ? preview : editorElement}</div>
</div>
);
}
Expand Down Expand Up @@ -976,6 +1049,24 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({
<IconButton onClick={() => handleViewModeButton('split-horizontal')} tooltip="Split Horizontal" size="xs" className={`rounded-md ${viewMode === 'split-horizontal' ? 'bg-secondary text-primary' : ''}`}><LayoutHorizontalIcon className="w-4 h-4" /></IconButton>
</div>
)}
{isRichDocument && (
<div className="flex items-center p-1 bg-background rounded-lg border border-border-color ml-2">
<button
type="button"
className={`px-2 py-1 text-xs font-semibold rounded-md transition-colors ${isUsingTiptapEngine ? 'bg-secondary text-primary' : 'text-text-secondary hover:text-text-main'}`}
onClick={() => handleEditorEngineChange('tiptap')}
>
Visual
</button>
<button
type="button"
className={`px-2 py-1 text-xs font-semibold rounded-md transition-colors ${!isUsingTiptapEngine ? 'bg-secondary text-primary' : 'text-text-secondary hover:text-text-main'}`}
onClick={() => handleEditorEngineChange('monaco')}
>
Source
</button>
</div>
)}
<div className="h-5 w-px bg-border-color mx-1"></div>
{supportsFormatting && (
<IconButton onClick={handleFormatDocument} tooltip="Format Document" size="xs" variant="ghost" disabled={isLocked || isLocking}>
Expand Down
41 changes: 41 additions & 0 deletions components/RichTextDiffView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React, { useMemo } from 'react';
import { diffWordsWithSpace } from 'diff';

const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');

interface RichTextDiffViewProps {
baseline: string;
current: string;
}

const RichTextDiffView: React.FC<RichTextDiffViewProps> = ({ baseline, current }) => {
const diffMarkup = useMemo(() => {
const parts = diffWordsWithSpace(baseline, current);
return parts.map((part, index) => {
const className = part.added
? 'bg-success/10 text-success'
: part.removed
? 'bg-destructive-bg text-destructive-text'
: 'text-text-main';
return (
<span
key={`${index}-${part.value.length}`}
className={`whitespace-pre-wrap ${className}`}
dangerouslySetInnerHTML={{ __html: escapeHtml(part.value) }}
/>
);
});
}, [baseline, current]);

return (
<div className="h-full w-full overflow-auto bg-background text-sm font-mono px-4 py-3 space-x-1">
{diffMarkup}
</div>
);
};

export default RichTextDiffView;
Loading