3426 lines
137 KiB
TypeScript
3426 lines
137 KiB
TypeScript
import {
|
|
ArrowLeft,
|
|
ListTree,
|
|
Pencil,
|
|
Bot,
|
|
BookOpen,
|
|
CheckCircle2,
|
|
ChevronRight,
|
|
CircleAlert,
|
|
Circle,
|
|
FileText,
|
|
Lightbulb,
|
|
ListTodo,
|
|
MoreHorizontal,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
PanelRightClose,
|
|
PanelRightOpen,
|
|
Pin,
|
|
PinOff,
|
|
Plus,
|
|
Sparkles,
|
|
Trash2
|
|
} from 'lucide-react'
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useId,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
memo
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { InlineHelp } from './InlineHelp'
|
|
import { createPortal } from 'react-dom'
|
|
import type {
|
|
MagicNoteAnalysisOptions,
|
|
MagicNoteEntryAnalysisOptions,
|
|
MagicNoteCommentDirection,
|
|
MagicNoteCommentFormat,
|
|
MagicNoteDraftAnalysis,
|
|
MagicNoteComment,
|
|
MagicNoteDetail,
|
|
MagicNoteEntry,
|
|
MagicNoteContent as NoteContent,
|
|
MagicNoteSummary,
|
|
MagicNoteTag,
|
|
MagicNoteTagRenameResult,
|
|
MagicTodoItem,
|
|
MagicTodoAnalysisOptions
|
|
} from '../../shared/magic-notes-contracts'
|
|
import { magicNoteTagKey } from '../../shared/magic-notes-contracts'
|
|
import {
|
|
MagicNoteActiveTagFilter,
|
|
MagicNoteTagChips,
|
|
MagicNoteTagEditor,
|
|
MagicNoteTagFilter,
|
|
MagicNoteTagManager,
|
|
MagicNoteTagManagerButton
|
|
} from './MagicNoteTags'
|
|
import type { ApplicationSettings, MagicNoteCommentMode } from '../../shared/application-settings-contracts'
|
|
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
|
|
import { MagicNoteContent } from './MagicNoteContent'
|
|
import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource'
|
|
import './magic-notes-panel.css'
|
|
import { MagicTodoDirectory } from './MagicTodoDirectory'
|
|
import { MagicNoteEditor } from './MagicNoteEditor'
|
|
import { MagicCanvasEditor, canvasHasContent, type MagicCanvasEditorHandle } from './MagicCanvasEditor'
|
|
import type { MagicCanvasContentHandle } from './MagicCanvasContent'
|
|
import { MagicCanvasThumbnail } from './MagicCanvasThumbnail'
|
|
import { MarkdownRenderer } from './MarkdownRenderer'
|
|
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
|
import { useGridListWindow } from './use-grid-columns'
|
|
import type { AppNotificationInput } from './notifications'
|
|
import {
|
|
EmptyState,
|
|
DestructiveConfirmActions,
|
|
PageHeader,
|
|
SegmentedControl
|
|
} from './WorkspacePrimitives'
|
|
|
|
export type MagicNotesWorkspaceProps = {
|
|
navigation?: { noteId: string; entryId?: string; requestId: number }
|
|
onOpenSource?: OpenMagicNoteSource
|
|
applicationSettings?: ApplicationSettings
|
|
onNotify: (notification: AppNotificationInput) => void
|
|
onBeforeLeave?: (requester: ((leave: () => void) => void) | undefined) => void
|
|
}
|
|
|
|
type LibraryView = 'notes' | 'todos'
|
|
type TodoFilter = 'active' | 'completed' | 'all'
|
|
type LoadStatus = 'loading' | 'ready' | 'error'
|
|
type ValidationTarget =
|
|
| 'create-note'
|
|
| 'note-title'
|
|
| 'new-entry'
|
|
| 'edit-entry'
|
|
type DraftSwitchTarget =
|
|
| { kind: 'delete-note'; remove: () => void }
|
|
| { kind: 'leave'; leave: () => void }
|
|
| { kind: 'select-entry'; entry: MagicNoteEntry }
|
|
| { kind: 'entry-type'; value: 'text' | 'canvas' }
|
|
| { kind: 'cancel-edit' }
|
|
| { kind: 'cancel-new-entry' }
|
|
| { kind: 'overview' }
|
|
| { kind: 'library-view'; value: LibraryView }
|
|
| { kind: 'create-note'; title: string }
|
|
| { kind: 'edit-entry'; entry: MagicNoteEntry }
|
|
| { kind: 'note'; noteId: string; entryId?: string }
|
|
|
|
const defaultAiPaneWidth = 280
|
|
const minimumAiPaneWidth = 240
|
|
const maximumAiPaneWidth = 520
|
|
const defaultIndexPaneWidth = 168
|
|
const minimumIndexPaneWidth = 140
|
|
const maximumIndexPaneWidth = 320
|
|
const minimumMagicNotesEditorWidth = 300
|
|
const magicNotesResizeHandleWidth = 9
|
|
const magicNotesPaneKeyboardResizeStep = 16
|
|
const magicNotesLayoutStorageKey =
|
|
'goodbuddy.magic-notes-layout.v1'
|
|
|
|
type MagicNotesLayoutPreferences = {
|
|
notesPaneOpen: boolean
|
|
notesPaneWidth: number
|
|
recordIndexOpen: boolean
|
|
indexPaneWidth: number
|
|
aiPanePinned: boolean
|
|
aiPaneWidth: number
|
|
todoPaneWidth: number
|
|
}
|
|
|
|
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
|
|
const defaults = {
|
|
notesPaneOpen: true,
|
|
notesPaneWidth: 200,
|
|
recordIndexOpen: false,
|
|
indexPaneWidth: defaultIndexPaneWidth,
|
|
aiPanePinned: false,
|
|
aiPaneWidth: defaultAiPaneWidth,
|
|
todoPaneWidth: 320
|
|
}
|
|
try {
|
|
const value = localStorage.getItem(magicNotesLayoutStorageKey)
|
|
if (!value || value.length > 10_000) {
|
|
return defaults
|
|
}
|
|
const parsed = JSON.parse(value) as Partial<
|
|
MagicNotesLayoutPreferences
|
|
>
|
|
return {
|
|
notesPaneOpen: parsed.notesPaneOpen !== false,
|
|
notesPaneWidth: typeof parsed.notesPaneWidth === 'number' && Number.isFinite(parsed.notesPaneWidth)
|
|
? Math.min(420, Math.max(160, parsed.notesPaneWidth)) : defaults.notesPaneWidth,
|
|
todoPaneWidth: typeof parsed.todoPaneWidth === 'number' && Number.isFinite(parsed.todoPaneWidth)
|
|
? Math.max(240, parsed.todoPaneWidth) : defaults.todoPaneWidth,
|
|
// Older layouts stored always-open panes; the compact defaults ignore those flags.
|
|
recordIndexOpen: parsed.recordIndexOpen === true,
|
|
indexPaneWidth: typeof parsed.indexPaneWidth === 'number' && Number.isFinite(parsed.indexPaneWidth)
|
|
? Math.min(maximumIndexPaneWidth, Math.max(minimumIndexPaneWidth, parsed.indexPaneWidth))
|
|
: defaults.indexPaneWidth,
|
|
aiPanePinned: parsed.aiPanePinned === true,
|
|
aiPaneWidth:
|
|
typeof parsed.aiPaneWidth === 'number' &&
|
|
Number.isFinite(parsed.aiPaneWidth)
|
|
? Math.min(
|
|
maximumAiPaneWidth,
|
|
Math.max(minimumAiPaneWidth, parsed.aiPaneWidth)
|
|
)
|
|
: defaults.aiPaneWidth
|
|
}
|
|
} catch {
|
|
return defaults
|
|
}
|
|
}
|
|
|
|
function persistMagicNotesLayoutPreferences(
|
|
preferences: MagicNotesLayoutPreferences
|
|
): void {
|
|
try {
|
|
localStorage.setItem(
|
|
magicNotesLayoutStorageKey,
|
|
JSON.stringify(preferences)
|
|
)
|
|
} catch {
|
|
// The in-memory layout remains usable when browser storage is unavailable.
|
|
}
|
|
}
|
|
|
|
type MagicNotesPaneWidthLimits = {
|
|
minimum: number
|
|
maximum: number
|
|
}
|
|
|
|
function getAiPaneWidthLimits(layoutWidth: number, indexWidth: number): MagicNotesPaneWidthLimits {
|
|
return {
|
|
minimum: minimumAiPaneWidth,
|
|
maximum: Math.max(
|
|
minimumAiPaneWidth,
|
|
Math.min(
|
|
maximumAiPaneWidth,
|
|
layoutWidth -
|
|
indexWidth -
|
|
minimumMagicNotesEditorWidth -
|
|
magicNotesResizeHandleWidth
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
function clampMagicNotesPaneWidth(
|
|
width: number,
|
|
limits: MagicNotesPaneWidthLimits
|
|
): number {
|
|
return Math.round(
|
|
Math.min(limits.maximum, Math.max(limits.minimum, width))
|
|
)
|
|
}
|
|
|
|
function noteSummary(note: MagicNoteDetail): MagicNoteSummary {
|
|
return {
|
|
id: note.id,
|
|
title: note.title,
|
|
preview: note.preview,
|
|
entryCount: note.entryCount,
|
|
pinned: note.pinned,
|
|
tags: note.tags ?? [],
|
|
revision: note.revision,
|
|
createdAt: note.createdAt,
|
|
updatedAt: note.updatedAt
|
|
}
|
|
}
|
|
|
|
function hasContent(content?: NoteContent): boolean {
|
|
if (content?.version === 2) return canvasHasContent(content)
|
|
return Boolean(
|
|
content?.ops.some((operation) =>
|
|
operation.attributes?.table !== undefined ||
|
|
(typeof operation.insert === 'string'
|
|
? operation.insert.trim().length > 0
|
|
: true)
|
|
)
|
|
)
|
|
}
|
|
|
|
function richContentEqual(
|
|
left: NoteContent | undefined,
|
|
right: NoteContent | undefined
|
|
): boolean {
|
|
return JSON.stringify(left) === JSON.stringify(right)
|
|
}
|
|
|
|
// Geometry and paper changes do not require another automatic AI comment.
|
|
function analysisContentKey(content: NoteContent): string {
|
|
if (content.version === 1) return JSON.stringify(content)
|
|
return JSON.stringify({
|
|
flow: content.flow?.ops.map((op) => typeof op.insert === 'string'
|
|
? op.insert : 'canvasPageBreak' in op.insert ? '' : JSON.stringify(op.insert)).join('').trim() ?? '',
|
|
pages: content.pages.map((page) => ({
|
|
background: page.background.type === 'pdf' ? page.background : undefined,
|
|
objects: page.objects.map((object) => Object.fromEntries(Object.entries(object).filter(([key]) =>
|
|
!['left', 'top', 'width', 'height', 'scaleX', 'scaleY', 'angle', 'skewX', 'skewY', 'originX', 'originY', 'id'].includes(key)
|
|
)))
|
|
})).filter((page) => page.background || page.objects.length),
|
|
assets: content.assets
|
|
})
|
|
}
|
|
|
|
function errorMessage(error: unknown, fallback: string): string {
|
|
if (typeof error === 'string') {
|
|
return error
|
|
}
|
|
return error instanceof Error ? error.message : fallback
|
|
}
|
|
|
|
function AiComment({
|
|
comment
|
|
}: {
|
|
comment: MagicNoteComment
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('magicNotes')
|
|
const kindLabel =
|
|
comment.kind === 'narrative'
|
|
? t('comments.kinds.narrative')
|
|
: comment.kind === 'warning'
|
|
? t('comments.kinds.warning')
|
|
: comment.kind === 'suggestion'
|
|
? t('comments.kinds.suggestion')
|
|
: t('comments.kinds.summary')
|
|
return (
|
|
<div
|
|
className={`magic-note-comment magic-note-comment--${comment.kind}`}
|
|
>
|
|
<span aria-hidden="true">
|
|
{comment.kind === 'warning' ? (
|
|
<CircleAlert size={15} />
|
|
) : comment.kind === 'suggestion' ? (
|
|
<Lightbulb size={15} />
|
|
) : (
|
|
<Bot size={15} />
|
|
)}
|
|
</span>
|
|
<div>
|
|
<strong>
|
|
{kindLabel}
|
|
</strong>
|
|
{comment.direction && (
|
|
<span className="magic-note-comment__direction">
|
|
{t(`comments.directions.${comment.direction}`)}
|
|
</span>
|
|
)}
|
|
{comment.inputMode && <span className="magic-note-comment__direction">{t(`canvas.inputMode.${comment.inputMode}`)}</span>}
|
|
{comment.kind === 'narrative' ? (
|
|
<div className="magic-note-comment__narrative markdown-content">
|
|
<MarkdownRenderer>{comment.content}</MarkdownRenderer>
|
|
</div>
|
|
) : (
|
|
<p>{comment.content}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TodoListItem({
|
|
disabled,
|
|
expanded,
|
|
id,
|
|
onSelect,
|
|
onToggle,
|
|
todo
|
|
}: {
|
|
disabled: boolean
|
|
expanded: boolean
|
|
id: string
|
|
onSelect: () => void
|
|
onToggle: () => void
|
|
todo: MagicTodoItem
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('magicNotes')
|
|
return (
|
|
<div className={`magic-todo-list-item${todo.completed ? ' magic-todo-list-item--completed' : ''}`}>
|
|
<button
|
|
aria-label={t(
|
|
todo.completed
|
|
? 'todos.markIncomplete'
|
|
: 'todos.markComplete',
|
|
{ title: todo.title }
|
|
)}
|
|
aria-pressed={todo.completed}
|
|
className="magic-todo-list-item__check"
|
|
disabled={disabled}
|
|
onClick={onToggle}
|
|
type="button"
|
|
>
|
|
{todo.completed ? (
|
|
<CheckCircle2 size={16} />
|
|
) : (
|
|
<Circle size={16} />
|
|
)}
|
|
</button>
|
|
<button
|
|
aria-controls={expanded ? `magic-todo-detail-${todo.id}` : undefined}
|
|
aria-expanded={expanded}
|
|
className="magic-todo-list-item__content"
|
|
disabled={disabled}
|
|
id={id}
|
|
onClick={onSelect}
|
|
type="button"
|
|
>
|
|
<strong>{todo.title}</strong>
|
|
<small>{t('todos.sourceNote', { title: todo.noteTitle })}</small>
|
|
<ChevronRight aria-hidden="true" size={16} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MagicNotesWorkspaceView({
|
|
navigation,
|
|
onOpenSource,
|
|
onNotify,
|
|
onBeforeLeave,
|
|
applicationSettings
|
|
}: MagicNotesWorkspaceProps): React.JSX.Element {
|
|
const { i18n, t } = useTranslation('magicNotes')
|
|
const tRef = useRef(t)
|
|
useEffect(() => {
|
|
tRef.current = t
|
|
}, [t])
|
|
const currentLocale = i18n.resolvedLanguage || i18n.language
|
|
const todoFilters = useMemo<
|
|
ReadonlyArray<{ value: TodoFilter; label: string }>
|
|
>(
|
|
() => [
|
|
{ value: 'all', label: t('todos.filters.all') },
|
|
{ value: 'active', label: t('todos.filters.active') },
|
|
{ value: 'completed', label: t('todos.filters.completed') }
|
|
],
|
|
[t]
|
|
)
|
|
const commentDirections = useMemo<
|
|
ReadonlyArray<{
|
|
value: MagicNoteCommentDirection
|
|
label: string
|
|
}>
|
|
>(
|
|
() => [
|
|
{ value: 'general', label: t('comments.directions.general') },
|
|
{ value: 'expand', label: t('comments.directions.expand') },
|
|
{ value: 'polish', label: t('comments.directions.polish') },
|
|
{
|
|
value: 'challenge',
|
|
label: t('comments.directions.challenge')
|
|
},
|
|
{
|
|
value: 'brainstorm',
|
|
label: t('comments.directions.brainstorm')
|
|
}
|
|
],
|
|
[t]
|
|
)
|
|
const commentDirectionLabels = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
commentDirections.map((direction) => [
|
|
direction.value,
|
|
direction.label
|
|
])
|
|
) as Record<MagicNoteCommentDirection, string>,
|
|
[commentDirections]
|
|
)
|
|
const dateFormatter = useMemo(
|
|
() =>
|
|
new Intl.DateTimeFormat(
|
|
currentLocale,
|
|
{
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}
|
|
),
|
|
[currentLocale]
|
|
)
|
|
// Compact list dates: time for today, month/day otherwise.
|
|
const relativeDate = useMemo(() => {
|
|
const time = new Intl.DateTimeFormat(currentLocale, { hour: '2-digit', minute: '2-digit' })
|
|
const day = new Intl.DateTimeFormat(currentLocale, { month: 'numeric', day: 'numeric' })
|
|
return (value: string): string => {
|
|
const date = new Date(value)
|
|
return date.toDateString() === new Date().toDateString() ? time.format(date) : day.format(date)
|
|
}
|
|
}, [currentLocale])
|
|
const [notes, setNotes] = useState<MagicNoteSummary[]>([])
|
|
const [noteTags, setNoteTags] = useState<MagicNoteTag[]>([])
|
|
// Selected tag keys; a note must carry every one of them.
|
|
const [tagFilter, setTagFilter] = useState<string[]>([])
|
|
const [tagManagerOpen, setTagManagerOpen] = useState(false)
|
|
const [todos, setTodos] = useState<MagicTodoItem[]>([])
|
|
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
|
const [detailView, setDetailView] = useState<'notes'>()
|
|
const overviewFocusRef = useRef('')
|
|
// The note whose card takes focus back on returning to the overview; the
|
|
// windowed note list keeps that card mounted.
|
|
const [overviewReturnNoteId, setOverviewReturnNoteId] = useState('')
|
|
const noteGridRef = useRef<HTMLDivElement>(null)
|
|
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
|
|
const [loadedCommentMode, setCommentMode] =
|
|
useState<MagicNoteCommentMode>('immediate')
|
|
const [commentDirection, setCommentDirection] =
|
|
useState<MagicNoteCommentDirection>('general')
|
|
const [loadedCommentFormat, setCommentFormat] =
|
|
useState<MagicNoteCommentFormat>('combined')
|
|
const commentMode = applicationSettings?.magicNoteCommentMode ?? loadedCommentMode
|
|
const commentFormat = applicationSettings?.magicNoteCommentFormat ?? loadedCommentFormat
|
|
const [selectedNoteId, setSelectedNoteId] = useState('')
|
|
const [selectedTodoId, setSelectedTodoId] = useState('')
|
|
const [detail, setDetail] = useState<MagicNoteDetail>()
|
|
const [todoSourceDetail, setTodoSourceDetail] =
|
|
useState<MagicNoteDetail>()
|
|
const [todoSourceStatus, setTodoSourceStatus] = useState<LoadStatus>('loading')
|
|
const [todoSourceRetry, setTodoSourceRetry] = useState(0)
|
|
const [loadStatus, setLoadStatus] = useState<LoadStatus>('loading')
|
|
const [loadError, setLoadError] = useState('')
|
|
const [refreshError, setRefreshError] = useState('')
|
|
const [detailLoadError, setDetailLoadError] = useState<{
|
|
message: string
|
|
noteId: string
|
|
}>()
|
|
const [busy, setBusy] = useState('')
|
|
const [search, setSearch] = useState('')
|
|
const [creating, setCreating] = useState(false)
|
|
const [newTitle, setNewTitle] = useState('')
|
|
const [titleDraft, setTitleDraft] = useState('')
|
|
const [deletingNote, setDeletingNote] = useState(false)
|
|
const [noteActionsId, setNoteActionsId] = useState('')
|
|
const noteActionsRef = useRef<HTMLDivElement>(null)
|
|
const noteActionTriggerRef = useRef<HTMLButtonElement | null>(null)
|
|
const [composerKey, setComposerKey] = useState(0)
|
|
const [entryType, setEntryType] = useState<'text' | 'canvas'>('text')
|
|
const composerCanvasRef = useRef<MagicCanvasEditorHandle>(null)
|
|
const editingCanvasRef = useRef<MagicCanvasEditorHandle>(null)
|
|
const canvasViewRefs = useRef(new Map<string, MagicCanvasContentHandle>())
|
|
const todoCanvasRef = useRef<MagicCanvasContentHandle>(null)
|
|
const [editingEntry, setEditingEntry] = useState<MagicNoteEntry>()
|
|
const [selectedEntryId, setSelectedEntryId] = useState('')
|
|
const [deletingEntryId, setDeletingEntryId] = useState('')
|
|
const initialLayoutPreferences = useMemo(
|
|
() => loadMagicNotesLayoutPreferences(),
|
|
[]
|
|
)
|
|
// AI comments open automatically when the note has comment activity; pinning keeps them open.
|
|
const [aiPanePinned, setAiPanePinned] = useState(initialLayoutPreferences.aiPanePinned)
|
|
const [aiDismissedNoteId, setAiDismissedNoteId] = useState('')
|
|
const [aiPaneWidth, setAiPaneWidth] = useState(
|
|
initialLayoutPreferences.aiPaneWidth
|
|
)
|
|
const [indexPaneOpen, setIndexPaneOpen] = useState(initialLayoutPreferences.recordIndexOpen)
|
|
const [composerHasDraft, setComposerHasDraft] = useState(false)
|
|
const [indexPaneWidth, setIndexPaneWidth] = useState(initialLayoutPreferences.indexPaneWidth)
|
|
const [todoPaneWidth, setTodoPaneWidth] = useState(initialLayoutPreferences.todoPaneWidth)
|
|
const [notesPaneOpen, setNotesPaneOpen] = useState(initialLayoutPreferences.notesPaneOpen)
|
|
const [narrowNotesOpen, setNarrowNotesOpen] = useState(false)
|
|
const [notesPaneWidth, setNotesPaneWidth] = useState(initialLayoutPreferences.notesPaneWidth)
|
|
const [workspaceWidth, setWorkspaceWidth] = useState(window.innerWidth)
|
|
const [todoLayoutWidth, setTodoLayoutWidth] = useState(window.innerWidth)
|
|
const [narrowIndexOpen, setNarrowIndexOpen] = useState(false)
|
|
const [resizingPane, setResizingPane] = useState<'ai' | 'index' | 'todo' | 'notes'>()
|
|
const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState(
|
|
window.innerWidth
|
|
)
|
|
const [draftAnalyses, setDraftAnalyses] = useState<
|
|
MagicNoteDraftAnalysis[]
|
|
>([])
|
|
const [canvasDraftAnalysis, setCanvasDraftAnalysis] = useState<MagicNoteDraftAnalysis>()
|
|
const canvasDraftContextRef = useRef(0)
|
|
const clearCanvasDraftAnalysis = useCallback((): void => {
|
|
canvasDraftContextRef.current += 1
|
|
setCanvasDraftAnalysis(undefined)
|
|
}, [])
|
|
const [draftAnalysisRunning, setDraftAnalysisRunning] = useState(false)
|
|
const [liveAnalysis, setLiveAnalysis] = useState<{
|
|
requestId: string
|
|
content: string
|
|
direction: MagicNoteCommentDirection
|
|
format: MagicNoteCommentFormat
|
|
}>()
|
|
const hasAiActivity = Boolean(
|
|
liveAnalysis || draftAnalysisRunning || canvasDraftAnalysis || draftAnalyses.length > 0 ||
|
|
detail?.entries.some((entry) => entry.comments.length > 0)
|
|
)
|
|
const aiPaneOpen = aiPanePinned || (hasAiActivity && aiDismissedNoteId !== detail?.id)
|
|
const aiAnalysisStarted = Boolean(liveAnalysis || draftAnalysisRunning)
|
|
const [previousAiAnalysisStarted, setPreviousAiAnalysisStarted] = useState(aiAnalysisStarted)
|
|
if (aiAnalysisStarted !== previousAiAnalysisStarted) {
|
|
// A new analysis request reopens comments that were dismissed for this note.
|
|
setPreviousAiAnalysisStarted(aiAnalysisStarted)
|
|
if (aiAnalysisStarted) setAiDismissedNoteId('')
|
|
}
|
|
const toggleAiPane = (): void => {
|
|
if (aiPaneOpen) {
|
|
setAiPanePinned(false)
|
|
setAiDismissedNoteId(detail?.id ?? '')
|
|
} else {
|
|
setAiPanePinned(true)
|
|
}
|
|
}
|
|
const [validation, setValidation] = useState<{
|
|
target: ValidationTarget
|
|
message: string
|
|
}>()
|
|
const [pendingDraftSwitch, setPendingDraftSwitch] =
|
|
useState<DraftSwitchTarget>()
|
|
const detailRequestRef = useRef(0)
|
|
const todoSourceRequestRef = useRef(0)
|
|
const requestedNoteIdRef = useRef('')
|
|
const requestedEntryIdRef = useRef<string | undefined>(undefined)
|
|
const refreshRequestRef = useRef(0)
|
|
const hasLoadedRef = useRef(false)
|
|
const busyRef = useRef('')
|
|
const refreshContextRef = useRef({ detail, titleDraft, editingEntry })
|
|
useEffect(() => {
|
|
refreshContextRef.current = { detail, titleDraft, editingEntry }
|
|
}, [detail, titleDraft, editingEntry])
|
|
const composerContentRef = useRef<NoteContent | undefined>(
|
|
undefined
|
|
)
|
|
const editingContentRef = useRef<NoteContent | undefined>(
|
|
undefined
|
|
)
|
|
// Quill/Fabric normalize persisted JSON when the editor loads a revision.
|
|
const editingBaselineRef = useRef<NoteContent | undefined>(undefined)
|
|
const draftAnalysisTimerRef = useRef<number | undefined>(undefined)
|
|
const draftAnalysisContentRef = useRef<
|
|
NoteContent | undefined
|
|
>(undefined)
|
|
const draftAnalysisQueuedRef = useRef(false)
|
|
const draftAnalysisRunningRef = useRef(false)
|
|
const draftAnalysisArmedRef = useRef(false)
|
|
const draftAnalysisContextRef = useRef(0)
|
|
const lastDraftAnalysisStartedAtRef = useRef(0)
|
|
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
|
|
const workspaceRef = useRef<HTMLDivElement>(null)
|
|
const streamRef = useRef<HTMLElement>(null)
|
|
const noteScrollPositions = useRef(new Map<string, number>())
|
|
const todoLayoutRef = useRef<HTMLDivElement>(null)
|
|
const paneResizeRef = useRef<{ pane: 'ai' | 'index' | 'todo' | 'notes'; pointerId: number; width: number } | undefined>(undefined)
|
|
const composerRef = useRef<HTMLDivElement>(null)
|
|
const continueEditingRef = useRef<HTMLButtonElement>(null)
|
|
const discardDraftRef = useRef<HTMLButtonElement>(null)
|
|
const discardDraftDialogRef = useRef<HTMLDivElement>(null)
|
|
const discardDraftTitleId = useId()
|
|
const discardDraftDescriptionId = useId()
|
|
const runDraftAnalysisRef = useRef<
|
|
(content: NoteContent) => Promise<void>
|
|
>(async () => undefined)
|
|
|
|
const createAnalysisOptions = useCallback(
|
|
async (canvas?: MagicCanvasContentHandle | null): Promise<MagicNoteAnalysisOptions> => {
|
|
let format = commentFormat
|
|
let pageCount = applicationSettings?.magicNoteCanvasPageCount ?? 1
|
|
// Read capability for this request, never reuse a previous profile's flag.
|
|
const runtimePromise = canvas !== undefined
|
|
? window.goodbuddy.settings.getRuntime()
|
|
: Promise.resolve(undefined)
|
|
const [settingsResult, runtimeResult] = await Promise.allSettled([
|
|
window.goodbuddy.updates?.getSettings(), runtimePromise
|
|
])
|
|
// Keep the last loaded format if application settings cannot be refreshed.
|
|
if (settingsResult.status === 'fulfilled' && settingsResult.value) {
|
|
format = settingsResult.value.magicNoteCommentFormat
|
|
pageCount = settingsResult.value.magicNoteCanvasPageCount ?? 1
|
|
setCommentFormat(format)
|
|
}
|
|
const options: MagicNoteAnalysisOptions = {
|
|
requestId: crypto.randomUUID(),
|
|
direction: commentDirection,
|
|
format
|
|
}
|
|
if (runtimeResult.status === 'rejected') throw runtimeResult.reason
|
|
const runtime = runtimeResult.value
|
|
if (runtime) {
|
|
const profile = runtime.modelProfiles.find((candidate) => candidate.id === runtime.defaultModelProfileId)
|
|
const supportsImages = (profile ? profile.supportsImageInput : runtime.supportsImageInput) === true
|
|
if (!canvas) throw new Error(tRef.current('canvas.notReady'))
|
|
const pages = await canvas.capturePages(pageCount, supportsImages)
|
|
options.canvasPageText = pages.map(({ pageId, text }) => ({ pageId, text: text ?? '' }))
|
|
if (supportsImages) options.canvasImages = pages.map(({ pageId, dataUrl }) => ({ pageId, dataUrl }))
|
|
}
|
|
return options
|
|
},
|
|
[commentDirection, commentFormat, applicationSettings?.magicNoteCanvasPageCount]
|
|
)
|
|
|
|
const getLayoutBounds = useCallback((): {
|
|
left: number
|
|
width: number
|
|
right: number
|
|
} => {
|
|
const bounds = magicNotesLayoutRef.current?.getBoundingClientRect()
|
|
const width = bounds?.width || window.innerWidth
|
|
return {
|
|
left: bounds?.left ?? 0,
|
|
width,
|
|
right: bounds?.right || width
|
|
}
|
|
}, [])
|
|
|
|
useEffect(
|
|
() =>
|
|
window.goodbuddy.magicNotes.onAnalysisEvent((event) => {
|
|
setLiveAnalysis((current) =>
|
|
current?.requestId === event.requestId
|
|
? {
|
|
...current,
|
|
content: current.content + event.delta
|
|
}
|
|
: current
|
|
)
|
|
}),
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
persistMagicNotesLayoutPreferences({
|
|
notesPaneOpen,
|
|
notesPaneWidth,
|
|
recordIndexOpen: indexPaneOpen,
|
|
indexPaneWidth,
|
|
aiPanePinned,
|
|
aiPaneWidth,
|
|
todoPaneWidth
|
|
})
|
|
}, [indexPaneOpen, indexPaneWidth, aiPanePinned, aiPaneWidth, todoPaneWidth, notesPaneOpen, notesPaneWidth])
|
|
|
|
useEffect(() => {
|
|
const workspace = workspaceRef.current
|
|
if (!workspace) return
|
|
const update = (): void => {
|
|
const width = workspace.getBoundingClientRect().width || window.innerWidth
|
|
setWorkspaceWidth(width)
|
|
if (width >= 600) setNarrowNotesOpen(false)
|
|
}
|
|
update()
|
|
const observer = new ResizeObserver(update)
|
|
observer.observe(workspace)
|
|
return () => observer.disconnect()
|
|
}, [loadStatus])
|
|
|
|
useEffect(() => {
|
|
const stream = streamRef.current
|
|
if (!stream || !detail?.id) return
|
|
const position = noteScrollPositions.current.get(detail.id) ?? 0
|
|
if (requestedEntryIdRef.current) return
|
|
// Run after the read-only rich text effects have populated their content.
|
|
const frame = requestAnimationFrame(() => { stream.scrollTop = position })
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [detail?.id, detailView])
|
|
|
|
useLayoutEffect(() => {
|
|
const entryId = requestedEntryIdRef.current
|
|
if (!entryId || detail?.id !== requestedNoteIdRef.current || detailView !== 'notes') return
|
|
const frame = requestAnimationFrame(() => {
|
|
const entry = document.getElementById(`magic-note-entry-${entryId}`)
|
|
if (!entry) return
|
|
entry.scrollIntoView({ block: 'center' })
|
|
requestedEntryIdRef.current = undefined
|
|
})
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [detail, detailView])
|
|
|
|
useEffect(() => {
|
|
const layout = todoLayoutRef.current
|
|
if (!layout) return
|
|
const update = (): void => setTodoLayoutWidth(layout.getBoundingClientRect().width || window.innerWidth)
|
|
update()
|
|
const observer = new ResizeObserver(update)
|
|
observer.observe(layout)
|
|
return () => observer.disconnect()
|
|
}, [detailView, libraryView])
|
|
|
|
useEffect(() => {
|
|
const layout = magicNotesLayoutRef.current
|
|
const updateLayoutWidth = (): void => {
|
|
const width = layout?.getBoundingClientRect().width || window.innerWidth
|
|
setMagicNotesLayoutWidth(width)
|
|
}
|
|
updateLayoutWidth()
|
|
const observer =
|
|
layout && typeof ResizeObserver !== 'undefined'
|
|
? new ResizeObserver(updateLayoutWidth)
|
|
: undefined
|
|
if (layout && observer) {
|
|
observer.observe(layout)
|
|
} else {
|
|
window.addEventListener('resize', updateLayoutWidth)
|
|
}
|
|
return () => {
|
|
if (!observer) {
|
|
window.removeEventListener('resize', updateLayoutWidth)
|
|
}
|
|
observer?.disconnect()
|
|
}
|
|
}, [aiPaneOpen, detailView, loadStatus])
|
|
|
|
const notifyError = useCallback(
|
|
(error: unknown): void =>
|
|
onNotify({
|
|
tone: 'error',
|
|
message: errorMessage(
|
|
error,
|
|
tRef.current('errors.operationFailed')
|
|
),
|
|
dedupeKey: 'magic-notes-error'
|
|
}),
|
|
[onNotify]
|
|
)
|
|
const notifySuccess = useCallback(
|
|
(message: string): void => onNotify({ tone: 'success', message }),
|
|
[onNotify]
|
|
)
|
|
const notifyInfo = useCallback(
|
|
(message: string): void => onNotify({ tone: 'info', message }),
|
|
[onNotify]
|
|
)
|
|
const clearValidation = useCallback((target: ValidationTarget): void => {
|
|
setValidation((current) =>
|
|
current?.target === target ? undefined : current
|
|
)
|
|
}, [])
|
|
|
|
const beginBusy = useCallback((operation: string): boolean => {
|
|
if (busyRef.current) {
|
|
notifyInfo(tRef.current('notifications.waitForOperation'))
|
|
return false
|
|
}
|
|
busyRef.current = operation
|
|
setBusy(operation)
|
|
return true
|
|
}, [notifyInfo])
|
|
|
|
const endBusy = useCallback((operation: string): void => {
|
|
if (busyRef.current !== operation) {
|
|
return
|
|
}
|
|
busyRef.current = ''
|
|
setBusy('')
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
void window.goodbuddy.updates
|
|
?.getSettings()
|
|
.then((settings) => {
|
|
if (active) {
|
|
setCommentMode(settings.magicNoteCommentMode)
|
|
setCommentFormat(settings.magicNoteCommentFormat)
|
|
}
|
|
})
|
|
.catch(() => undefined)
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [])
|
|
|
|
const runDraftAnalysis = useCallback(
|
|
async (content: NoteContent): Promise<void> => {
|
|
if (draftAnalysisRunningRef.current) {
|
|
draftAnalysisContentRef.current = content
|
|
draftAnalysisQueuedRef.current = true
|
|
return
|
|
}
|
|
draftAnalysisArmedRef.current = false
|
|
draftAnalysisRunningRef.current = true
|
|
const analysisContext = draftAnalysisContextRef.current
|
|
lastDraftAnalysisStartedAtRef.current = Date.now()
|
|
setDraftAnalysisRunning(true)
|
|
let options: MagicNoteAnalysisOptions | undefined
|
|
try {
|
|
options = await createAnalysisOptions()
|
|
if (draftAnalysisContextRef.current !== analysisContext) return
|
|
setLiveAnalysis({
|
|
requestId: options.requestId,
|
|
content: '',
|
|
direction: options.direction,
|
|
format: options.format
|
|
})
|
|
const analysis =
|
|
await window.goodbuddy.magicNotes.analyzeDraft(content, options)
|
|
if (draftAnalysisContextRef.current === analysisContext) {
|
|
setDraftAnalyses((current) => [analysis, ...current].slice(0, 20))
|
|
}
|
|
} catch (analysisError) {
|
|
if (draftAnalysisContextRef.current === analysisContext) {
|
|
notifyError(analysisError)
|
|
}
|
|
} finally {
|
|
setLiveAnalysis((current) =>
|
|
current?.requestId === options?.requestId ? undefined : current
|
|
)
|
|
draftAnalysisRunningRef.current = false
|
|
setDraftAnalysisRunning(false)
|
|
if (draftAnalysisQueuedRef.current) {
|
|
draftAnalysisQueuedRef.current = false
|
|
const queuedContent = draftAnalysisContentRef.current
|
|
const delay = Math.max(
|
|
0,
|
|
5_000 -
|
|
(Date.now() - lastDraftAnalysisStartedAtRef.current)
|
|
)
|
|
if (queuedContent) {
|
|
draftAnalysisTimerRef.current = window.setTimeout(() => {
|
|
void runDraftAnalysisRef.current(queuedContent)
|
|
}, delay)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[createAnalysisOptions, notifyError]
|
|
)
|
|
useEffect(() => {
|
|
runDraftAnalysisRef.current = runDraftAnalysis
|
|
}, [runDraftAnalysis])
|
|
|
|
const scheduleDraftAnalysis = useCallback(
|
|
(content: NoteContent): void => {
|
|
draftAnalysisContentRef.current = content
|
|
if (draftAnalysisTimerRef.current !== undefined) {
|
|
window.clearTimeout(draftAnalysisTimerRef.current)
|
|
}
|
|
draftAnalysisTimerRef.current = window.setTimeout(() => {
|
|
draftAnalysisTimerRef.current = undefined
|
|
void runDraftAnalysisRef.current(content)
|
|
}, 5_000)
|
|
},
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (commentMode === 'immediate') {
|
|
return
|
|
}
|
|
draftAnalysisArmedRef.current = false
|
|
draftAnalysisQueuedRef.current = false
|
|
if (draftAnalysisTimerRef.current !== undefined) {
|
|
window.clearTimeout(draftAnalysisTimerRef.current)
|
|
draftAnalysisTimerRef.current = undefined
|
|
}
|
|
}, [commentMode])
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (draftAnalysisTimerRef.current !== undefined) {
|
|
window.clearTimeout(draftAnalysisTimerRef.current)
|
|
}
|
|
draftAnalysisQueuedRef.current = false
|
|
},
|
|
[]
|
|
)
|
|
|
|
const applyNoteSummary = useCallback((next: MagicNoteDetail) => {
|
|
setNotes((current) => {
|
|
const summary = noteSummary(next)
|
|
const existing = current.some((note) => note.id === next.id)
|
|
return (existing
|
|
? current.map((note) => (note.id === next.id ? summary : note))
|
|
: [summary, ...current]
|
|
).sort(
|
|
(left, right) =>
|
|
Number(right.pinned) - Number(left.pinned) ||
|
|
right.updatedAt.localeCompare(left.updatedAt)
|
|
)
|
|
})
|
|
}, [])
|
|
|
|
const applyDetail = useCallback(
|
|
(next: MagicNoteDetail, submittedTitle?: string) => {
|
|
const previous = refreshContextRef.current.detail
|
|
setDetail(next)
|
|
setTitleDraft((current) => previous?.id !== next.id ||
|
|
current === (submittedTitle ?? previous.title) ? next.title : current)
|
|
applyNoteSummary(next)
|
|
},
|
|
[applyNoteSummary]
|
|
)
|
|
|
|
const applyTodo = useCallback((next: MagicTodoItem) => {
|
|
setTodos((current) =>
|
|
[
|
|
next,
|
|
...current.filter((candidate) => candidate.id !== next.id)
|
|
].sort(
|
|
(left, right) =>
|
|
Number(left.completed) - Number(right.completed) ||
|
|
right.updatedAt.localeCompare(left.updatedAt)
|
|
)
|
|
)
|
|
}, [])
|
|
|
|
const clearDraftAnalysis = useCallback((): void => {
|
|
clearCanvasDraftAnalysis()
|
|
draftAnalysisArmedRef.current = false
|
|
draftAnalysisQueuedRef.current = false
|
|
draftAnalysisContentRef.current = undefined
|
|
draftAnalysisContextRef.current += 1
|
|
setDraftAnalyses([])
|
|
setLiveAnalysis(undefined)
|
|
setDraftAnalysisRunning(false)
|
|
if (draftAnalysisTimerRef.current !== undefined) {
|
|
window.clearTimeout(draftAnalysisTimerRef.current)
|
|
draftAnalysisTimerRef.current = undefined
|
|
}
|
|
}, [clearCanvasDraftAnalysis])
|
|
|
|
const loadDetail = useCallback(
|
|
async (noteId: string, entryId?: string): Promise<void> => {
|
|
const requestId = ++detailRequestRef.current
|
|
requestedNoteIdRef.current = noteId
|
|
requestedEntryIdRef.current = entryId
|
|
setDetailLoadError(undefined)
|
|
try {
|
|
const nextDetail = await window.goodbuddy.magicNotes.get(noteId)
|
|
if (detailRequestRef.current === requestId) {
|
|
clearDraftAnalysis()
|
|
composerContentRef.current = undefined
|
|
setComposerKey((current) => current + 1)
|
|
setComposerHasDraft(false)
|
|
setEditingEntry(undefined)
|
|
setSelectedEntryId(nextDetail.entries.find((entry) => entry.id === entryId)?.id ?? nextDetail.entries.at(-1)?.id ?? '')
|
|
editingContentRef.current = undefined
|
|
setSelectedNoteId(noteId)
|
|
applyDetail(nextDetail)
|
|
}
|
|
} catch (loadError) {
|
|
if (detailRequestRef.current === requestId) {
|
|
setDetailLoadError({
|
|
message: errorMessage(
|
|
loadError,
|
|
tRef.current('errors.operationFailed')
|
|
),
|
|
noteId
|
|
})
|
|
}
|
|
}
|
|
},
|
|
[applyDetail, clearDraftAnalysis]
|
|
)
|
|
|
|
const discardComposerDraft = useCallback((): void => {
|
|
clearDraftAnalysis()
|
|
composerContentRef.current = undefined
|
|
setComposerKey((current) => current + 1)
|
|
setComposerHasDraft(false)
|
|
}, [clearDraftAnalysis])
|
|
|
|
const discardEditingDraft = useCallback((): void => {
|
|
clearDraftAnalysis()
|
|
setEditingEntry(undefined)
|
|
editingContentRef.current = undefined
|
|
clearValidation('edit-entry')
|
|
}, [clearValidation, clearDraftAnalysis])
|
|
|
|
const hasDirtyEditingDraft = useCallback(
|
|
(): boolean =>
|
|
Boolean(
|
|
editingEntry &&
|
|
!richContentEqual(
|
|
editingContentRef.current,
|
|
editingBaselineRef.current ?? editingEntry.content
|
|
)
|
|
),
|
|
[editingEntry]
|
|
)
|
|
|
|
const createNote = useCallback(
|
|
async (title: string, discardDraft: boolean): Promise<void> => {
|
|
const operation = 'create-note'
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
try {
|
|
const created = await window.goodbuddy.magicNotes.create({
|
|
title
|
|
})
|
|
if (discardDraft) {
|
|
discardComposerDraft()
|
|
discardEditingDraft()
|
|
}
|
|
applyDetail(created)
|
|
detailRequestRef.current += 1
|
|
requestedNoteIdRef.current = created.id
|
|
requestedEntryIdRef.current = undefined
|
|
setSelectedNoteId(created.id)
|
|
setSelectedEntryId('')
|
|
setDetailView('notes')
|
|
setNewTitle('')
|
|
setCreating(false)
|
|
notifySuccess(t('notifications.noteCreated'))
|
|
} catch (createError) {
|
|
notifyError(createError)
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
},
|
|
[
|
|
applyDetail,
|
|
beginBusy,
|
|
discardComposerDraft,
|
|
discardEditingDraft,
|
|
endBusy,
|
|
notifyError,
|
|
notifySuccess,
|
|
t
|
|
]
|
|
)
|
|
|
|
const focusSwitchTarget = useCallback(
|
|
(target: DraftSwitchTarget): void => {
|
|
requestAnimationFrame(() => {
|
|
if (target.kind === 'edit-entry' && target.entry.content.version === 2) {
|
|
document.getElementById(`magic-note-entry-${target.entry.id}`)?.scrollIntoView({ block: 'start' })
|
|
editingCanvasRef.current?.focus()
|
|
return
|
|
}
|
|
const focusTarget =
|
|
target.kind === 'overview'
|
|
? (document.getElementById('magic-todo-back')?.checkVisibility?.() &&
|
|
document.getElementById('magic-todo-back')) || document.getElementById(overviewFocusRef.current) ||
|
|
document.getElementById('magic-note-new')
|
|
: target.kind === 'library-view'
|
|
? document.getElementById('magic-library-switch')
|
|
: target.kind === 'note'
|
|
? document.getElementById('magic-notes-back') ??
|
|
document.getElementById(`magic-note-select-${target.noteId}`)
|
|
: target.kind === 'edit-entry'
|
|
? document
|
|
.getElementById(
|
|
`magic-note-entry-${target.entry.id}`
|
|
)
|
|
?.querySelector<HTMLElement>(
|
|
'.ql-editor, [data-testid="magic-note-editor"]'
|
|
)
|
|
: composerRef.current?.querySelector<HTMLElement>(
|
|
'.ql-editor, [data-testid="magic-note-editor"]'
|
|
)
|
|
focusTarget?.focus({ preventScroll: true })
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
|
|
const performDraftSwitch = useCallback(
|
|
(target: DraftSwitchTarget): void => {
|
|
setPendingDraftSwitch(undefined)
|
|
setValidation(undefined)
|
|
if (target.kind === 'delete-note') {
|
|
target.remove()
|
|
return
|
|
}
|
|
if (target.kind === 'leave') {
|
|
discardComposerDraft()
|
|
discardEditingDraft()
|
|
setTitleDraft(detail?.title ?? '')
|
|
target.leave()
|
|
return
|
|
}
|
|
if (target.kind === 'select-entry') {
|
|
setSelectedEntryId(target.entry.id)
|
|
setNarrowIndexOpen(false)
|
|
const article = document.getElementById(`magic-note-entry-${target.entry.id}`)
|
|
article?.scrollIntoView({ block: 'start' })
|
|
if (narrowIndexOpen) article?.focus({ preventScroll: true })
|
|
return
|
|
}
|
|
if (target.kind === 'entry-type') {
|
|
discardComposerDraft()
|
|
setEntryType(target.value)
|
|
// The composer remounts with the new type; move focus into the new editor.
|
|
requestAnimationFrame(() => {
|
|
if (target.value === 'canvas' && composerCanvasRef.current) { composerCanvasRef.current.focus(); return }
|
|
composerRef.current?.querySelector<HTMLElement>('.ql-editor, [data-testid="magic-note-editor"]')?.focus({ preventScroll: true })
|
|
})
|
|
return
|
|
}
|
|
if (target.kind === 'cancel-new-entry') {
|
|
discardComposerDraft()
|
|
setEntryType('text')
|
|
clearValidation('new-entry')
|
|
// Move focus out of the composer after any confirmation restores it, so the
|
|
// composer collapses without dropping focus onto the document body.
|
|
const fallbackEntryId = detail?.entries.some((entry) => entry.id === selectedEntryId) ? selectedEntryId : detail?.entries.at(-1)?.id
|
|
requestAnimationFrame(() => {
|
|
const active = document.activeElement
|
|
if (active instanceof HTMLElement && active !== document.body && !composerRef.current?.contains(active)) return
|
|
const entry = fallbackEntryId ? document.getElementById(`magic-note-entry-${fallbackEntryId}`) : null
|
|
if (entry) { entry.focus({ preventScroll: true }); return }
|
|
if (active instanceof HTMLElement) active.blur()
|
|
})
|
|
return
|
|
}
|
|
if (target.kind === 'cancel-edit') {
|
|
const editedEntryId = editingEntry?.id
|
|
discardEditingDraft()
|
|
setSelectedEntryId((current) => detail?.entries.some((entry) => entry.id === current) ? current : detail?.entries.at(-1)?.id ?? '')
|
|
// The inline editor unmounts; return focus to the entry that was being edited.
|
|
requestAnimationFrame(() => {
|
|
const article = editedEntryId ? document.getElementById(`magic-note-entry-${editedEntryId}`) : null
|
|
if (!article) return
|
|
const editButton = article.querySelector<HTMLButtonElement>('button[data-entry-edit]:not(:disabled)')
|
|
;(editButton ?? article).focus({ preventScroll: true })
|
|
})
|
|
return
|
|
}
|
|
if (target.kind === 'overview') {
|
|
setNarrowNotesOpen(false)
|
|
discardComposerDraft()
|
|
discardEditingDraft()
|
|
detailRequestRef.current += 1
|
|
requestedNoteIdRef.current = ''
|
|
setSelectedNoteId('')
|
|
setDetail(undefined)
|
|
setTitleDraft('')
|
|
setDetailLoadError(undefined)
|
|
setRefreshError('')
|
|
setDeletingEntryId('')
|
|
setDetailView(undefined)
|
|
focusSwitchTarget(target)
|
|
return
|
|
}
|
|
if (target.kind === 'library-view') {
|
|
setNarrowNotesOpen(false)
|
|
if (target.value === 'todos') {
|
|
discardComposerDraft()
|
|
discardEditingDraft()
|
|
setDetailView(undefined)
|
|
}
|
|
setLibraryView(target.value)
|
|
setCreating(false)
|
|
setSearch('')
|
|
focusSwitchTarget(target)
|
|
return
|
|
}
|
|
if (target.kind === 'edit-entry') {
|
|
discardComposerDraft()
|
|
setSelectedEntryId(target.entry.id)
|
|
discardEditingDraft()
|
|
setDeletingEntryId('')
|
|
setEditingEntry(target.entry)
|
|
editingBaselineRef.current = target.entry.content
|
|
editingContentRef.current = target.entry.content
|
|
focusSwitchTarget(target)
|
|
return
|
|
}
|
|
if (target.kind === 'create-note') {
|
|
overviewFocusRef.current = 'magic-note-new'
|
|
setOverviewReturnNoteId('')
|
|
void createNote(target.title, true)
|
|
.then(() => focusSwitchTarget(target))
|
|
return
|
|
}
|
|
setDeletingNote(false)
|
|
if (libraryView === 'notes') {
|
|
overviewFocusRef.current = `magic-note-select-${target.noteId}`
|
|
setOverviewReturnNoteId(target.noteId)
|
|
}
|
|
setDetailView('notes')
|
|
setNarrowNotesOpen(false)
|
|
focusSwitchTarget(target)
|
|
void loadDetail(target.noteId, target.entryId)
|
|
},
|
|
[
|
|
clearValidation,
|
|
createNote,
|
|
detail,
|
|
discardComposerDraft,
|
|
discardEditingDraft,
|
|
editingEntry?.id,
|
|
selectedEntryId,
|
|
focusSwitchTarget,
|
|
loadDetail,
|
|
libraryView,
|
|
narrowIndexOpen
|
|
]
|
|
)
|
|
|
|
const requestDraftSwitch = useCallback(
|
|
async (target: DraftSwitchTarget): Promise<void> => {
|
|
if (pendingDraftSwitch) {
|
|
return
|
|
}
|
|
if (target.kind === 'select-entry') {
|
|
performDraftSwitch(target)
|
|
return
|
|
}
|
|
if (busyRef.current) {
|
|
notifyInfo(tRef.current('notifications.waitForOperation'))
|
|
return
|
|
}
|
|
if (composerCanvasRef.current || editingCanvasRef.current) {
|
|
if (!beginBusy('flush-switch')) return
|
|
try {
|
|
if (composerCanvasRef.current) composerContentRef.current = await composerCanvasRef.current.flush()
|
|
if (editingCanvasRef.current) editingContentRef.current = await editingCanvasRef.current.flush()
|
|
} catch (error) {
|
|
notifyError(error)
|
|
return
|
|
} finally {
|
|
endBusy('flush-switch')
|
|
}
|
|
}
|
|
const changesContext =
|
|
target.kind === 'library-view'
|
|
? target.value !== libraryView
|
|
: target.kind === 'note'
|
|
? !detailView || target.noteId !== selectedNoteId ||
|
|
target.entryId !== undefined
|
|
: target.kind === 'edit-entry'
|
|
? target.entry.id !== editingEntry?.id
|
|
: true
|
|
if (!changesContext) {
|
|
return
|
|
}
|
|
const wouldClearComposer = target.kind !== 'cancel-edit'
|
|
const current = refreshContextRef.current
|
|
if (
|
|
(wouldClearComposer && hasContent(composerContentRef.current)) ||
|
|
(target.kind !== 'entry-type' && hasDirtyEditingDraft()) ||
|
|
((target.kind === 'overview' || target.kind === 'leave' || target.kind === 'library-view' || target.kind === 'note' || target.kind === 'create-note' || target.kind === 'delete-note') && current.detail && current.titleDraft !== current.detail.title)
|
|
) {
|
|
setPendingDraftSwitch(target)
|
|
return
|
|
}
|
|
performDraftSwitch(target)
|
|
},
|
|
[
|
|
editingEntry?.id,
|
|
detailView,
|
|
notifyInfo,
|
|
hasDirtyEditingDraft,
|
|
libraryView,
|
|
pendingDraftSwitch,
|
|
performDraftSwitch,
|
|
selectedNoteId, beginBusy, endBusy, notifyError
|
|
]
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
onBeforeLeave?.((leave) => { void requestDraftSwitch({ kind: 'leave', leave }) })
|
|
return () => onBeforeLeave?.(undefined)
|
|
}, [onBeforeLeave, requestDraftSwitch])
|
|
|
|
const handledNavigation = useRef<typeof navigation>(undefined)
|
|
useEffect(() => {
|
|
if (!navigation || handledNavigation.current === navigation || loadStatus !== 'ready' || busy) return
|
|
handledNavigation.current = navigation
|
|
void requestDraftSwitch({ kind: 'note', noteId: navigation.noteId, entryId: navigation.entryId })
|
|
}, [navigation, requestDraftSwitch, loadStatus, busy])
|
|
|
|
const continueEditing = useCallback((): void => {
|
|
setPendingDraftSwitch(undefined)
|
|
requestAnimationFrame(() => {
|
|
const canvas = editingEntry?.content.version === 2 ? editingCanvasRef.current : composerCanvasRef.current
|
|
if (canvas) { canvas.focus(); return }
|
|
const editor = editingEntry
|
|
? document
|
|
.getElementById(`magic-note-entry-${editingEntry.id}`)
|
|
?.querySelector<HTMLElement>(
|
|
'.ql-editor, [data-testid="magic-note-editor"]'
|
|
)
|
|
: composerRef.current?.querySelector<HTMLElement>(
|
|
'.ql-editor, [data-testid="magic-note-editor"]'
|
|
)
|
|
editor?.focus()
|
|
})
|
|
}, [editingEntry])
|
|
|
|
useEffect(() => {
|
|
if (!pendingDraftSwitch) {
|
|
return
|
|
}
|
|
return activateModalFocus(() => continueEditingRef.current)
|
|
}, [pendingDraftSwitch])
|
|
|
|
const refreshNotes = useCallback(
|
|
async (preferredId?: string, background = false): Promise<void> => {
|
|
const requestId = ++refreshRequestRef.current
|
|
const detailRequestAtStart = detailRequestRef.current
|
|
await Promise.resolve()
|
|
if (refreshRequestRef.current !== requestId) {
|
|
return
|
|
}
|
|
const isInitialLoad = !hasLoadedRef.current
|
|
if (isInitialLoad) {
|
|
setLoadStatus('loading')
|
|
setLoadError('')
|
|
}
|
|
setRefreshError('')
|
|
try {
|
|
const [snapshot, todoSnapshot] = await Promise.all([
|
|
window.goodbuddy.magicNotes.list(),
|
|
window.goodbuddy.magicNotes.listTodos()
|
|
])
|
|
const nextId =
|
|
preferredId && snapshot.notes.some((note) => note.id === preferredId)
|
|
? preferredId
|
|
: ''
|
|
const nextDetail = nextId
|
|
? await window.goodbuddy.magicNotes.get(nextId)
|
|
: undefined
|
|
if (background && editingCanvasRef.current) {
|
|
const editor = editingCanvasRef.current
|
|
const content = await editor.flush()
|
|
if (editingCanvasRef.current === editor) editingContentRef.current = content
|
|
}
|
|
if (refreshRequestRef.current !== requestId) {
|
|
return
|
|
}
|
|
const preserveNewerSelection =
|
|
detailRequestRef.current !== detailRequestAtStart
|
|
setNotes(snapshot.notes)
|
|
setNoteTags(snapshot.tags ?? [])
|
|
setTodos(todoSnapshot.todos)
|
|
setSelectedTodoId((current) =>
|
|
todoSnapshot.todos.some((todo) => todo.id === current)
|
|
? current
|
|
: ''
|
|
)
|
|
hasLoadedRef.current = true
|
|
setLoadStatus('ready')
|
|
if (preserveNewerSelection) {
|
|
return
|
|
}
|
|
const current = refreshContextRef.current
|
|
const editingDirty = current.editingEntry && !richContentEqual(editingContentRef.current, editingBaselineRef.current ?? current.editingEntry.content)
|
|
const titleDirty = current.detail &&
|
|
current.titleDraft !== current.detail.title
|
|
if (background && current.detail?.id !== nextId) {
|
|
if (
|
|
titleDirty ||
|
|
hasContent(composerContentRef.current) ||
|
|
editingDirty
|
|
) {
|
|
setRefreshError(tRef.current('errors.noteDeletedExternally'))
|
|
return
|
|
}
|
|
discardComposerDraft()
|
|
discardEditingDraft()
|
|
}
|
|
detailRequestRef.current += 1
|
|
requestedNoteIdRef.current = nextId
|
|
setSelectedNoteId(nextId)
|
|
setDetail(nextDetail)
|
|
if (!editingDirty) {
|
|
setSelectedEntryId((selected) => nextDetail?.entries.some((entry) => entry.id === selected) ? selected : nextDetail?.entries.at(-1)?.id ?? '')
|
|
}
|
|
if (background && current.editingEntry && !editingDirty) {
|
|
const refreshed = nextDetail?.entries.find((entry) => entry.id === current.editingEntry?.id)
|
|
// An unchanged revision keeps the mounted editor's normalized baseline.
|
|
if (refreshed?.revision !== current.editingEntry.revision) {
|
|
setEditingEntry(refreshed)
|
|
editingBaselineRef.current = refreshed?.content
|
|
editingContentRef.current = refreshed?.content
|
|
}
|
|
}
|
|
if (preferredId && !nextId) setDetailView(undefined)
|
|
if (!background || !titleDirty) {
|
|
setTitleDraft(nextDetail?.title ?? '')
|
|
}
|
|
if (
|
|
background && editingDirty && current.editingEntry && nextDetail &&
|
|
!nextDetail.entries.some((entry) => entry.id === current.editingEntry?.id)
|
|
) {
|
|
setRefreshError(tRef.current('errors.entryDeletedExternally'))
|
|
}
|
|
setDetailLoadError(undefined)
|
|
} catch (loadError) {
|
|
if (refreshRequestRef.current === requestId) {
|
|
const message = errorMessage(
|
|
loadError,
|
|
tRef.current('errors.operationFailed')
|
|
)
|
|
if (hasLoadedRef.current) {
|
|
setRefreshError(message)
|
|
} else {
|
|
setLoadError(message)
|
|
setLoadStatus('error')
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[discardComposerDraft, discardEditingDraft]
|
|
)
|
|
|
|
useEffect(() => {
|
|
const timeout = window.setTimeout(() => {
|
|
void refreshNotes()
|
|
}, 0)
|
|
return () => {
|
|
window.clearTimeout(timeout)
|
|
refreshRequestRef.current += 1
|
|
detailRequestRef.current += 1
|
|
}
|
|
}, [refreshNotes])
|
|
|
|
useEffect(() => {
|
|
let timeout: number | undefined
|
|
const refresh = (): void => {
|
|
if (busyRef.current) {
|
|
timeout = window.setTimeout(refresh, 100)
|
|
return
|
|
}
|
|
timeout = undefined
|
|
void refreshNotes(requestedNoteIdRef.current, true)
|
|
}
|
|
const unsubscribe = window.goodbuddy.magicNotes.onChanged(() => {
|
|
refreshRequestRef.current += 1
|
|
window.clearTimeout(timeout)
|
|
timeout = window.setTimeout(refresh, 100)
|
|
})
|
|
return () => {
|
|
unsubscribe()
|
|
window.clearTimeout(timeout)
|
|
refreshRequestRef.current += 1
|
|
}
|
|
}, [refreshNotes])
|
|
|
|
// Filter keys whose tag was renamed away or deleted stop applying.
|
|
const activeTagFilter = useMemo(() => {
|
|
const known = new Set(noteTags.map((tag) => magicNoteTagKey(tag.name)))
|
|
return tagFilter.filter((key) => known.has(key))
|
|
}, [noteTags, tagFilter])
|
|
const activeTagFilterNames = useMemo(
|
|
() => activeTagFilter.map((key) => noteTags.find((tag) => magicNoteTagKey(tag.name) === key)?.name ?? key),
|
|
[activeTagFilter, noteTags]
|
|
)
|
|
|
|
const visibleNotes = useMemo(() => {
|
|
const query = search.trim().toLocaleLowerCase()
|
|
return notes.filter((note) => {
|
|
const keys = new Set((note.tags ?? []).map(magicNoteTagKey))
|
|
if (!activeTagFilter.every((key) => keys.has(key))) return false
|
|
return !query ||
|
|
note.title.toLocaleLowerCase().includes(query) ||
|
|
note.preview.toLocaleLowerCase().includes(query) ||
|
|
(note.tags ?? []).some((tag) => tag.toLocaleLowerCase().includes(query))
|
|
})
|
|
}, [activeTagFilter, notes, search])
|
|
|
|
const updateNoteTags = async (next: string[]): Promise<boolean> => {
|
|
if (!detail) return false
|
|
const operation = 'update-tags'
|
|
if (!beginBusy(operation)) return false
|
|
try {
|
|
const updated = await window.goodbuddy.magicNotes.update({
|
|
noteId: detail.id,
|
|
tags: next,
|
|
expectedRevision: detail.revision
|
|
})
|
|
applyDetail(updated)
|
|
return true
|
|
} catch (error) {
|
|
notifyError(error)
|
|
return false
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const renameNoteTag = async (tag: MagicNoteTag, name: string): Promise<MagicNoteTagRenameResult | undefined> => {
|
|
const operation = 'rename-tag'
|
|
if (!beginBusy(operation)) return undefined
|
|
try {
|
|
const result = await window.goodbuddy.magicNotes.renameTag({ tagId: tag.id, name })
|
|
const fromKey = magicNoteTagKey(tag.name)
|
|
const toKey = magicNoteTagKey(result.tag.name)
|
|
setTagFilter((current) => [...new Set(current.map((key) => key === fromKey ? toKey : key))])
|
|
notifySuccess(result.merged ? t('tags.merged', { name: result.tag.name }) : t('tags.renamed'))
|
|
await refreshNotes(requestedNoteIdRef.current, true)
|
|
return result
|
|
} catch (error) {
|
|
notifyError(error)
|
|
return undefined
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const deleteNoteTag = async (tag: MagicNoteTag): Promise<boolean> => {
|
|
const operation = 'delete-tag'
|
|
if (!beginBusy(operation)) return false
|
|
try {
|
|
await window.goodbuddy.magicNotes.removeTag(tag.id)
|
|
const key = magicNoteTagKey(tag.name)
|
|
setTagFilter((current) => current.filter((item) => item !== key))
|
|
notifySuccess(t('tags.deleted'))
|
|
await refreshNotes(requestedNoteIdRef.current, true)
|
|
return true
|
|
} catch (error) {
|
|
notifyError(error)
|
|
return false
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const actionNote = useMemo(
|
|
() => libraryView === 'notes' && !pendingDraftSwitch
|
|
? visibleNotes.find((note) => note.id === noteActionsId)
|
|
: undefined,
|
|
[libraryView, noteActionsId, pendingDraftSwitch, visibleNotes]
|
|
)
|
|
if (noteActionsId && !actionNote) {
|
|
setNoteActionsId('')
|
|
setDeletingNote(false)
|
|
}
|
|
|
|
const closeNoteActions = (): void => {
|
|
setNoteActionsId('')
|
|
setDeletingNote(false)
|
|
noteActionTriggerRef.current?.focus({ preventScroll: true })
|
|
}
|
|
|
|
useLayoutEffect(() => {
|
|
const surface = noteActionsRef.current
|
|
const trigger = noteActionTriggerRef.current
|
|
if (!noteActionsId || !surface || !trigger) return
|
|
const position = (): void => {
|
|
const anchor = trigger.getBoundingClientRect()
|
|
const bounds = surface.getBoundingClientRect()
|
|
surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px`
|
|
surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px`
|
|
}
|
|
position()
|
|
surface.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true })
|
|
const dismiss = (event: Event): void => {
|
|
if (event.target instanceof Node && !surface.contains(event.target) && !trigger.contains(event.target)) {
|
|
setNoteActionsId('')
|
|
setDeletingNote(false)
|
|
}
|
|
}
|
|
const observer = new ResizeObserver(position)
|
|
observer.observe(surface)
|
|
document.addEventListener('pointerdown', dismiss)
|
|
document.addEventListener('focusin', dismiss)
|
|
window.addEventListener('resize', position)
|
|
window.addEventListener('scroll', position, true)
|
|
return () => {
|
|
observer.disconnect()
|
|
document.removeEventListener('pointerdown', dismiss)
|
|
document.removeEventListener('focusin', dismiss)
|
|
window.removeEventListener('resize', position)
|
|
window.removeEventListener('scroll', position, true)
|
|
}
|
|
}, [noteActionsId])
|
|
|
|
const pinNote = async (note: MagicNoteSummary): Promise<void> => {
|
|
const operation = 'pin-note'
|
|
if (!beginBusy(operation)) return
|
|
closeNoteActions()
|
|
try {
|
|
const updated = await window.goodbuddy.magicNotes.update({
|
|
noteId: note.id,
|
|
pinned: !note.pinned,
|
|
expectedRevision: note.revision
|
|
})
|
|
applyNoteSummary(updated)
|
|
setDetail((current) => current?.id === updated.id ? updated : current)
|
|
} catch (error) {
|
|
notifyError(error)
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const deleteNote = async (note: MagicNoteSummary): Promise<void> => {
|
|
const operation = 'delete-note'
|
|
if (!beginBusy(operation)) return
|
|
try {
|
|
await window.goodbuddy.magicNotes.remove(note.id)
|
|
notifySuccess(t('notifications.noteDeleted'))
|
|
closeNoteActions()
|
|
if (note.id === requestedNoteIdRef.current) {
|
|
performDraftSwitch({ kind: 'overview' })
|
|
await refreshNotes()
|
|
requestAnimationFrame(() => document.getElementById('magic-note-new')?.focus({ preventScroll: true }))
|
|
} else {
|
|
await refreshNotes(requestedNoteIdRef.current, true)
|
|
}
|
|
} catch (error) {
|
|
notifyError(error)
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const visibleTodos = useMemo(() => {
|
|
const normalized = search.trim().toLocaleLowerCase()
|
|
return todos.filter((todo) => {
|
|
const matchesStatus =
|
|
todoFilter === 'all' ||
|
|
(todoFilter === 'completed' ? todo.completed : !todo.completed)
|
|
return (
|
|
matchesStatus &&
|
|
(!normalized ||
|
|
`${todo.title} ${todo.instructions} ${todo.noteTitle ?? ''}`
|
|
.toLocaleLowerCase()
|
|
.includes(normalized))
|
|
)
|
|
})
|
|
}, [search, todoFilter, todos])
|
|
|
|
const todoDirectories = useMemo(() => {
|
|
const directories = new Map<
|
|
string,
|
|
{ noteId: string; noteTitle: string; todos: MagicTodoItem[] }
|
|
>()
|
|
for (const todo of visibleTodos) {
|
|
const directory = directories.get(todo.noteId) ?? {
|
|
noteId: todo.noteId,
|
|
noteTitle: todo.noteTitle,
|
|
todos: []
|
|
}
|
|
directory.todos.push(todo)
|
|
directories.set(todo.noteId, directory)
|
|
}
|
|
return [...directories.values()].sort((left, right) =>
|
|
left.noteTitle.localeCompare(
|
|
right.noteTitle,
|
|
currentLocale
|
|
)
|
|
)
|
|
}, [currentLocale, visibleTodos])
|
|
|
|
const selectedTodo = useMemo(
|
|
() => visibleTodos.find((todo) => todo.id === selectedTodoId),
|
|
[selectedTodoId, visibleTodos]
|
|
)
|
|
const selectedTodoNoteId = selectedTodo?.noteId
|
|
const selectedTodoEntryId = selectedTodo?.entryId
|
|
|
|
useEffect(() => {
|
|
if (detailView || libraryView !== 'todos' || !selectedTodoNoteId) {
|
|
return
|
|
}
|
|
const requestId = ++todoSourceRequestRef.current
|
|
void Promise.resolve().then(() => {
|
|
if (todoSourceRequestRef.current !== requestId) return undefined
|
|
setTodoSourceStatus('loading')
|
|
return window.goodbuddy.magicNotes.get(selectedTodoNoteId)
|
|
})
|
|
.then((sourceDetail) => {
|
|
if (todoSourceRequestRef.current === requestId) {
|
|
setTodoSourceDetail(sourceDetail)
|
|
setTodoSourceStatus('ready')
|
|
}
|
|
})
|
|
.catch((sourceError: unknown) => {
|
|
if (todoSourceRequestRef.current === requestId) {
|
|
setTodoSourceDetail(undefined)
|
|
setTodoSourceStatus('error')
|
|
notifyError(sourceError)
|
|
}
|
|
})
|
|
return () => {
|
|
todoSourceRequestRef.current += 1
|
|
}
|
|
}, [detailView, libraryView, notifyError, selectedTodoNoteId, todos, todoSourceRetry])
|
|
|
|
const selectedTodoSourceEntry = useMemo(
|
|
() =>
|
|
todoSourceDetail && todoSourceDetail.id === selectedTodoNoteId
|
|
? todoSourceDetail.entries.find(
|
|
(entry) => entry.id === selectedTodoEntryId
|
|
)
|
|
: undefined,
|
|
[selectedTodoEntryId, selectedTodoNoteId, todoSourceDetail]
|
|
)
|
|
const reloadTodos = useCallback(async (): Promise<void> => {
|
|
const snapshot = await window.goodbuddy.magicNotes.listTodos()
|
|
setTodos(snapshot.todos)
|
|
setSelectedTodoId((current) =>
|
|
snapshot.todos.some((todo) => todo.id === current)
|
|
? current
|
|
: ''
|
|
)
|
|
}, [])
|
|
|
|
const aiEntries = useMemo(
|
|
() =>
|
|
[...(detail?.entries ?? [])]
|
|
.filter((entry) => entry.comments.length > 0)
|
|
.reverse(),
|
|
[detail]
|
|
)
|
|
const displayedEntries = useMemo(() => {
|
|
const entries = detail?.entries ?? []
|
|
// Keep an externally deleted entry mounted until its draft is resolved.
|
|
return (editingEntry && !entries.some((entry) => entry.id === editingEntry.id)
|
|
? [...entries, editingEntry] : [...entries]).reverse()
|
|
}, [detail, editingEntry])
|
|
// Detail tiers: >800 three columns; 601-800 editor + AI with a record drawer; <=600 AI stacks below.
|
|
const isNarrowLayout = magicNotesLayoutWidth <= 800
|
|
const isStackedLayout = magicNotesLayoutWidth <= 600
|
|
const isNarrowWorkspace = workspaceWidth < 600
|
|
const paneResizeDisabled = (pane: 'ai' | 'index' | 'todo' | 'notes'): boolean =>
|
|
pane === 'notes' ? isNarrowWorkspace : pane === 'todo' ? todoLayoutWidth <= 700 : pane === 'ai' ? isStackedLayout : isNarrowLayout
|
|
const notesPaneWidthLimits = { minimum: 160, maximum: Math.min(420, Math.max(160, workspaceWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth)) }
|
|
const displayedNotesWidth = clampMagicNotesPaneWidth(notesPaneWidth, notesPaneWidthLimits)
|
|
// Overview shows every note full width; the split list appears only beside an open note.
|
|
const notesDrawerOpen = libraryView === 'notes' && Boolean(detailView) && isNarrowWorkspace && narrowNotesOpen
|
|
const notesExpanded = isNarrowWorkspace ? !detailView || notesDrawerOpen : notesPaneOpen || !detailView
|
|
const notesSplit = libraryView === 'notes' && Boolean(detailView) && !isNarrowWorkspace && notesPaneOpen
|
|
const closeNotesDrawer = (): void => {
|
|
setNarrowNotesOpen(false)
|
|
document.getElementById('magic-notes-list-toggle')?.focus({ preventScroll: true })
|
|
}
|
|
|
|
const createNoteTag = async (name: string): Promise<MagicNoteTag | undefined> => {
|
|
const operation = 'create-tag'
|
|
if (!beginBusy(operation)) return undefined
|
|
try {
|
|
const tag = await window.goodbuddy.magicNotes.createTag({ name })
|
|
notifySuccess(t('tags.created'))
|
|
await refreshNotes(requestedNoteIdRef.current, true)
|
|
return tag
|
|
} catch (error) {
|
|
notifyError(error)
|
|
return undefined
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
useEffect(() => {
|
|
if (notesDrawerOpen) document.querySelector<HTMLInputElement>('#magic-library-panel-notes input[type="search"]')?.focus({ preventScroll: true })
|
|
}, [notesDrawerOpen])
|
|
const visibleNoteIdsKey = visibleNotes.map((note) => note.id).join('\n')
|
|
const visibleNoteIds = useMemo(
|
|
() => (visibleNoteIdsKey ? visibleNoteIdsKey.split('\n') : []),
|
|
[visibleNoteIdsKey]
|
|
)
|
|
// P5: thousands of notes mount only the card rows near the viewport.
|
|
const noteWindow = useGridListWindow({
|
|
itemIds: visibleNoteIds,
|
|
gridRef: noteGridRef,
|
|
scope: `${search}\n${activeTagFilter.join('\n')}`,
|
|
// Selection, the open menu and the overview return target stay mounted.
|
|
keepIds: [selectedNoteId, noteActionsId, overviewReturnNoteId],
|
|
enabled: libraryView === 'notes' && loadStatus !== 'loading',
|
|
active: libraryView === 'notes' && notesExpanded,
|
|
layoutKey: detailView ? 'list' : 'cards',
|
|
estimatedRowHeight: detailView ? 56 : 176,
|
|
rowAttribute: 'data-magic-note-window-row'
|
|
})
|
|
const todoPaneWidthLimits = { minimum: 240, maximum: Math.max(240, todoLayoutWidth - 301) }
|
|
const displayedTodoWidth = clampMagicNotesPaneWidth(todoPaneWidth, todoPaneWidthLimits)
|
|
const indexExpanded = isNarrowLayout ? narrowIndexOpen : indexPaneOpen
|
|
// Clamp displayed widths without replacing the user's saved desktop preferences.
|
|
const layoutInnerWidth = Math.max(0, magicNotesLayoutWidth - 2)
|
|
const displayedIndexWidth = clampMagicNotesPaneWidth(indexPaneWidth, {
|
|
minimum: minimumIndexPaneWidth,
|
|
maximum: Math.max(minimumIndexPaneWidth, Math.min(maximumIndexPaneWidth,
|
|
layoutInnerWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth -
|
|
(aiPaneOpen ? minimumAiPaneWidth + magicNotesResizeHandleWidth : 0)))
|
|
})
|
|
const aiPaneWidthLimits = getAiPaneWidthLimits(
|
|
layoutInnerWidth, indexPaneOpen && !isNarrowLayout ? displayedIndexWidth + magicNotesResizeHandleWidth : 0
|
|
)
|
|
const displayedAiWidth = clampMagicNotesPaneWidth(aiPaneWidth, aiPaneWidthLimits)
|
|
const indexPaneWidthLimits = {
|
|
minimum: minimumIndexPaneWidth,
|
|
maximum: Math.max(minimumIndexPaneWidth, Math.min(maximumIndexPaneWidth,
|
|
layoutInnerWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth -
|
|
(aiPaneOpen ? displayedAiWidth + magicNotesResizeHandleWidth : 0)))
|
|
}
|
|
const finishPaneResize = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
const resize = paneResizeRef.current
|
|
if (!resize || resize.pointerId !== event.pointerId) return
|
|
paneResizeRef.current = undefined
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
}
|
|
if (resize.pane === 'ai') setAiPaneWidth(resize.width)
|
|
else if (resize.pane === 'notes') setNotesPaneWidth(resize.width)
|
|
else if (resize.pane === 'todo') setTodoPaneWidth(resize.width)
|
|
else setIndexPaneWidth(resize.width)
|
|
setResizingPane(undefined)
|
|
}
|
|
const updatePanePointerWidth = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
const resize = paneResizeRef.current
|
|
if (!resize || resize.pointerId !== event.pointerId) return
|
|
const isAi = resize.pane === 'ai'
|
|
const isTodo = resize.pane === 'todo'
|
|
const isNotes = resize.pane === 'notes'
|
|
const limits = isNotes ? notesPaneWidthLimits : isTodo ? todoPaneWidthLimits : isAi ? aiPaneWidthLimits : indexPaneWidthLimits
|
|
if (paneResizeDisabled(resize.pane)) { finishPaneResize(event); return }
|
|
const bounds = isNotes ? workspaceRef.current!.getBoundingClientRect() : isTodo ? todoLayoutRef.current!.getBoundingClientRect() : getLayoutBounds()
|
|
resize.width = clampMagicNotesPaneWidth(isAi ? bounds.right - 1 - event.clientX : event.clientX - bounds.left - 1, limits)
|
|
const layout = isNotes ? workspaceRef.current : isTodo ? todoLayoutRef.current : magicNotesLayoutRef.current
|
|
layout?.style.setProperty(`--magic-notes-${resize.pane}-width`, `${resize.width}px`)
|
|
event.currentTarget.setAttribute('aria-valuenow', String(resize.width))
|
|
event.currentTarget.setAttribute('aria-valuetext', t(isNotes ? 'accessibility.notesPaneWidth' : isTodo ? 'accessibility.todoPaneWidth' : isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width: resize.width }))
|
|
}
|
|
const startPaneResize = (pane: 'ai' | 'index' | 'todo' | 'notes', event: React.PointerEvent<HTMLDivElement>): void => {
|
|
const limits = pane === 'notes' ? notesPaneWidthLimits : pane === 'todo' ? todoPaneWidthLimits : pane === 'ai' ? aiPaneWidthLimits : indexPaneWidthLimits
|
|
if (event.button !== 0 || paneResizeDisabled(pane) || limits.maximum <= limits.minimum) return
|
|
event.preventDefault()
|
|
paneResizeRef.current = { pane, pointerId: event.pointerId, width: pane === 'notes' ? displayedNotesWidth : pane === 'todo' ? displayedTodoWidth : pane === 'ai' ? displayedAiWidth : displayedIndexWidth }
|
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
setResizingPane(pane)
|
|
}
|
|
const renderPaneSeparator = (pane: 'ai' | 'index' | 'todo' | 'notes'): React.JSX.Element => {
|
|
const isAi = pane === 'ai'
|
|
const isTodo = pane === 'todo'
|
|
const isNotes = pane === 'notes'
|
|
const limits = isNotes ? notesPaneWidthLimits : isTodo ? todoPaneWidthLimits : isAi ? aiPaneWidthLimits : indexPaneWidthLimits
|
|
const width = isNotes ? displayedNotesWidth : isTodo ? displayedTodoWidth : isAi ? displayedAiWidth : displayedIndexWidth
|
|
const enabled = !paneResizeDisabled(pane) && limits.maximum > limits.minimum
|
|
const setWidth = isNotes ? setNotesPaneWidth : isTodo ? setTodoPaneWidth : isAi ? setAiPaneWidth : setIndexPaneWidth
|
|
return <div
|
|
aria-controls={isNotes ? 'magic-library-panel-notes' : isTodo ? 'magic-todo-list' : isAi ? 'magic-notes-ai-pane' : 'magic-notes-index'}
|
|
aria-disabled={!enabled}
|
|
aria-label={t(isNotes ? 'accessibility.resizeNotesPane' : isTodo ? 'accessibility.resizeTodoPane' : isAi ? 'accessibility.resizeAiPane' : 'accessibility.resizeIndexPane')}
|
|
aria-orientation="vertical"
|
|
aria-valuemax={limits.maximum}
|
|
aria-valuemin={limits.minimum}
|
|
aria-valuenow={width}
|
|
aria-valuetext={t(isNotes ? 'accessibility.notesPaneWidth' : isTodo ? 'accessibility.todoPaneWidth' : isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width })}
|
|
className={`magic-notes-pane-resize-handle magic-notes-${pane}-resize-handle`}
|
|
onKeyDown={(event) => {
|
|
if (!enabled) return
|
|
const direction = isAi ? -1 : 1
|
|
const next = event.key === 'Home' ? limits.minimum : event.key === 'End' ? limits.maximum
|
|
: event.key === 'ArrowRight' ? width + direction * magicNotesPaneKeyboardResizeStep
|
|
: event.key === 'ArrowLeft' ? width - direction * magicNotesPaneKeyboardResizeStep : undefined
|
|
if (next === undefined) return
|
|
event.preventDefault()
|
|
setWidth(clampMagicNotesPaneWidth(next, limits))
|
|
}}
|
|
onLostPointerCapture={finishPaneResize}
|
|
onPointerCancel={finishPaneResize}
|
|
onPointerDown={(event) => startPaneResize(pane, event)}
|
|
onPointerMove={updatePanePointerWidth}
|
|
onPointerUp={finishPaneResize}
|
|
role="separator"
|
|
tabIndex={enabled ? 0 : -1}
|
|
/>
|
|
}
|
|
|
|
const submitCreateNote = async (): Promise<void> => {
|
|
const title = newTitle.trim()
|
|
if (!title) {
|
|
setValidation({
|
|
target: 'create-note',
|
|
message: t('validation.createNoteTitle')
|
|
})
|
|
return
|
|
}
|
|
clearValidation('create-note')
|
|
requestDraftSwitch({
|
|
kind: 'create-note',
|
|
title
|
|
})
|
|
}
|
|
|
|
const analyzeTodo = async (todoId: string): Promise<void> => {
|
|
const operation = `analyze-todo-${todoId}`
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
let options: MagicTodoAnalysisOptions | undefined
|
|
try {
|
|
const source = selectedTodoSourceEntry
|
|
options = {
|
|
...await createAnalysisOptions(source?.content.version === 2 ? todoCanvasRef.current : undefined),
|
|
sourceEntryRevision: source?.revision
|
|
}
|
|
setLiveAnalysis({ ...options, content: '' })
|
|
applyTodo(
|
|
await window.goodbuddy.magicNotes.analyzeTodo(todoId, options)
|
|
)
|
|
notifySuccess(t('notifications.aiCommentAdded'))
|
|
} catch (analysisError) {
|
|
notifyError(analysisError)
|
|
} finally {
|
|
setLiveAnalysis((current) =>
|
|
current?.requestId === options?.requestId ? undefined : current
|
|
)
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const updateTodoCompletion = async (
|
|
todo: MagicTodoItem
|
|
): Promise<void> => {
|
|
const operation = `update-todo-${todo.id}`
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
try {
|
|
const completed = !todo.completed
|
|
const result = await window.goodbuddy.magicNotes.updateTodo({
|
|
todoId: todo.id,
|
|
completed,
|
|
expectedRevision: todo.revision
|
|
})
|
|
applyTodo(result.todo)
|
|
if (todoFilter !== 'all') {
|
|
requestAnimationFrame(() => {
|
|
if (document.activeElement === document.body) {
|
|
document.querySelector<HTMLElement>('#magic-library-panel-todos .magic-todo-list-item__check, #magic-library-panel-todos .segmented-control__option--active')?.focus()
|
|
}
|
|
})
|
|
}
|
|
if (selectedTodoId === todo.id) {
|
|
todoSourceRequestRef.current += 1
|
|
setTodoSourceDetail(result.note)
|
|
}
|
|
if (requestedNoteIdRef.current === result.note.id) {
|
|
applyDetail(result.note)
|
|
} else {
|
|
applyNoteSummary(result.note)
|
|
}
|
|
notifySuccess(
|
|
t(
|
|
completed
|
|
? 'notifications.todoCompleted'
|
|
: 'notifications.todoReopened'
|
|
)
|
|
)
|
|
} catch (updateError) {
|
|
notifyError(updateError)
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const updateTitle = async (): Promise<void> => {
|
|
if (!detail || titleDraft.trim() === detail.title) {
|
|
return
|
|
}
|
|
if (!titleDraft.trim()) {
|
|
setTitleDraft(detail.title)
|
|
setValidation({
|
|
target: 'note-title',
|
|
message: t('validation.noteTitleRequired')
|
|
})
|
|
return
|
|
}
|
|
clearValidation('note-title')
|
|
const operation = 'update-title'
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
try {
|
|
const updated = await window.goodbuddy.magicNotes.update({
|
|
noteId: detail.id,
|
|
title: titleDraft.trim(),
|
|
expectedRevision: detail.revision
|
|
})
|
|
applyDetail(updated, titleDraft)
|
|
await reloadTodos()
|
|
} catch (updateError) {
|
|
notifyError(updateError)
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const saveEntry = async (): Promise<void> => {
|
|
if (!detail) return
|
|
const operation = 'create-entry'
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
try {
|
|
const composerContent = entryType === 'canvas'
|
|
? await composerCanvasRef.current?.flush()
|
|
: composerContentRef.current
|
|
composerContentRef.current = composerContent
|
|
if (!composerContent || !hasContent(composerContent)) {
|
|
setValidation({ target: 'new-entry', message: t('validation.newEntryRequired') })
|
|
return
|
|
}
|
|
clearValidation('new-entry')
|
|
const shouldAnalyze = commentMode === 'after-save-auto' && (composerContent.version === 1 ||
|
|
composerContent.pages.some((page) => page.objects.length || page.background.type === 'pdf') ||
|
|
composerContent.flow?.ops.some((op) => typeof op.insert === 'string' && op.insert.trim()))
|
|
let options: MagicNoteAnalysisOptions | undefined
|
|
let analysisPreparationError: string | undefined
|
|
if (shouldAnalyze) {
|
|
try {
|
|
options = await createAnalysisOptions(composerContent.version === 2 ? composerCanvasRef.current : undefined)
|
|
} catch (error) {
|
|
analysisPreparationError = errorMessage(error, t('errors.operationFailed'))
|
|
}
|
|
}
|
|
const updated = await window.goodbuddy.magicNotes.createEntry({
|
|
noteId: detail.id,
|
|
content: composerContent
|
|
})
|
|
applyDetail(updated)
|
|
const createdEntry = updated.entries.find(
|
|
(entry) => entry.id === updated.createdEntryId
|
|
)
|
|
if (createdEntry) {
|
|
setSelectedEntryId(createdEntry.id)
|
|
}
|
|
composerContentRef.current = undefined
|
|
setPendingDraftSwitch(undefined)
|
|
clearDraftAnalysis()
|
|
setComposerKey((current) => current + 1)
|
|
setComposerHasDraft(false)
|
|
notifySuccess(t('notifications.entrySaved'))
|
|
if (analysisPreparationError !== undefined) notifyError(t('canvas.savedAnalysisFailed', { error: analysisPreparationError }))
|
|
try {
|
|
await reloadTodos()
|
|
} catch (refreshTodosError) {
|
|
notifyError(refreshTodosError)
|
|
}
|
|
if (options && createdEntry) {
|
|
setLiveAnalysis({
|
|
requestId: options.requestId,
|
|
content: '',
|
|
direction: options.direction,
|
|
format: options.format
|
|
})
|
|
try {
|
|
const analyzed = await window.goodbuddy.magicNotes.analyze(createdEntry.id, {
|
|
...options, expectedRevision: createdEntry.revision
|
|
})
|
|
applyDetail(analyzed)
|
|
notifySuccess(t('notifications.aiCommentAdded'))
|
|
} catch (analysisError) {
|
|
notifyError(analysisError)
|
|
} finally {
|
|
setLiveAnalysis((current) =>
|
|
current?.requestId === options.requestId
|
|
? undefined
|
|
: current
|
|
)
|
|
}
|
|
}
|
|
} catch (saveError) {
|
|
notifyError(saveError)
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const saveEditedEntry = async (): Promise<void> => {
|
|
if (!editingEntry) return
|
|
const operation = `edit-${editingEntry.id}`
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
try {
|
|
const editingContent = editingEntry.content.version === 2
|
|
? await editingCanvasRef.current?.flush() : editingContentRef.current
|
|
editingContentRef.current = editingContent
|
|
if (!editingContent || !hasContent(editingContent)) {
|
|
setValidation({ target: 'edit-entry', message: t('validation.entryRequired') })
|
|
return
|
|
}
|
|
clearValidation('edit-entry')
|
|
const currentEntry = detail?.entries.find((entry) => entry.id === editingEntry.id) ?? editingEntry
|
|
const shouldAnalyze = commentMode === 'after-save-auto' &&
|
|
(editingContent.version === 1 || currentEntry.content.version === 1 ||
|
|
(currentEntry.comments.some((comment) => comment.inputMode === 'canvas-images')
|
|
? !richContentEqual(editingContent, currentEntry.content)
|
|
: currentEntry.comments.length > 0
|
|
? magicNoteCanvasAnalysisText(editingContent) !== magicNoteCanvasAnalysisText(currentEntry.content)
|
|
: analysisContentKey(editingContent) !== analysisContentKey(currentEntry.content)))
|
|
let options: MagicNoteAnalysisOptions | undefined
|
|
let analysisPreparationError: string | undefined
|
|
if (shouldAnalyze) {
|
|
try {
|
|
options = await createAnalysisOptions(editingContent.version === 2 ? editingCanvasRef.current : undefined)
|
|
} catch (error) {
|
|
analysisPreparationError = errorMessage(error, t('errors.operationFailed'))
|
|
}
|
|
}
|
|
const updated = await window.goodbuddy.magicNotes.updateEntry({
|
|
entryId: editingEntry.id,
|
|
content: editingContent,
|
|
expectedRevision: editingEntry.revision
|
|
})
|
|
applyDetail(updated)
|
|
const savedEntry = updated.entries.find((entry) => entry.id === editingEntry.id)
|
|
setEditingEntry(undefined)
|
|
editingBaselineRef.current = undefined
|
|
editingContentRef.current = undefined
|
|
clearDraftAnalysis()
|
|
notifySuccess(t('notifications.entryUpdated'))
|
|
if (analysisPreparationError !== undefined) notifyError(t('canvas.savedAnalysisFailed', { error: analysisPreparationError }))
|
|
try {
|
|
await reloadTodos()
|
|
} catch (refreshTodosError) {
|
|
notifyError(refreshTodosError)
|
|
}
|
|
if (options && savedEntry) {
|
|
setLiveAnalysis({
|
|
requestId: options.requestId,
|
|
content: '',
|
|
direction: options.direction,
|
|
format: options.format
|
|
})
|
|
try {
|
|
const analyzed = await window.goodbuddy.magicNotes.analyze(savedEntry.id, {
|
|
...options, expectedRevision: savedEntry.revision
|
|
})
|
|
applyDetail(analyzed)
|
|
notifySuccess(t('notifications.aiCommentAdded'))
|
|
} catch (analysisError) {
|
|
notifyError(analysisError)
|
|
} finally {
|
|
setLiveAnalysis((current) =>
|
|
current?.requestId === options.requestId
|
|
? undefined
|
|
: current
|
|
)
|
|
}
|
|
}
|
|
} catch (updateError) {
|
|
notifyError(updateError)
|
|
} finally {
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const analyzeEntry = async (entryId: string): Promise<void> => {
|
|
const operation = `analyze-${entryId}`
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
let options: MagicNoteEntryAnalysisOptions | undefined
|
|
try {
|
|
const entry = detail?.entries.find((entry) => entry.id === entryId)
|
|
options = {
|
|
...await createAnalysisOptions(entry?.content.version === 2 ? canvasViewRefs.current.get(entryId) ?? null : undefined),
|
|
expectedRevision: entry?.revision
|
|
}
|
|
setLiveAnalysis({ ...options, content: '' })
|
|
applyDetail(
|
|
await window.goodbuddy.magicNotes.analyze(entryId, options)
|
|
)
|
|
notifySuccess(t('notifications.aiCommentAdded'))
|
|
} catch (analysisError) {
|
|
notifyError(analysisError)
|
|
} finally {
|
|
setLiveAnalysis((current) =>
|
|
current?.requestId === options?.requestId ? undefined : current
|
|
)
|
|
endBusy(operation)
|
|
}
|
|
}
|
|
|
|
const analyzeCanvasDraft = async (editing: boolean): Promise<void> => {
|
|
const editor = editing ? editingCanvasRef.current : composerCanvasRef.current
|
|
if (!editor || !beginBusy('analyze-canvas-draft')) return
|
|
let options: MagicNoteAnalysisOptions | undefined
|
|
try {
|
|
const content = await editor.flush()
|
|
if (editing) editingContentRef.current = content
|
|
else composerContentRef.current = content
|
|
if (!hasContent(content)) {
|
|
setValidation({ target: editing ? 'edit-entry' : 'new-entry', message: t('validation.newEntryRequired') })
|
|
return
|
|
}
|
|
clearCanvasDraftAnalysis()
|
|
const analysisContext = canvasDraftContextRef.current
|
|
options = await createAnalysisOptions(editor)
|
|
setLiveAnalysis({ ...options, content: '' })
|
|
const analysis = await window.goodbuddy.magicNotes.analyzeDraft(content, options)
|
|
if (analysisContext === canvasDraftContextRef.current) setCanvasDraftAnalysis(analysis)
|
|
} catch (error) {
|
|
notifyError(error)
|
|
} finally {
|
|
setLiveAnalysis((current) => current?.requestId === options?.requestId ? undefined : current)
|
|
endBusy('analyze-canvas-draft')
|
|
}
|
|
}
|
|
|
|
const renderNoteCard = (note: MagicNoteSummary, index: number): React.JSX.Element => (
|
|
<div className="magic-note-row" key={note.id} {...(noteWindow.windowed ? { role: 'listitem', 'aria-posinset': index + 1, 'aria-setsize': visibleNotes.length } : {})}>
|
|
<button
|
|
id={`magic-note-select-${note.id}`}
|
|
className="magic-note-list-item"
|
|
aria-current={selectedNoteId === note.id ? 'true' : undefined}
|
|
type="button"
|
|
onClick={() => {
|
|
if (selectedNoteId === note.id) {
|
|
if (notesDrawerOpen) closeNotesDrawer()
|
|
return
|
|
}
|
|
void requestDraftSwitch({
|
|
kind: 'note',
|
|
noteId: note.id
|
|
})
|
|
}}
|
|
>
|
|
<span className="magic-note-list-item__title">
|
|
{note.pinned && (
|
|
<Pin aria-label={t('status.pinned')} size={12} />
|
|
)}
|
|
<span className="magic-note-list-item__title-text">{note.title}</span>
|
|
<time dateTime={note.updatedAt} title={t('notes.updatedAt', { date: dateFormatter.format(new Date(note.updatedAt)) })}>
|
|
{relativeDate(note.updatedAt)}
|
|
</time>
|
|
</span>
|
|
<span className="magic-note-list-item__preview">
|
|
{note.preview || t('notes.noPreview')}
|
|
</span>
|
|
{!detailView && <MagicNoteTagChips tags={note.tags ?? []} />}
|
|
<span className={detailView ? 'sr-only' : 'magic-note-list-item__meta magic-note-list-item__meta--overview'}>
|
|
{t(
|
|
note.entryCount === 1
|
|
? 'notes.entryCountOne'
|
|
: 'notes.entryCountOther',
|
|
{ count: note.entryCount }
|
|
)}
|
|
</span>
|
|
</button>
|
|
<button
|
|
aria-controls={`magic-note-actions-${note.id}`}
|
|
aria-expanded={noteActionsId === note.id}
|
|
aria-haspopup="menu"
|
|
aria-label={t('actions.more', { title: note.title })}
|
|
title={t('actions.more', { title: note.title })}
|
|
className="magic-note-more icon-button"
|
|
type="button"
|
|
onClick={(event) => {
|
|
noteActionTriggerRef.current = event.currentTarget
|
|
setDeletingNote(false)
|
|
setNoteActionsId((current) => current === note.id ? '' : note.id)
|
|
}}
|
|
>
|
|
<MoreHorizontal aria-hidden="true" size={14} />
|
|
</button>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="magic-notes-page">
|
|
<PageHeader
|
|
actions={
|
|
<>
|
|
{detailView && (
|
|
<button
|
|
id="magic-notes-back"
|
|
className="secondary-button"
|
|
onClick={() => requestDraftSwitch({ kind: 'overview' })}
|
|
disabled={Boolean(busy)}
|
|
type="button"
|
|
>
|
|
<ArrowLeft aria-hidden="true" size={15} />
|
|
{t(libraryView === 'todos' ? 'actions.backToTodos' : 'actions.backToOverview')}
|
|
</button>
|
|
)}
|
|
{!detailView && (
|
|
<>
|
|
<button
|
|
id="magic-library-switch"
|
|
className="secondary-button"
|
|
disabled={Boolean(busy)}
|
|
onClick={() => requestDraftSwitch({ kind: 'library-view', value: libraryView === 'notes' ? 'todos' : 'notes' })}
|
|
type="button"
|
|
>
|
|
{libraryView === 'notes' ? <ListTodo aria-hidden="true" size={15} /> : <BookOpen aria-hidden="true" size={15} />}
|
|
{t(libraryView === 'notes' ? 'actions.switchToTodos' : 'actions.switchToNotes')}
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
id="magic-note-new"
|
|
className="primary-button"
|
|
disabled={Boolean(busy)}
|
|
type="button"
|
|
onClick={() => {
|
|
setValidation(undefined)
|
|
setLibraryView('notes')
|
|
setCreating(true)
|
|
if (detailView) {
|
|
if (isNarrowWorkspace) void requestDraftSwitch({ kind: 'overview' })
|
|
else setNotesPaneOpen(true)
|
|
}
|
|
}}
|
|
>
|
|
<Plus aria-hidden="true" size={15} />
|
|
{t('actions.newNote')}
|
|
</button>
|
|
</>
|
|
}
|
|
help={t('page.description')}
|
|
headingId="magic-notes-title"
|
|
icon={<Sparkles size={20} />}
|
|
scope={{ kind: 'global' }}
|
|
title={t('page.title')}
|
|
/>
|
|
|
|
{loadStatus === 'error' ? (
|
|
<EmptyState
|
|
action={
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => void refreshNotes()}
|
|
type="button"
|
|
>
|
|
{t('actions.retry')}
|
|
</button>
|
|
}
|
|
description={t('errors.initialLoadDescription', {
|
|
error: loadError
|
|
})}
|
|
icon={<CircleAlert size={24} />}
|
|
level="page"
|
|
title={t('errors.initialLoadTitle')}
|
|
/>
|
|
) : (
|
|
<>
|
|
{refreshError && (
|
|
<div className="magic-note-delete-confirmation" role="alert">
|
|
<span>
|
|
{t('errors.refreshFailed', { error: refreshError })}
|
|
</span>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => void refreshNotes(selectedNoteId, true)}
|
|
type="button"
|
|
>
|
|
{t('actions.retry')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={workspaceRef}
|
|
aria-busy={Boolean(busy)}
|
|
className={`magic-notes-workspace${notesSplit || notesDrawerOpen ? ' magic-notes-workspace--notes' : ''}${notesDrawerOpen ? ' magic-notes-workspace--drawer' : ''}${libraryView === 'notes' && !detailView ? ' magic-notes-workspace--overview' : ''}${notesExpanded ? '' : ' magic-notes-workspace--list-hidden'}${isNarrowWorkspace ? ' magic-notes-workspace--narrow' : ''}${resizingPane === 'notes' ? ' magic-notes-layout--resizing' : ''}`}
|
|
style={{ '--magic-notes-notes-width': `${displayedNotesWidth}px` } as React.CSSProperties}
|
|
>
|
|
{notesDrawerOpen && <div className="magic-notes-list-backdrop" aria-hidden="true" onClick={closeNotesDrawer} />}
|
|
<section
|
|
aria-label={t(
|
|
libraryView === 'notes'
|
|
? 'notes.listLabel'
|
|
: 'todos.listLabel'
|
|
)}
|
|
className={`magic-notes-overview${libraryView === 'todos' ? ' magic-notes-overview--todos' : ''}`}
|
|
hidden={libraryView === 'notes' ? !notesExpanded : Boolean(detailView)}
|
|
onKeyDown={(event) => {
|
|
if (notesDrawerOpen && event.key === 'Escape') {
|
|
event.stopPropagation()
|
|
closeNotesDrawer()
|
|
}
|
|
}}
|
|
>
|
|
{libraryView === 'notes' ? (
|
|
<div
|
|
className="magic-notes-library-panel"
|
|
id="magic-library-panel-notes"
|
|
>
|
|
<div className="magic-notes-pane-heading">
|
|
<strong>{t('notes.heading')}</strong>
|
|
<span>{notes.length}</span>
|
|
{notesDrawerOpen && <button type="button" className="icon-button" aria-label={t('actions.hideNotes')} onClick={closeNotesDrawer}><PanelLeftClose aria-hidden="true" size={16} /></button>}
|
|
<MagicNoteTagManagerButton
|
|
compact={Boolean(detailView)}
|
|
disabled={Boolean(busy)}
|
|
onClick={() => setTagManagerOpen(true)}
|
|
/>
|
|
</div>
|
|
<label className="magic-notes-search">
|
|
<span className="sr-only">{t('notes.searchLabel')}</span>
|
|
<input
|
|
placeholder={t('notes.searchPlaceholder')}
|
|
type="search"
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
</label>
|
|
{detailView ? (
|
|
<MagicNoteActiveTagFilter names={activeTagFilterNames} onClear={() => setTagFilter([])} />
|
|
) : (
|
|
<MagicNoteTagFilter
|
|
tags={noteTags}
|
|
selected={activeTagFilter}
|
|
onChange={setTagFilter}
|
|
/>
|
|
)}
|
|
{creating && (
|
|
<form
|
|
className="magic-notes-create"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
void submitCreateNote()
|
|
}}
|
|
>
|
|
<label>
|
|
<span>{t('notes.titleLabel')}</span>
|
|
<input
|
|
aria-describedby={
|
|
validation?.target === 'create-note'
|
|
? 'magic-note-create-error'
|
|
: undefined
|
|
}
|
|
aria-invalid={validation?.target === 'create-note'}
|
|
autoFocus
|
|
maxLength={100}
|
|
value={newTitle}
|
|
onChange={(event) => {
|
|
setNewTitle(event.target.value)
|
|
clearValidation('create-note')
|
|
}}
|
|
/>
|
|
</label>
|
|
{validation?.target === 'create-note' && (
|
|
<small
|
|
className="magic-notes-field-error"
|
|
id="magic-note-create-error"
|
|
role="alert"
|
|
>
|
|
{validation.message}
|
|
</small>
|
|
)}
|
|
<div>
|
|
<button
|
|
className="secondary-button"
|
|
type="button"
|
|
onClick={() => {
|
|
setCreating(false)
|
|
clearValidation('create-note')
|
|
}}
|
|
>
|
|
{t('actions.cancel')}
|
|
</button>
|
|
<button
|
|
className="primary-button"
|
|
disabled={busy === 'create-note'}
|
|
type="submit"
|
|
>
|
|
{t('actions.createNote')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
<div
|
|
ref={noteGridRef}
|
|
className={`magic-notes-list magic-notes-card-grid${noteWindow.windowed ? ' magic-notes-card-grid--windowed' : ''}`}
|
|
role={noteWindow.windowed ? 'list' : undefined}
|
|
aria-label={noteWindow.windowed ? t('notes.listLabel') : undefined}
|
|
onBlur={noteWindow.onBlur}
|
|
onFocus={noteWindow.onFocus}
|
|
onScroll={noteWindow.onScroll}
|
|
>
|
|
{loadStatus === 'loading' ? (
|
|
<p className="magic-notes-muted">
|
|
{t('status.loadingNotes')}
|
|
</p>
|
|
) : visibleNotes.length === 0 ? (
|
|
<>
|
|
<p className="magic-notes-muted">
|
|
{search.trim() || activeTagFilter.length > 0
|
|
? t('notes.noMatches')
|
|
: t('notes.empty')}
|
|
</p>
|
|
{(search.trim() || activeTagFilter.length > 0) && (
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => { setSearch(''); setTagFilter([]) }}
|
|
type="button"
|
|
>
|
|
{t('actions.clearFilters')}
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
noteWindow.windowed ? noteWindow.segments.map((segment) => segment.kind === 'spacer' ? (
|
|
<div aria-hidden="true" className="magic-notes-list__spacer" key={`spacer:${segment.key}`} style={{ height: `${segment.height}px` }} />
|
|
) : (
|
|
<div className="magic-notes-window-row" data-magic-note-window-row={segment.key} key={segment.key} ref={noteWindow.rowRef(segment.key)} role="none" style={{ paddingBottom: `${noteWindow.gap}px` }}>
|
|
{visibleNotes.slice(segment.start, segment.end).map((note, offset) => renderNoteCard(note, segment.start + offset))}
|
|
</div>
|
|
)) : visibleNotes.map((note, index) => renderNoteCard(note, index))
|
|
)}
|
|
{actionNote && createPortal(
|
|
<div
|
|
className="conversation-actions magic-note-actions"
|
|
id={`magic-note-actions-${actionNote.id}`}
|
|
aria-label={t('actions.more', { title: actionNote.title })}
|
|
ref={noteActionsRef}
|
|
role="menu"
|
|
onKeyDown={(event) => {
|
|
if (event.defaultPrevented) return
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
closeNoteActions()
|
|
return
|
|
}
|
|
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
|
|
event.preventDefault()
|
|
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'))
|
|
const index = items.indexOf(document.activeElement as HTMLButtonElement)
|
|
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
|
|
items[next]?.focus()
|
|
}}
|
|
>
|
|
<button role="menuitem" type="button" disabled={Boolean(busy)} onClick={() => void pinNote(actionNote)}>
|
|
{actionNote.pinned ? <PinOff size={14} /> : <Pin size={14} />}
|
|
{t(actionNote.pinned ? 'actions.unpinNote' : 'actions.pinNote')}
|
|
</button>
|
|
<DestructiveConfirmActions
|
|
triggerRole="menuitem"
|
|
triggerLabel={t('actions.deleteNote')}
|
|
confirmLabel={t('actions.deleteNote')}
|
|
confirming={deletingNote}
|
|
disabled={Boolean(busy)}
|
|
icon={<Trash2 size={14} />}
|
|
message={t('confirmations.deleteNote', { title: actionNote.title })}
|
|
onCancel={() => setDeletingNote(false)}
|
|
onRequestConfirm={() => setDeletingNote(true)}
|
|
onConfirm={() => {
|
|
if (actionNote.id === requestedNoteIdRef.current) {
|
|
void requestDraftSwitch({ kind: 'delete-note', remove: () => { void deleteNote(actionNote) } })
|
|
} else {
|
|
void deleteNote(actionNote)
|
|
}
|
|
}}
|
|
/>
|
|
</div>, document.body
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="magic-notes-library-panel"
|
|
id="magic-library-panel-todos"
|
|
>
|
|
<div className="magic-notes-pane-heading">
|
|
<strong>{t('todos.heading')}</strong>
|
|
<span>{t('todos.resultCount', { count: visibleTodos.length, total: todos.length })}</span>
|
|
</div>
|
|
<div className="magic-todo-toolbar">
|
|
<label className="magic-notes-search">
|
|
<span className="sr-only">{t('todos.searchLabel')}</span>
|
|
<input
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder={t('todos.searchPlaceholder')}
|
|
type="search"
|
|
value={search}
|
|
/>
|
|
</label>
|
|
<SegmentedControl
|
|
ariaLabel={t('todos.filterLabel')}
|
|
onChange={setTodoFilter}
|
|
options={todoFilters}
|
|
value={todoFilter}
|
|
/>
|
|
</div>
|
|
<div ref={todoLayoutRef} style={{ '--magic-notes-todo-width': `${displayedTodoWidth}px` } as React.CSSProperties} className={`magic-todo-workspace${selectedTodo ? ' magic-todo-workspace--selected' : ''}${resizingPane === 'todo' ? ' magic-todo-workspace--resizing' : ''}`}>
|
|
<div className="magic-notes-list" id="magic-todo-list">
|
|
{loadStatus === 'loading' ? (
|
|
<p className="magic-notes-muted">
|
|
{t('status.loadingTodos')}
|
|
</p>
|
|
) : visibleTodos.length === 0 ? (
|
|
<>
|
|
<p className="magic-notes-muted">
|
|
{todos.length === 0
|
|
? t('todos.empty')
|
|
: search.trim() || todoFilter !== 'all'
|
|
? t('todos.noMatches')
|
|
: t('todos.empty')}
|
|
</p>
|
|
{todos.length > 0 &&
|
|
(Boolean(search.trim()) ||
|
|
todoFilter !== 'all') && (
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => {
|
|
setSearch('')
|
|
setTodoFilter('all')
|
|
}}
|
|
type="button"
|
|
>
|
|
{t('actions.clearFilters')}
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
todoDirectories.map((directory) => (
|
|
<MagicTodoDirectory
|
|
key={directory.noteId}
|
|
noteId={directory.noteId}
|
|
title={directory.noteTitle}
|
|
count={directory.todos.length}
|
|
>
|
|
{directory.todos.map((todo) => (
|
|
<div className="magic-todo-task" key={todo.id}>
|
|
<TodoListItem
|
|
disabled={Boolean(busy)}
|
|
expanded={selectedTodo?.id === todo.id}
|
|
id={`magic-todo-select-${todo.id}`}
|
|
onSelect={() => {
|
|
overviewFocusRef.current = `magic-todo-select-${todo.id}`
|
|
setSelectedTodoId((current) => current === todo.id ? '' : todo.id)
|
|
requestAnimationFrame(() => {
|
|
const back = document.getElementById('magic-todo-back')
|
|
if (back?.checkVisibility?.()) back.focus({ preventScroll: true })
|
|
})
|
|
}}
|
|
onToggle={() =>
|
|
void updateTodoCompletion(todo)
|
|
}
|
|
todo={todo}
|
|
/>
|
|
</div>
|
|
))}
|
|
</MagicTodoDirectory>
|
|
))
|
|
)}
|
|
</div>
|
|
{renderPaneSeparator('todo')}
|
|
{selectedTodo ? (
|
|
<section
|
|
aria-label={t('todos.detailLabel')}
|
|
className="magic-todo-detail"
|
|
id={`magic-todo-detail-${selectedTodo.id}`}
|
|
key={selectedTodo.id}
|
|
>
|
|
<button id="magic-todo-back" className="secondary-button" disabled={Boolean(busy)} type="button" onClick={() => {
|
|
setSelectedTodoId('')
|
|
requestAnimationFrame(() => document.getElementById(`magic-todo-select-${selectedTodo.id}`)?.focus({ preventScroll: true }))
|
|
}}>
|
|
<ArrowLeft aria-hidden="true" size={14} />
|
|
{t('actions.backToTodoList')}
|
|
</button>
|
|
<h3>{selectedTodo.title}</h3>
|
|
<p className="magic-todo-instructions">{selectedTodo.instructions || t('todos.defaultInstructions')}</p>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={Boolean(busy)}
|
|
onClick={() => requestDraftSwitch({ kind: 'note', noteId: selectedTodo.noteId, entryId: selectedTodo.entryId })}
|
|
type="button"
|
|
>
|
|
<BookOpen aria-hidden="true" size={14} />
|
|
{t('actions.openSourceNote')}
|
|
</button>
|
|
<section aria-label={t('todos.sourceEntryLabel')} className="magic-todo-source-entry">
|
|
<header><strong>{t('todos.sourceEntryHeading')}</strong></header>
|
|
{selectedTodoSourceEntry ? (
|
|
<MagicNoteContent content={selectedTodoSourceEntry.content} canvasRef={todoCanvasRef} onError={notifyError} />
|
|
) : todoSourceStatus === 'error' ? (
|
|
<button className="secondary-button" onClick={() => setTodoSourceRetry((current) => current + 1)} type="button">{t('todos.retrySource')}</button>
|
|
) : (
|
|
<p className="magic-notes-muted">{t(todoSourceStatus === 'loading' || todoSourceDetail?.id !== selectedTodo.noteId ? 'todos.loadingSource' : 'todos.sourceEntryMissing')}</p>
|
|
)}
|
|
</section>
|
|
<section aria-label={t('comments.paneLabel')} className="magic-todo-comments">
|
|
<strong>{t('comments.paneLabel')}</strong>
|
|
<div className="magic-notes-ai-controls">
|
|
<div>
|
|
<div className="inline-help-label">
|
|
<label htmlFor="magic-todo-comment-direction">{t('comments.directionLabel')}</label>
|
|
<InlineHelp label={t('comments.directionLabel')}>{t('comments.directionHelp')}</InlineHelp>
|
|
</div>
|
|
<select
|
|
id="magic-todo-comment-direction"
|
|
aria-label={t('comments.directionAriaLabel')}
|
|
onChange={(event) => setCommentDirection(event.target.value as MagicNoteCommentDirection)}
|
|
value={commentDirection}
|
|
>
|
|
{commentDirections.map((direction) => <option key={direction.value} value={direction.value}>{direction.label}</option>)}
|
|
</select>
|
|
</div>
|
|
<button className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeTodo(selectedTodo.id)} type="button">
|
|
<Bot aria-hidden="true" size={14} />
|
|
{t(busy === `analyze-todo-${selectedTodo.id}` ? 'actions.analyzing' : selectedTodo.analyzedAt ? 'actions.analyzeAgain' : 'actions.analyze')}
|
|
</button>
|
|
</div>
|
|
{liveAnalysis && busy === `analyze-todo-${selectedTodo.id}` && (
|
|
<div className="magic-notes-ai-live" aria-live="polite">
|
|
<p role="status">{t(liveAnalysis.format === 'structured' ? 'status.generatingPoints' : 'status.generatingDirection', { direction: commentDirectionLabels[liveAnalysis.direction] })}</p>
|
|
{liveAnalysis.format !== 'structured' && <div className="markdown-content"><MarkdownRenderer>{liveAnalysis.content || t('status.preparingComment')}</MarkdownRenderer></div>}
|
|
</div>
|
|
)}
|
|
{selectedTodo.comments.map((comment) => <AiComment comment={comment} key={comment.id} />)}
|
|
{!liveAnalysis && selectedTodo.comments.length === 0 && <p className="magic-notes-muted">{t('comments.analyzeTodoHint')}</p>}
|
|
</section>
|
|
</section>
|
|
) : (
|
|
<div className="magic-todo-detail magic-todo-detail--empty">
|
|
<EmptyState icon={<ListTodo size={24} />} title={t('todos.emptySelectionTitle')} description={t('todos.emptySelectionDescription')} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{notesSplit && renderPaneSeparator('notes')}
|
|
<div className="magic-notes-detail-container" hidden={!detailView}>
|
|
<div
|
|
ref={magicNotesLayoutRef}
|
|
className={`magic-notes-layout${detailView ? ' magic-notes-layout--detail' : ' magic-notes-layout--overview'}${aiPaneOpen ? '' : ' magic-notes-layout--ai-hidden'}${indexExpanded ? '' : ' magic-notes-layout--index-hidden'}${resizingPane && !paneResizeDisabled(resizingPane) ? ` magic-notes-layout--resizing magic-notes-layout--${resizingPane}-resizing` : ''}`}
|
|
style={{ '--magic-notes-ai-width': `${displayedAiWidth}px`, '--magic-notes-index-width': `${displayedIndexWidth}px` } as React.CSSProperties}
|
|
>
|
|
{detailView && (
|
|
<>
|
|
<aside className={`magic-notes-index-pane${narrowIndexOpen ? ' magic-notes-index-pane--drawer-open' : ''}`} hidden={!indexExpanded} aria-label={t('records.pane')} onKeyDown={(event) => {
|
|
if (event.key === 'Escape') { setNarrowIndexOpen(false); document.getElementById('magic-notes-index-toggle')?.focus() }
|
|
}}>
|
|
<nav id="magic-notes-index" className="magic-note-records" aria-label={t('records.title')} hidden={!indexExpanded}>
|
|
{displayedEntries.map((entry) => <button key={entry.id} type="button" className="magic-note-record" aria-current={selectedEntryId === entry.id ? 'true' : undefined} onClick={() => void requestDraftSwitch({ kind: 'select-entry', entry })}>
|
|
{entry.content.version === 2 ? <MagicCanvasThumbnail content={entry.content} /> : <span className="magic-note-record__summary">{entry.plainText || t('notes.noPreview')}</span>}
|
|
<span>{t(entry.content.version === 2 ? 'canvas.canvas' : 'canvas.text')}{entry.content.version === 2 && ` · ${t('records.pages', { count: entry.content.pages.length })}`}</span>
|
|
<time dateTime={entry.createdAt}>{dateFormatter.format(new Date(entry.createdAt))}</time>
|
|
</button>)}
|
|
</nav>
|
|
</aside>
|
|
{indexExpanded && !isNarrowLayout && renderPaneSeparator('index')}
|
|
<section
|
|
aria-label={t('notes.streamLabel')}
|
|
className="magic-notes-stream-pane"
|
|
ref={streamRef}
|
|
onScroll={(event) => {
|
|
if (detail) noteScrollPositions.current.set(detail.id, event.currentTarget.scrollTop)
|
|
}}
|
|
>
|
|
<div className="magic-note-detail-toolbar" role="toolbar" aria-label={t('notes.toolbarLabel')}>
|
|
{libraryView === 'notes' && (
|
|
<button id="magic-notes-list-toggle" type="button" className="icon-button" aria-controls="magic-library-panel-notes"
|
|
aria-expanded={notesExpanded} aria-label={t(notesExpanded ? 'actions.hideNotes' : 'actions.showNotes')}
|
|
title={t(notesExpanded ? 'actions.hideNotes' : 'actions.showNotes')}
|
|
onClick={() => isNarrowWorkspace ? setNarrowNotesOpen((current) => !current) : setNotesPaneOpen((current) => !current)}>
|
|
{notesExpanded ? <PanelLeftClose aria-hidden="true" size={16} /> : <PanelLeftOpen aria-hidden="true" size={16} />}
|
|
</button>
|
|
)}
|
|
<button
|
|
id="magic-notes-index-toggle"
|
|
type="button"
|
|
className="icon-button"
|
|
aria-controls="magic-notes-index"
|
|
aria-expanded={indexExpanded}
|
|
aria-label={t(indexExpanded ? 'records.hide' : 'records.show')}
|
|
title={t(indexExpanded ? 'records.hide' : 'records.show')}
|
|
onClick={() => isNarrowLayout
|
|
? setNarrowIndexOpen((current) => !current)
|
|
: setIndexPaneOpen((current) => !current)}
|
|
>
|
|
<ListTree aria-hidden="true" size={16} />
|
|
</button>
|
|
{detail && <span className="magic-note-detail-toolbar__meta">
|
|
{t(detail.entries.length === 1 ? 'notes.entryCountOne' : 'notes.entryCountOther', { count: detail.entries.length })}
|
|
{' · '}
|
|
<time dateTime={detail.updatedAt}>{t('notes.updatedAt', { date: dateFormatter.format(new Date(detail.updatedAt)) })}</time>
|
|
</span>}
|
|
<button
|
|
aria-controls="magic-notes-ai-pane"
|
|
aria-expanded={aiPaneOpen}
|
|
aria-label={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
|
|
className="icon-button magic-note-detail-toolbar__ai"
|
|
onClick={toggleAiPane}
|
|
title={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
|
|
type="button"
|
|
>
|
|
{aiPaneOpen ? <PanelRightClose aria-hidden="true" size={16} /> : <PanelRightOpen aria-hidden="true" size={16} />}
|
|
</button>
|
|
</div>
|
|
{!detail ? (
|
|
<EmptyState
|
|
action={detailLoadError ? (
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => void loadDetail(detailLoadError.noteId)}
|
|
type="button"
|
|
>
|
|
{t('actions.retry')}
|
|
</button>
|
|
) : undefined}
|
|
description={detailLoadError ? detailLoadError.message : t('status.loadingNotes')}
|
|
icon={<FileText size={24} />}
|
|
title={
|
|
detailLoadError ? t('errors.initialLoadTitle') : t('status.loading')
|
|
}
|
|
/>
|
|
) : (
|
|
<>
|
|
{detailLoadError && (
|
|
<div className="magic-note-delete-confirmation" role="alert">
|
|
<span>
|
|
{t('errors.detailLoadFailed', {
|
|
error: detailLoadError.message
|
|
})}
|
|
</span>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
requestDraftSwitch({
|
|
kind: 'note',
|
|
noteId: detailLoadError.noteId
|
|
})
|
|
}
|
|
type="button"
|
|
>
|
|
{t('actions.retry')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<header className="magic-note-detail-header">
|
|
<input
|
|
className="magic-note-detail-title"
|
|
aria-describedby={
|
|
validation?.target === 'note-title'
|
|
? 'magic-note-title-error'
|
|
: undefined
|
|
}
|
|
aria-invalid={validation?.target === 'note-title'}
|
|
aria-label={t('notes.titleLabel')}
|
|
maxLength={100}
|
|
value={titleDraft}
|
|
onBlur={(event) => {
|
|
if (event.relatedTarget instanceof Element && event.relatedTarget.closest('.magic-note-more, .magic-note-actions')) return
|
|
void updateTitle()
|
|
}}
|
|
onChange={(event) => {
|
|
setTitleDraft(event.target.value)
|
|
clearValidation('note-title')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.currentTarget.blur()
|
|
}
|
|
if (event.key === 'Escape') {
|
|
setTitleDraft(detail.title)
|
|
event.currentTarget.blur()
|
|
}
|
|
}}
|
|
/>
|
|
</header>
|
|
{validation?.target === 'note-title' && (
|
|
<p
|
|
className="magic-notes-field-error"
|
|
id="magic-note-title-error"
|
|
role="alert"
|
|
>
|
|
{validation.message}
|
|
</p>
|
|
)}
|
|
<MagicNoteTagEditor
|
|
key={detail.id}
|
|
tags={detail.tags ?? []}
|
|
allTags={noteTags}
|
|
disabled={Boolean(busy)}
|
|
onChange={updateNoteTags}
|
|
/>
|
|
|
|
{!editingEntry && <div
|
|
className={`magic-note-composer${composerHasDraft || entryType === 'canvas' || validation?.target === 'new-entry' ? ' magic-note-composer--active' : ''}`}
|
|
ref={composerRef}
|
|
inert={Boolean(busy)}
|
|
>
|
|
<div className="magic-note-composer__header">
|
|
<div className="magic-note-entry-type" role="group" aria-label={t('canvas.entryType')}>
|
|
{(['text', 'canvas'] as const).map((value) => <button key={value} type="button" className="secondary-button" aria-pressed={entryType === value} disabled={Boolean(busy) || entryType === value} onClick={() => void requestDraftSwitch({ kind: 'entry-type', value })}>{t(`canvas.${value}`)}</button>)}
|
|
{entryType === 'text' && <InlineHelp label={t('notes.newEntryLabel')}>
|
|
<p>{commentMode === 'immediate'
|
|
? t('notes.composerImmediateHint')
|
|
: t('notes.composerRichTextHint')}</p>
|
|
{commentMode === 'after-save-auto' && <p>{t('comments.autoHint')}</p>}
|
|
</InlineHelp>}
|
|
</div>
|
|
{entryType === 'canvas' && <div className="magic-note-canvas-actions">
|
|
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(false)}>{t('canvas.analyzeDraft')}</button>
|
|
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}>{t('actions.cancel')}</button>
|
|
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => void saveEntry()}>{t('actions.saveEntry')}</button>
|
|
</div>}
|
|
</div>
|
|
{entryType === 'canvas' ? <MagicCanvasEditor
|
|
key={`${detail.id}-${composerKey}-canvas`}
|
|
ref={composerCanvasRef}
|
|
disabled={Boolean(busy)}
|
|
onChange={(content) => {
|
|
if (!richContentEqual(composerContentRef.current, content)) clearCanvasDraftAnalysis()
|
|
composerContentRef.current = content
|
|
clearValidation('new-entry')
|
|
}}
|
|
onError={notifyError}
|
|
/> : <MagicNoteEditor
|
|
key={`${detail.id}-${composerKey}`}
|
|
ariaDescribedBy={
|
|
validation?.target === 'new-entry'
|
|
? 'magic-note-entry-create-error'
|
|
: undefined
|
|
}
|
|
ariaInvalid={validation?.target === 'new-entry'}
|
|
ariaLabel={t('notes.newEntryLabel')}
|
|
onChange={(content) => {
|
|
composerContentRef.current = content
|
|
setComposerHasDraft(hasContent(content))
|
|
clearValidation('new-entry')
|
|
if (
|
|
commentMode === 'immediate' &&
|
|
draftAnalysisArmedRef.current
|
|
) {
|
|
scheduleDraftAnalysis(content)
|
|
}
|
|
}}
|
|
onError={(message) =>
|
|
setValidation({ target: 'new-entry', message })
|
|
}
|
|
onParagraphCommit={(content) => {
|
|
if (
|
|
commentMode === 'immediate' &&
|
|
hasContent(content)
|
|
) {
|
|
draftAnalysisArmedRef.current = true
|
|
scheduleDraftAnalysis(content)
|
|
}
|
|
}}
|
|
/>}
|
|
{validation?.target === 'new-entry' && (
|
|
<p
|
|
className="magic-notes-field-error"
|
|
id="magic-note-entry-create-error"
|
|
role="alert"
|
|
>
|
|
{validation.message}
|
|
</p>
|
|
)}
|
|
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={Boolean(busy)}
|
|
type="button"
|
|
onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}
|
|
>
|
|
{t('actions.cancel')}
|
|
</button>
|
|
<button
|
|
className="primary-button"
|
|
disabled={Boolean(busy)}
|
|
type="button"
|
|
onClick={() => void saveEntry()}
|
|
>
|
|
{t('actions.saveEntry')}
|
|
</button>
|
|
</footer>}
|
|
</div>}
|
|
{pendingDraftSwitch && (
|
|
<div
|
|
aria-describedby={discardDraftDescriptionId}
|
|
aria-labelledby={discardDraftTitleId}
|
|
aria-modal="true"
|
|
className="magic-note-draft-confirmation"
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
continueEditing()
|
|
return
|
|
}
|
|
trapTabFocus(event, discardDraftDialogRef.current)
|
|
}}
|
|
ref={discardDraftDialogRef}
|
|
role="alertdialog"
|
|
tabIndex={-1}
|
|
>
|
|
<strong id={discardDraftTitleId}>
|
|
{t('confirmations.discardDraftTitle')}
|
|
</strong>
|
|
<span id={discardDraftDescriptionId}>
|
|
{t(entryType === 'canvas' || editingEntry?.content.version === 2 ? 'canvas.discardDescription' : 'confirmations.discardDraftDescription')}
|
|
</span>
|
|
<div>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={continueEditing}
|
|
ref={continueEditingRef}
|
|
type="button"
|
|
>
|
|
{t('actions.continueEditing')}
|
|
</button>
|
|
<button
|
|
className="danger-button"
|
|
onClick={() =>
|
|
performDraftSwitch(pendingDraftSwitch)
|
|
}
|
|
ref={discardDraftRef}
|
|
type="button"
|
|
>
|
|
{t('actions.discardAndSwitch')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="magic-note-entry-stream">
|
|
{displayedEntries.length === 0 ? (
|
|
<p className="magic-notes-muted">
|
|
{t('notes.emptyEntries')}
|
|
</p>
|
|
) : (
|
|
displayedEntries.map((entry) => (
|
|
<article
|
|
key={entry.id}
|
|
id={`magic-note-entry-${entry.id}`}
|
|
tabIndex={-1}
|
|
className={`magic-note-entry${editingEntry?.id === entry.id && entry.content.version === 2 ? ' magic-note-entry--canvas-editing' : ''}`}
|
|
>
|
|
{entry.source && <MagicNoteSource source={entry.source} onOpen={onOpenSource} />}
|
|
<header>
|
|
<time dateTime={entry.createdAt}>
|
|
{dateFormatter.format(new Date(entry.createdAt))}
|
|
</time>
|
|
{editingEntry?.id === entry.id && entry.content.version === 2 ? <div className="magic-note-canvas-actions">
|
|
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(true)}>{t('canvas.analyzeDraft')}</button>
|
|
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void requestDraftSwitch({ kind: 'cancel-edit' })}>{t('actions.cancel')}</button>
|
|
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => void saveEditedEntry()}>{t('actions.saveChanges')}</button>
|
|
</div> : <div className="magic-note-entry__actions">
|
|
{(commentMode === 'after-save-manual' || entry.content.version === 2) && editingEntry?.id !== entry.id && (
|
|
<button
|
|
className="icon-button"
|
|
aria-label={t(busy === `analyze-${entry.id}` ? 'actions.analyzing' : entry.analyzedAt ? 'actions.analyzeAgain' : 'actions.analyze')}
|
|
title={t(busy === `analyze-${entry.id}` ? 'actions.analyzing' : entry.analyzedAt ? 'actions.analyzeAgain' : 'actions.analyze')}
|
|
disabled={Boolean(busy)}
|
|
type="button"
|
|
onClick={() => void analyzeEntry(entry.id)}
|
|
>
|
|
<Bot aria-hidden="true" size={15} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className="magic-note-entry__action-button"
|
|
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
|
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
|
disabled={Boolean(busy) || editingEntry?.id === entry.id}
|
|
data-entry-edit=""
|
|
type="button"
|
|
onClick={() =>
|
|
requestDraftSwitch({
|
|
kind: 'edit-entry',
|
|
entry
|
|
})
|
|
}
|
|
>
|
|
<Pencil aria-hidden="true" size={14} />
|
|
<span>{t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}</span>
|
|
</button>
|
|
<button
|
|
aria-label={t('actions.deleteEntry')}
|
|
title={t('actions.deleteEntry')}
|
|
className="magic-note-entry__action-button magic-note-entry__delete-button"
|
|
disabled={Boolean(busy) || Boolean(editingEntry)}
|
|
type="button"
|
|
onClick={() => {
|
|
setEditingEntry(undefined)
|
|
editingContentRef.current = undefined
|
|
clearValidation('edit-entry')
|
|
setDeletingEntryId(entry.id)
|
|
}}
|
|
>
|
|
<Trash2 aria-hidden="true" size={14} />
|
|
<span>{t('actions.deleteEntry')}</span>
|
|
</button>
|
|
</div>}
|
|
</header>
|
|
{deletingEntryId === entry.id && (
|
|
<div className="magic-note-entry__delete">
|
|
<span>{t('confirmations.deleteEntry')}</span>
|
|
<button
|
|
className="secondary-button"
|
|
type="button"
|
|
onClick={() => setDeletingEntryId('')}
|
|
>
|
|
{t('actions.cancel')}
|
|
</button>
|
|
<button
|
|
className="danger-solid"
|
|
type="button"
|
|
onClick={() => {
|
|
const operation = `delete-${entry.id}`
|
|
if (!beginBusy(operation)) {
|
|
return
|
|
}
|
|
void window.goodbuddy.magicNotes
|
|
.removeEntry(entry.id)
|
|
.then(async (next) => {
|
|
applyDetail(next)
|
|
setSelectedEntryId((selected) => next.entries.some((entry) => entry.id === selected) ? selected : next.entries.at(-1)?.id ?? '')
|
|
await reloadTodos()
|
|
setDeletingEntryId('')
|
|
notifySuccess(
|
|
t('notifications.entryDeleted')
|
|
)
|
|
})
|
|
.catch((deleteError) =>
|
|
notifyError(deleteError)
|
|
)
|
|
.finally(() => endBusy(operation))
|
|
}}
|
|
>
|
|
{t('actions.deleteEntry')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{editingEntry?.id === entry.id ? (
|
|
<div className="magic-note-entry__editor" inert={Boolean(busy)}>
|
|
{editingEntry.content.version === 2 ? <>
|
|
<MagicCanvasEditor
|
|
key={`${editingEntry.id}-${editingEntry.revision}`}
|
|
ref={editingCanvasRef}
|
|
initialContent={editingEntry.content}
|
|
onReady={(content) => { editingBaselineRef.current = content; editingContentRef.current = content }}
|
|
disabled={Boolean(busy)}
|
|
onChange={(content) => {
|
|
if (!richContentEqual(editingContentRef.current, content)) clearCanvasDraftAnalysis()
|
|
editingContentRef.current = content
|
|
clearValidation('edit-entry')
|
|
}}
|
|
onError={notifyError}
|
|
/>
|
|
</> : <MagicNoteEditor
|
|
key={`${editingEntry.id}-${editingEntry.revision}`}
|
|
ariaDescribedBy={
|
|
validation?.target === 'edit-entry'
|
|
? 'magic-note-entry-edit-error'
|
|
: undefined
|
|
}
|
|
ariaInvalid={
|
|
validation?.target === 'edit-entry'
|
|
}
|
|
ariaLabel={t('notes.editEntryLabel')}
|
|
initialContent={editingEntry.content}
|
|
onReady={(content) => { editingBaselineRef.current = content; editingContentRef.current = content }}
|
|
onChange={(content) => {
|
|
editingContentRef.current = content
|
|
clearValidation('edit-entry')
|
|
if (
|
|
commentMode === 'immediate' &&
|
|
draftAnalysisArmedRef.current
|
|
) {
|
|
scheduleDraftAnalysis(content)
|
|
}
|
|
}}
|
|
onError={(message) =>
|
|
setValidation({
|
|
target: 'edit-entry',
|
|
message
|
|
})
|
|
}
|
|
onParagraphCommit={(content) => {
|
|
if (
|
|
commentMode === 'immediate' &&
|
|
hasContent(content)
|
|
) {
|
|
draftAnalysisArmedRef.current = true
|
|
scheduleDraftAnalysis(content)
|
|
}
|
|
}}
|
|
/>}
|
|
{validation?.target === 'edit-entry' && (
|
|
<p
|
|
className="magic-notes-field-error"
|
|
id="magic-note-entry-edit-error"
|
|
role="alert"
|
|
>
|
|
{validation.message}
|
|
</p>
|
|
)}
|
|
{editingEntry.content.version === 1 && <div className="magic-note-entry__editor-actions">
|
|
<button
|
|
className="secondary-button"
|
|
disabled={Boolean(busy)}
|
|
type="button"
|
|
onClick={() => {
|
|
void requestDraftSwitch({ kind: 'cancel-edit' })
|
|
}}
|
|
>
|
|
{t('actions.cancel')}
|
|
</button>
|
|
<button
|
|
className="primary-button"
|
|
disabled={Boolean(busy)}
|
|
type="button"
|
|
onClick={() => void saveEditedEntry()}
|
|
>
|
|
{t('actions.saveChanges')}
|
|
</button>
|
|
</div>}
|
|
</div>
|
|
) : (
|
|
<MagicNoteContent content={entry.content} onError={notifyError} canvasRef={(viewer) => {
|
|
if (viewer) canvasViewRefs.current.set(entry.id, viewer)
|
|
else canvasViewRefs.current.delete(entry.id)
|
|
}} />
|
|
)}
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{aiPaneOpen && renderPaneSeparator('ai')}
|
|
|
|
<aside
|
|
aria-label={t('comments.paneLabel')}
|
|
className="magic-notes-ai-pane"
|
|
hidden={!aiPaneOpen}
|
|
id="magic-notes-ai-pane"
|
|
>
|
|
<div className="magic-notes-ai-controls">
|
|
<div>
|
|
<div className="inline-help-label">
|
|
<label htmlFor="magic-note-comment-direction">{t('comments.directionLabel')}</label>
|
|
<InlineHelp label={t('comments.directionLabel')}>{t('comments.directionHelp')}</InlineHelp>
|
|
</div>
|
|
<select
|
|
id="magic-note-comment-direction"
|
|
aria-label={t('comments.directionAriaLabel')}
|
|
onChange={(event) =>
|
|
setCommentDirection(
|
|
event.target.value as MagicNoteCommentDirection
|
|
)
|
|
}
|
|
value={commentDirection}
|
|
>
|
|
{commentDirections.map((direction) => (
|
|
<option key={direction.value} value={direction.value}>
|
|
{direction.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{liveAnalysis?.format === 'structured' ? (
|
|
<p className="magic-notes-muted" role="status">
|
|
{t('status.generatingPoints', {
|
|
direction:
|
|
commentDirectionLabels[liveAnalysis.direction]
|
|
})}
|
|
</p>
|
|
) : liveAnalysis ? (
|
|
<section
|
|
aria-live="polite"
|
|
className="magic-notes-ai-group magic-notes-ai-live"
|
|
>
|
|
<span className="magic-notes-ai-source">
|
|
{t('status.generatingDirection', {
|
|
direction:
|
|
commentDirectionLabels[liveAnalysis.direction]
|
|
})}
|
|
</span>
|
|
<div className="magic-note-comment magic-note-comment--narrative">
|
|
<span aria-hidden="true">
|
|
<Bot size={15} />
|
|
</span>
|
|
<div>
|
|
<strong>{t('comments.kinds.narrative')}</strong>
|
|
{liveAnalysis.content ? (
|
|
<div className="magic-note-comment__narrative markdown-content">
|
|
<MarkdownRenderer>
|
|
{liveAnalysis.content}
|
|
</MarkdownRenderer>
|
|
</div>
|
|
) : (
|
|
<p role="status">
|
|
{t('status.preparingComment')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
{!detail ? (
|
|
<EmptyState
|
|
description={detailLoadError ? detailLoadError.message : t('status.loadingNotes')}
|
|
icon={<Bot size={20} />}
|
|
title={t('status.loading')}
|
|
/>
|
|
) : aiEntries.length === 0 &&
|
|
draftAnalyses.length === 0 &&
|
|
!canvasDraftAnalysis &&
|
|
!draftAnalysisRunning &&
|
|
!liveAnalysis ? (
|
|
<EmptyState
|
|
description={
|
|
(!editingEntry && entryType === 'canvas') || editingEntry?.content.version === 2
|
|
? undefined : commentMode === 'immediate'
|
|
? t('comments.immediateHint')
|
|
: commentMode === 'after-save-auto'
|
|
? t('comments.autoHint')
|
|
: t('comments.manualHint')
|
|
}
|
|
icon={<Bot size={20} />}
|
|
title={
|
|
commentMode === 'immediate'
|
|
? t('comments.startWritingTitle')
|
|
: commentMode === 'after-save-auto'
|
|
? t('comments.saveEntryTitle')
|
|
: t('comments.analyzeEntryTitle')
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="magic-notes-ai-feed">
|
|
{draftAnalysisRunning && !liveAnalysis && (
|
|
<p className="magic-notes-muted" role="status">
|
|
{t('status.commentingDraft')}
|
|
</p>
|
|
)}
|
|
{[...(canvasDraftAnalysis ? [canvasDraftAnalysis] : []), ...draftAnalyses].map((analysis) => (
|
|
<section
|
|
className="magic-notes-ai-group"
|
|
key={analysis.id}
|
|
>
|
|
<span className="magic-notes-ai-source">
|
|
{dateFormatter.format(new Date(analysis.analyzedAt))} ·
|
|
{t('status.unsavedDraft')}
|
|
</span>
|
|
{analysis.comments.map((comment) => (
|
|
<AiComment comment={{ ...comment, inputMode: comment.inputMode ?? analysis.inputMode }} key={comment.id} />
|
|
))}
|
|
</section>
|
|
))}
|
|
{aiEntries.map((entry) => (
|
|
<section className="magic-notes-ai-group" key={entry.id}>
|
|
<button type="button" className="secondary-button" onClick={() => void requestDraftSwitch({ kind: 'select-entry', entry })}>
|
|
{t('notes.entryAt', {
|
|
date: dateFormatter.format(
|
|
new Date(entry.createdAt)
|
|
)
|
|
})}
|
|
</button>
|
|
{entry.comments.map((comment) => (
|
|
<AiComment comment={comment} key={comment.id} />
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
</aside>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
{tagManagerOpen && (
|
|
<MagicNoteTagManager
|
|
tags={noteTags}
|
|
onClose={() => setTagManagerOpen(false)}
|
|
onCreate={createNoteTag}
|
|
onRename={renameNoteTag}
|
|
onDelete={deleteNoteTag}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
|
|
export const MagicNotesWorkspace = memo(MagicNotesWorkspaceView)
|