diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 2a97ec8b..92f43577 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -492,6 +492,7 @@ function App() { const fileLoadRequestIdRef = useRef(0) const initialContentByPathRef = useRef>(new Map()) const recentLocalMarkdownWritesRef = useRef>(new Map()) + const untitledRenameReadyPathsRef = useRef>(new Set()) // Global navigation history (back/forward) across views (chat/file/graph/task) const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] }) @@ -739,6 +740,7 @@ function App() { const removeEditorCacheForPath = useCallback((path: string) => { editorContentByPathRef.current.delete(path) + untitledRenameReadyPathsRef.current.delete(path) setEditorContentByPath((prev) => { if (!(path in prev)) return prev const next = { ...prev } @@ -1019,7 +1021,8 @@ function App() { if (isUntitledPlaceholderName(currentBase)) { const headingTitle = getHeadingTitle(debouncedContent) const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null - if (desiredName && desiredName !== currentBase) { + const shouldAutoRename = untitledRenameReadyPathsRef.current.has(pathAtStart) + if (shouldAutoRename && desiredName && desiredName !== currentBase) { const parentDir = pathAtStart.split('/').slice(0, -1).join('/') let targetPath = `${parentDir}/${desiredName}.md` if (targetPath !== pathAtStart) { @@ -1041,6 +1044,7 @@ function App() { renamedFrom = pathAtStart renamedTo = targetPath editorPathRef.current = targetPath + untitledRenameReadyPathsRef.current.delete(pathAtStart) setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab))) initialContentByPathRef.current.delete(pathAtStart) const cachedContent = editorContentByPathRef.current.get(pathAtStart) @@ -2157,6 +2161,7 @@ function App() { if (closingTab && !isGraphTabPath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) + untitledRenameReadyPathsRef.current.delete(closingTab.path) if (editorPathRef.current === closingTab.path) { editorPathRef.current = null } @@ -2756,6 +2761,7 @@ function App() { parts[parts.length - 1] = finalName const newPath = parts.join('/') await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath }) + untitledRenameReadyPathsRef.current.delete(oldPath) const rewriteForRename = (content: string) => isDir ? content : rewriteWikiLinksForRenamedFileInMarkdown(content, oldPath, newPath) setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab))) @@ -2798,6 +2804,7 @@ function App() { if (path.endsWith('.md')) { removeEditorCacheForPath(path) initialContentByPathRef.current.delete(path) + untitledRenameReadyPathsRef.current.delete(path) } // Close any file tab showing the deleted file const tabForFile = fileTabs.find(t => t.path === path) @@ -3376,6 +3383,10 @@ function App() { { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} + onPrimaryHeadingCommit={() => { + untitledRenameReadyPathsRef.current.add(tab.path) + }} + preserveUntitledTitleHeading={isUntitledPlaceholderName(getBaseName(tab.path))} placeholder="Start writing..." wikiLinks={wikiLinkConfig} onImageUpload={handleImageUpload} diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 6bcaef29..37a827cb 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -1,6 +1,6 @@ import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { Plugin, PluginKey } from '@tiptap/pm/state' -import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' @@ -192,6 +192,8 @@ type WikiLinkConfig = { interface MarkdownEditorProps { content: string onChange: (markdown: string) => void + onPrimaryHeadingCommit?: () => void + preserveUntitledTitleHeading?: boolean placeholder?: string wikiLinks?: WikiLinkConfig onImageUpload?: (file: File) => Promise @@ -278,6 +280,8 @@ const TabIndentExtension = Extension.create({ export function MarkdownEditor({ content, onChange, + onPrimaryHeadingCommit, + preserveUntitledTitleHeading = false, placeholder = 'Start writing...', wikiLinks, onImageUpload, @@ -292,6 +296,7 @@ export function MarkdownEditor({ const [selectionHighlight, setSelectionHighlight] = useState(null) const selectionHighlightRef = useRef(null) const [wikiCommandValue, setWikiCommandValue] = useState('') + const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit) const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {}) @@ -304,6 +309,68 @@ export function MarkdownEditor({ [] ) + useEffect(() => { + onPrimaryHeadingCommitRef.current = onPrimaryHeadingCommit + }, [onPrimaryHeadingCommit]) + + const maybeCommitPrimaryHeading = useCallback((view: EditorView) => { + const onCommit = onPrimaryHeadingCommitRef.current + if (!onCommit) return + const { selection, doc } = view.state + if (!selection.empty) return + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return + if (!['heading', 'paragraph'].includes($from.parent.type.name)) return + + const firstNode = doc.firstChild + if (!firstNode || !['heading', 'paragraph'].includes(firstNode.type.name)) return + + onCommit() + }, []) + + const preventTitleHeadingDemotion = useCallback((view: EditorView, event: KeyboardEvent) => { + if (!preserveUntitledTitleHeading) return false + if (event.key !== 'Backspace' || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false + + const { selection } = view.state + if (!selection.empty) return false + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return false + if ($from.parent.type.name !== 'heading') return false + + const headingLevel = (( + $from.parent.attrs as { level?: number } | null | undefined + )?.level) ?? 0 + if (headingLevel !== 1) return false + if ($from.parentOffset !== 0) return false + if ($from.parent.textContent.length > 0) return false + + event.preventDefault() + return true + }, [preserveUntitledTitleHeading]) + + const promoteFirstParagraphToTitleHeading = useCallback((view: EditorView) => { + if (!preserveUntitledTitleHeading) return + + const { state, dispatch } = view + const { selection } = state + if (!selection.empty) return + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return + if ($from.parent.type.name !== 'paragraph') return + if ($from.parentOffset !== 0) return + if ($from.parent.textContent.length > 0) return + + const headingType = state.schema.nodes.heading + if (!headingType) return + + const tr = state.tr.setNodeMarkup($from.before(1), headingType, { level: 1 }) + dispatch(tr) + }, [preserveUntitledTitleHeading]) + const editor = useEditor({ editable, extensions: [ @@ -359,11 +426,14 @@ export function MarkdownEditor({ markdown = postprocessMarkdown(markdown) onChange(markdown) }, + onBlur: ({ editor }) => { + maybeCommitPrimaryHeading(editor.view) + }, editorProps: { attributes: { class: 'prose prose-sm max-w-none focus:outline-none', }, - handleKeyDown: (_view, event) => { + handleKeyDown: (view, event) => { const state = wikiKeyStateRef.current if (state.open) { if (event.key === 'Escape') { @@ -396,6 +466,25 @@ export function MarkdownEditor({ } } + if (preventTitleHeadingDemotion(view, event)) { + return true + } + + const isPrintableKey = event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey + if (isPrintableKey) { + promoteFirstParagraphToTitleHeading(view) + } + + if ( + event.key === 'Enter' + && !event.shiftKey + && !event.ctrlKey + && !event.metaKey + && !event.altKey + ) { + maybeCommitPrimaryHeading(view) + } + return false }, handleClickOn: (_view, _pos, node, _nodePos, event) => { @@ -407,7 +496,12 @@ export function MarkdownEditor({ return false }, }, - }, [editorSessionKey]) + }, [ + editorSessionKey, + maybeCommitPrimaryHeading, + preventTitleHeadingDemotion, + promoteFirstParagraphToTitleHeading, + ]) const orderedFiles = useMemo(() => { if (!wikiLinks) return []