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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal file
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user