feat: editor improvements and architectural grid prototype

Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-27 19:45:15 +00:00
parent 2de66a863d
commit f46654f574
99 changed files with 29948 additions and 919 deletions

View File

@@ -1,8 +1,9 @@
'use client'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
import { NoteEditorProvider } from './note-editor-context'
import { NoteEditorFullPage } from './note-editor-full-page'
import { NoteEditorDialog } from './note-editor-dialog'
import { NoteEditorPeekHost } from './note-editor-peek-host'
import { Note } from '@/lib/types'
interface NoteEditorProps {
@@ -16,11 +17,13 @@ interface NoteEditorProps {
export function NoteEditor({ note, readOnly, onClose, fullPage = false, onNoteSaved }: NoteEditorProps) {
return (
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage} onNoteSaved={onNoteSaved}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (
<NoteEditorDialog onClose={onClose} />
)}
<NoteEditorPeekHost noteId={note.id} fullPage={fullPage}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (
<NoteEditorDialog onClose={onClose} />
)}
</NoteEditorPeekHost>
</NoteEditorProvider>
)
}
@@ -35,4 +38,4 @@ export { NoteEditorProvider } from './note-editor-context'
export { NoteTitleBlock } from './note-title-block'
export { NoteContentArea } from './note-content-area'
export { NoteMetadataSection } from './note-metadata-section'
export { NoteEditorToolbar } from './note-editor-toolbar'
export { NoteEditorToolbar } from './note-editor-toolbar'

View File

@@ -107,6 +107,7 @@ export function NoteContentArea() {
className="min-h-[280px]"
onImageUpload={uploadImageFile}
noteId={note.id}
noteTitle={state.title || note.title}
sourceUrl={note.sourceUrl}
/>
</div>
@@ -122,6 +123,7 @@ export function NoteContentArea() {
className="min-h-[200px]"
onImageUpload={uploadImageFile}
noteId={note.id}
noteTitle={state.title || note.title}
sourceUrl={note.sourceUrl}
/>
<GhostTags

View File

@@ -25,14 +25,12 @@ import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
import { MemoryEchoSection } from '@/components/memory-echo-section'
import { useRouter } from 'next/navigation'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const router = useRouter()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const dateLocale = language === 'fr' ? fr : enUS
@@ -66,10 +64,9 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
return (
<>
{/* ── outer container ── */}
<div className="h-screen flex items-stretch overflow-hidden transition-all duration-500">
<div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />

View File

@@ -0,0 +1,90 @@
'use client'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { AnimatePresence } from 'framer-motion'
import { useRouter, useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import type { Note } from '@/lib/types'
import { getNoteById } from '@/app/actions/notes'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import {
NOTE_PEEK_OPEN_EVENT,
NOTE_PEEK_CLOSE_EVENT,
type NotePeekOpenDetail,
} from '@/lib/note-peek-sync'
import { NoteEditorSplitPeek } from './note-editor-split-peek'
import { useLanguage } from '@/lib/i18n'
interface NoteEditorPeekHostProps {
noteId: string
fullPage?: boolean
children: ReactNode
}
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
useEffect(() => {
const onOpenPeek = (event: Event) => {
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
if (!detail?.noteId) return
if (detail.noteId === noteId) return
void getNoteById(detail.noteId).then((fetched) => {
if (fetched) {
setPeekState({ note: fetched, blockId: detail.blockId })
} else {
toast.error(t('notePeek.loadFailed'))
}
})
}
const onClosePeek = () => setPeekState(null)
window.addEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
window.addEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
return () => {
window.removeEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
window.removeEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
}
}, [noteId, t])
const handleClosePeek = useCallback(() => {
setPeekState(null)
}, [])
const handleOpenPeekFully = useCallback(() => {
if (!peekState) return
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'before-peek-full-open' },
}))
const params = new URLSearchParams(searchParams.toString())
params.set('openNote', peekState.note.id)
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
setPeekState(null)
}, [noteId, peekState, router, searchParams])
const shellClass = fullPage
? 'flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden'
: 'relative flex min-h-0 flex-1 flex-col overflow-hidden'
return (
<div className={`${shellClass}${peekState ? (isRtl ? ' flex-row-reverse' : ' flex-row') : ''}`}>
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">{children}</div>
<AnimatePresence initial={false}>
{peekState && (
<NoteEditorSplitPeek
key={peekState.note.id}
note={peekState.note}
blockId={peekState.blockId}
onClose={handleClosePeek}
onOpenFully={handleOpenPeekFully}
/>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import { useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { X, Maximize2 } from 'lucide-react'
import type { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NoteEditorSplitPeekProps {
note: Note
blockId?: string
onClose: () => void
onOpenFully: () => void
}
function PeekEditorBody({ blockId }: { blockId?: string }) {
const { note } = useNoteEditorContext()
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const scrollRootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!blockId) return
const timer = window.setTimeout(() => {
const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(blockId) : blockId
const el = scrollRootRef.current?.querySelector(`[data-id="${escaped}"]`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 450)
return () => window.clearTimeout(timer)
}, [blockId, note.id])
return (
<div ref={scrollRootRef} className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-10 space-y-8 pb-24">
<p
className="text-[10px] uppercase tracking-[.25em] font-bold text-[var(--color-concrete)]"
suppressHydrationWarning
>
{formatAbsoluteDateLocalized(new Date(note.contentUpdatedAt), language, 'MMM d, yyyy', dateLocale)}
</p>
<NoteTitleBlock />
<div className="max-w-xl mx-auto w-full">
<NoteContentArea />
</div>
<p className="text-[11px] text-[var(--color-concrete)] italic">{t('notePeek.readOnlyHint')}</p>
</div>
</div>
)
}
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) {
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
return (
<motion.aside
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'min(50vw, 720px)', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 340, damping: 34 }}
className={`shrink-0 h-full min-h-0 bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden z-40 ${
isRtl
? 'border-r border-black/10 dark:border-white/10 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.12)]'
: 'border-l border-black/10 dark:border-white/10 shadow-[-4px_0_24px_-12px_rgba(0,0,0,0.12)]'
}`}
aria-label={t('notePeek.panelLabel')}
>
<NoteEditorProvider note={note} readOnly fullPage>
<div className="shrink-0 px-4 py-2.5 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-[var(--color-concrete)] truncate">
{t('notePeek.label')}
</span>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onOpenFully}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors"
title={t('notePeek.openFullyHelp')}
>
<Maximize2 size={12} />
{t('notePeek.openFully')}
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-[var(--color-concrete)] hover:text-[var(--color-ink)] hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title={t('notePeek.close')}
aria-label={t('notePeek.close')}
>
<X size={16} />
</button>
</div>
</div>
<PeekEditorBody blockId={blockId} />
</NoteEditorProvider>
</motion.aside>
)
}