feat: add magic note tags and a two-pane notes layout
Keep the notes list beside the open note, compact the detail view with a full-width sticky toolbar, and make Back to overview return to a full-width all-notes grid. Add manual note tags with AND filtering, a tag manager for rename, merge and delete, schema 51 tag tables, and tag support in the note Agent tools.
This commit is contained in:
@@ -895,7 +895,11 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
},
|
||||
magicNotes: {
|
||||
search: vi.fn(async () => []),
|
||||
list: vi.fn(async () => ({ notes: [] })),
|
||||
list: vi.fn(async () => ({ notes: [], tags: [] })),
|
||||
renameTag: vi.fn(async () => {
|
||||
throw new Error("not used");
|
||||
}),
|
||||
removeTag: vi.fn(async () => undefined),
|
||||
get: vi.fn(async () => {
|
||||
throw new Error("not used");
|
||||
}),
|
||||
@@ -14223,14 +14227,14 @@ describe("App", () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation])
|
||||
const noteId = crypto.randomUUID()
|
||||
const oldEntry = { id: crypto.randomUUID(), noteId, content: { version: 1 as const, ops: [{ insert: 'Keep existing entry\n' }] }, plainText: 'Keep existing entry', comments: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z' }
|
||||
const note = { id: noteId, title: 'Collected answers', preview: oldEntry.plainText, entryCount: 1, pinned: false, revision: 1, createdAt: oldEntry.createdAt, updatedAt: oldEntry.updatedAt, entries: [oldEntry] }
|
||||
const note = { id: noteId, title: 'Collected answers', preview: oldEntry.plainText, entryCount: 1, pinned: false, tags: [], revision: 1, createdAt: oldEntry.createdAt, updatedAt: oldEntry.updatedAt, entries: [oldEntry] }
|
||||
const createdEntryId = crypto.randomUUID()
|
||||
const saved = { ...note, entryCount: 2, entries: [oldEntry, { ...oldEntry, id: createdEntryId, content: { version: 1 as const, ops: [{ insert: `${message.content}\n` }] }, plainText: message.content }], createdEntryId }
|
||||
vi.mocked(api.magicNotes.search).mockResolvedValue([note])
|
||||
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
|
||||
vi.mocked(api.magicNotes.createEntry).mockImplementationOnce(async () => {
|
||||
vi.mocked(api.magicNotes.get).mockResolvedValue(saved)
|
||||
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [saved] })
|
||||
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [saved], tags: [] })
|
||||
return saved
|
||||
})
|
||||
render(<App />)
|
||||
@@ -14290,7 +14294,7 @@ describe("App", () => {
|
||||
const conversation = { id: crypto.randomUUID(), projectId, title: 'Source discussion', updatedAt: 1_775_000_000_502, messages }
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation])
|
||||
const noteId = crypto.randomUUID()
|
||||
const note = { id: noteId, title: 'Source note', preview: '', entryCount: 1, pinned: false, revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', entries: [{ id: crypto.randomUUID(), noteId, content: { version: 1 as const, ops: [{ insert: 'Saved text\n' }] }, plainText: 'Saved text', comments: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', source: { kind: 'message' as const, conversationId: conversation.id, messageIds: [messages[1]!.id], capturedAt: '2026-09-26T00:00:00Z', conversationTitle: conversation.title } }] }
|
||||
const note = { id: noteId, title: 'Source note', preview: '', entryCount: 1, pinned: false, tags: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', entries: [{ id: crypto.randomUUID(), noteId, content: { version: 1 as const, ops: [{ insert: 'Saved text\n' }] }, plainText: 'Saved text', comments: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', source: { kind: 'message' as const, conversationId: conversation.id, messageIds: [messages[1]!.id], capturedAt: '2026-09-26T00:00:00Z', conversationTitle: conversation.title } }] }
|
||||
vi.mocked(api.magicNotes.search).mockResolvedValue([note])
|
||||
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
|
||||
render(<App />)
|
||||
@@ -14350,10 +14354,10 @@ describe("App", () => {
|
||||
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||
const note = {
|
||||
id: '00000000-0000-4000-8000-000000000601', title: 'Navigation note', preview: '',
|
||||
entryCount: 0, pinned: false, revision: 1, createdAt: '2026-08-01T00:00:00.000Z',
|
||||
entryCount: 0, pinned: false, tags: [], revision: 1, createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z', entries: [],
|
||||
}
|
||||
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note] })
|
||||
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note], tags: [] })
|
||||
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' }))
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { Check, Pencil, Plus, Tags, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
MAGIC_NOTE_MAX_TAGS,
|
||||
MAGIC_NOTE_TAG_MAX_LENGTH,
|
||||
magicNoteTagKey,
|
||||
magicNoteTagNameSchema,
|
||||
type MagicNoteTag,
|
||||
type MagicNoteTagRenameResult
|
||||
} from '../../shared/magic-notes-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
|
||||
function tagNameError(value: string): string | undefined {
|
||||
const result = magicNoteTagNameSchema.safeParse(value)
|
||||
return result.success ? undefined : result.error.issues[0]?.message
|
||||
}
|
||||
|
||||
/** Overview filter row: "All" plus one toggle per tag; selecting several narrows to notes with all of them. */
|
||||
export function MagicNoteTagFilter({
|
||||
tags,
|
||||
selected,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
tags: MagicNoteTag[]
|
||||
selected: string[]
|
||||
disabled?: boolean
|
||||
onChange: (next: string[]) => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t } = useTranslation('magicNotes')
|
||||
if (tags.length === 0) return null
|
||||
const selectedKeys = new Set(selected)
|
||||
return (
|
||||
<div className="magic-note-tag-filter" role="group" aria-label={t('tags.filterLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
className="magic-note-tag-chip magic-note-tag-chip--toggle"
|
||||
aria-pressed={selected.length === 0}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
{t('tags.all')}
|
||||
</button>
|
||||
{tags.map((tag) => {
|
||||
const key = magicNoteTagKey(tag.name)
|
||||
const active = selectedKeys.has(key)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className="magic-note-tag-chip magic-note-tag-chip--toggle"
|
||||
aria-pressed={active}
|
||||
aria-label={t('tags.tagWithCount', { name: tag.name, count: tag.noteCount })}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(active ? selected.filter((item) => item !== key) : [...selected, key])}
|
||||
>
|
||||
<span>{tag.name}</span>
|
||||
<small aria-hidden="true">{tag.noteCount}</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Compact notice shown above the side list while a tag filter is active. */
|
||||
export function MagicNoteActiveTagFilter({
|
||||
names,
|
||||
onClear
|
||||
}: {
|
||||
names: string[]
|
||||
onClear: () => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t } = useTranslation('magicNotes')
|
||||
if (names.length === 0) return null
|
||||
return (
|
||||
<div className="magic-note-active-tag-filter">
|
||||
<span>{t('tags.activeFilter')}</span>
|
||||
<span className="magic-note-active-tag-filter__names">{names.join(' · ')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label={t('tags.clearFilter')}
|
||||
title={t('tags.clearFilter')}
|
||||
onClick={onClear}
|
||||
>
|
||||
<X aria-hidden="true" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Read-only tag chips for note cards. */
|
||||
export function MagicNoteTagChips({
|
||||
tags,
|
||||
limit = 3
|
||||
}: {
|
||||
tags: string[]
|
||||
limit?: number
|
||||
}): React.JSX.Element | null {
|
||||
if (tags.length === 0) return null
|
||||
const shown = tags.slice(0, limit)
|
||||
return (
|
||||
<span className="magic-note-tag-chips">
|
||||
{shown.map((tag) => (
|
||||
<span className="magic-note-tag-chip" key={tag}>{tag}</span>
|
||||
))}
|
||||
{tags.length > shown.length && (
|
||||
<span className="magic-note-tag-chip magic-note-tag-chip--more">+{tags.length - shown.length}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tag row under the note title: remove with ×, add through a suggestion combobox. */
|
||||
export function MagicNoteTagEditor({
|
||||
tags,
|
||||
allTags,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
tags: string[]
|
||||
allTags: MagicNoteTag[]
|
||||
disabled?: boolean
|
||||
onChange: (next: string[]) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('magicNotes')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const listboxId = useId()
|
||||
const errorId = useId()
|
||||
const currentKeys = useMemo(() => new Set(tags.map(magicNoteTagKey)), [tags])
|
||||
const trimmed = query.trim()
|
||||
const queryKey = magicNoteTagKey(query)
|
||||
const options = useMemo(() => {
|
||||
const matches = allTags
|
||||
.filter((tag) => !currentKeys.has(magicNoteTagKey(tag.name)))
|
||||
.filter((tag) => !queryKey || magicNoteTagKey(tag.name).includes(queryKey))
|
||||
.slice(0, 8)
|
||||
.map((tag) => ({ name: tag.name, create: false }))
|
||||
const exact = allTags.some((tag) => magicNoteTagKey(tag.name) === queryKey)
|
||||
if (trimmed && !exact && !currentKeys.has(queryKey)) {
|
||||
matches.push({ name: trimmed, create: true })
|
||||
}
|
||||
return matches
|
||||
}, [allTags, currentKeys, queryKey, trimmed])
|
||||
const atLimit = tags.length >= MAGIC_NOTE_MAX_TAGS
|
||||
|
||||
useEffect(() => {
|
||||
if (adding) inputRef.current?.focus()
|
||||
}, [adding])
|
||||
|
||||
const close = (restoreFocus: boolean): void => {
|
||||
setAdding(false)
|
||||
setQuery('')
|
||||
setError('')
|
||||
setActiveIndex(0)
|
||||
if (restoreFocus) requestAnimationFrame(() => addButtonRef.current?.focus())
|
||||
}
|
||||
|
||||
const add = async (name: string): Promise<void> => {
|
||||
const problem = tagNameError(name)
|
||||
if (problem) {
|
||||
setError(problem)
|
||||
return
|
||||
}
|
||||
if (currentKeys.has(magicNoteTagKey(name))) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
if (atLimit) {
|
||||
setError(t('tags.limitReached', { count: MAGIC_NOTE_MAX_TAGS }))
|
||||
return
|
||||
}
|
||||
if (await onChange([...tags, name.trim()])) {
|
||||
setQuery('')
|
||||
setError('')
|
||||
setActiveIndex(0)
|
||||
if (tags.length + 1 >= MAGIC_NOTE_MAX_TAGS) close(true)
|
||||
else inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="magic-note-tag-editor">
|
||||
<ul className="magic-note-tag-chips" aria-label={t('tags.listLabel')}>
|
||||
{tags.map((tag) => (
|
||||
<li className="magic-note-tag-chip magic-note-tag-chip--removable" key={tag}>
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('tags.remove', { name: tag })}
|
||||
title={t('tags.remove', { name: tag })}
|
||||
disabled={disabled}
|
||||
onClick={() => void onChange(tags.filter((item) => item !== tag))}
|
||||
>
|
||||
<X aria-hidden="true" size={11} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{adding ? (
|
||||
<div className="magic-note-tag-combobox">
|
||||
<input
|
||||
ref={inputRef}
|
||||
role="combobox"
|
||||
aria-label={t('tags.inputLabel')}
|
||||
aria-expanded={options.length > 0}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={options[activeIndex] ? `${listboxId}-${activeIndex}` : undefined}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
placeholder={t('tags.inputPlaceholder')}
|
||||
maxLength={MAGIC_NOTE_TAG_MAX_LENGTH}
|
||||
disabled={disabled}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value)
|
||||
setActiveIndex(0)
|
||||
setError('')
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (event.relatedTarget instanceof Node && event.currentTarget.parentElement?.contains(event.relatedTarget)) return
|
||||
if (!query.trim()) close(false)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (options.length === 0) return
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1
|
||||
setActiveIndex((index) => (index + step + options.length) % options.length)
|
||||
} else if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
|
||||
event.preventDefault()
|
||||
const option = options[activeIndex]
|
||||
const name = option && (event.key === 'Enter' || option.create) ? option.name : trimmed
|
||||
if (name) void add(name)
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{options.length > 0 && (
|
||||
<ul className="magic-note-tag-suggestions" id={listboxId} role="listbox" aria-label={t('tags.suggestionsLabel')}>
|
||||
{options.map((option, index) => (
|
||||
<li
|
||||
key={`${option.create ? 'new' : 'tag'}-${option.name}`}
|
||||
id={`${listboxId}-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => void add(option.name)}
|
||||
>
|
||||
{option.create ? <Plus aria-hidden="true" size={12} /> : null}
|
||||
{option.create ? t('tags.create', { name: option.name }) : option.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && <small className="magic-notes-field-error" id={errorId} role="alert">{error}</small>}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
ref={addButtonRef}
|
||||
type="button"
|
||||
className="magic-note-tag-add"
|
||||
aria-label={t('tags.addLabel')}
|
||||
title={atLimit ? t('tags.limitReached', { count: MAGIC_NOTE_MAX_TAGS }) : t('tags.addLabel')}
|
||||
disabled={disabled || atLimit}
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
<Plus aria-hidden="true" size={12} />
|
||||
{t('tags.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MagicNoteTagManagerButton({
|
||||
disabled,
|
||||
compact,
|
||||
onClick
|
||||
}: {
|
||||
disabled?: boolean
|
||||
compact?: boolean
|
||||
onClick: () => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('magicNotes')
|
||||
return (
|
||||
<button
|
||||
id="magic-note-tag-manager"
|
||||
type="button"
|
||||
className={compact ? 'icon-button' : 'secondary-button magic-note-tag-manager-button'}
|
||||
aria-label={t('tags.manage')}
|
||||
title={t('tags.manage')}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Tags aria-hidden="true" size={15} />
|
||||
{!compact && <span>{t('tags.manage')}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dialog to rename (merging on name clash) or delete tags across all notes. */
|
||||
export function MagicNoteTagManager({
|
||||
tags,
|
||||
onClose,
|
||||
onRename,
|
||||
onDelete
|
||||
}: {
|
||||
tags: MagicNoteTag[]
|
||||
onClose: () => void
|
||||
onRename: (tag: MagicNoteTag, name: string) => Promise<MagicNoteTagRenameResult | undefined>
|
||||
onDelete: (tag: MagicNoteTag) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('magicNotes')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const [editingId, setEditingId] = useState('')
|
||||
const [draft, setDraft] = useState('')
|
||||
const [deletingId, setDeletingId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => activateModalFocus(() => closeRef.current), [])
|
||||
|
||||
const editing = tags.find((tag) => tag.id === editingId)
|
||||
const draftKey = magicNoteTagKey(draft)
|
||||
const mergeTarget = editing
|
||||
? tags.find((tag) => tag.id !== editing.id && magicNoteTagKey(tag.name) === draftKey)
|
||||
: undefined
|
||||
|
||||
const startRename = (tag: MagicNoteTag): void => {
|
||||
setEditingId(tag.id)
|
||||
setDeletingId('')
|
||||
setDraft(tag.name)
|
||||
setError('')
|
||||
}
|
||||
|
||||
const submitRename = async (): Promise<void> => {
|
||||
if (!editing || busy) return
|
||||
const problem = tagNameError(draft)
|
||||
if (problem) {
|
||||
setError(problem)
|
||||
return
|
||||
}
|
||||
if (draft.trim() === editing.name) {
|
||||
setEditingId('')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const result = await onRename(editing, draft.trim())
|
||||
setBusy(false)
|
||||
if (result) {
|
||||
setEditingId('')
|
||||
setError('')
|
||||
requestAnimationFrame(() => document.getElementById(`magic-note-tag-rename-${result.tag.id}`)?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async (tag: MagicNoteTag): Promise<void> => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
const deleted = await onDelete(tag)
|
||||
setBusy(false)
|
||||
if (deleted) {
|
||||
setDeletingId('')
|
||||
requestAnimationFrame(() => closeRef.current?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="custom-task-dialog">
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="custom-task-dialog__surface magic-note-tag-manager"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
onKeyDown={(event) => {
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
event.preventDefault()
|
||||
if (editingId) setEditingId('')
|
||||
else if (deletingId) setDeletingId('')
|
||||
else if (!busy) onClose()
|
||||
}}
|
||||
>
|
||||
<header className="custom-task-dialog__header">
|
||||
<div>
|
||||
<h2 id={titleId}>{t('tags.managerTitle')}</h2>
|
||||
<p id={descriptionId}>{t('tags.managerDescription')}</p>
|
||||
</div>
|
||||
<button ref={closeRef} type="button" className="icon-button" aria-label={t('tags.close')} title={t('tags.close')} disabled={busy} onClick={onClose}>
|
||||
<X aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="magic-note-tag-manager__body" aria-busy={busy}>
|
||||
{tags.length === 0 ? (
|
||||
<p className="magic-notes-muted">{t('tags.managerEmpty')}</p>
|
||||
) : (
|
||||
<ul className="magic-note-tag-manager__list">
|
||||
{tags.map((tag) => (
|
||||
<li key={tag.id} className="magic-note-tag-manager__row">
|
||||
{editingId === tag.id ? (
|
||||
<form
|
||||
className="magic-note-tag-manager__rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitRename()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
aria-label={t('tags.renameLabel', { name: tag.name })}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? 'magic-note-tag-rename-error' : mergeTarget ? 'magic-note-tag-merge-hint' : undefined}
|
||||
maxLength={MAGIC_NOTE_TAG_MAX_LENGTH}
|
||||
value={draft}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value)
|
||||
setError('')
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="icon-button" aria-label={t('tags.save')} title={t('tags.save')} disabled={busy}>
|
||||
<Check aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button type="button" className="icon-button" aria-label={t('tags.cancel')} title={t('tags.cancel')} disabled={busy} onClick={() => setEditingId('')}>
|
||||
<X aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{error ? (
|
||||
<small className="magic-notes-field-error" id="magic-note-tag-rename-error" role="alert">{error}</small>
|
||||
) : mergeTarget ? (
|
||||
<small className="magic-note-tag-manager__hint" id="magic-note-tag-merge-hint">{t('tags.mergeHint', { name: mergeTarget.name })}</small>
|
||||
) : null}
|
||||
</form>
|
||||
) : deletingId === tag.id ? (
|
||||
<div className="magic-note-tag-manager__confirm" role="group" aria-label={t('tags.deleteLabel', { name: tag.name })}>
|
||||
<span>{t('tags.deleteConfirm', { name: tag.name, count: tag.noteCount })}</span>
|
||||
<button type="button" className="secondary-button" disabled={busy} onClick={() => setDeletingId('')}>{t('tags.cancel')}</button>
|
||||
<button type="button" className="danger-button" autoFocus disabled={busy} onClick={() => void confirmDelete(tag)}>{t('tags.delete')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="magic-note-tag-chip">{tag.name}</span>
|
||||
<span className="magic-note-tag-manager__count">{t(tag.noteCount === 1 ? 'tags.noteCountOne' : 'tags.noteCountOther', { count: tag.noteCount })}</span>
|
||||
<button id={`magic-note-tag-rename-${tag.id}`} type="button" className="icon-button" aria-label={t('tags.renameLabel', { name: tag.name })} title={t('tags.rename')} disabled={busy} onClick={() => startRename(tag)}>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button type="button" className="icon-button magic-note-tag-manager__delete" aria-label={t('tags.deleteLabel', { name: tag.name })} title={t('tags.delete')} disabled={busy} onClick={() => { setDeletingId(tag.id); setEditingId('') }}>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useMagicNoteDraft } from './use-magic-note-draft'
|
||||
import { MagicNoteSource as SourceBar } from './MagicNoteSource'
|
||||
import i18n from './i18n'
|
||||
|
||||
const note: MagicNoteDetail = { id: '00000000-0000-4000-8000-000000000601', title: 'Release decisions', preview: 'Existing text', entryCount: 0, entries: [], pinned: true, revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z' }
|
||||
const note: MagicNoteDetail = { id: '00000000-0000-4000-8000-000000000601', title: 'Release decisions', preview: 'Existing text', entryCount: 0, entries: [], pinned: true, tags: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z' }
|
||||
const source: MagicNoteSource = { kind: 'message', conversationId: 'conversation-a', messageIds: ['message-a'], capturedAt: '2026-09-26T00:00:00Z', conversationTitle: 'Discussion A', projectName: 'Project A' }
|
||||
const entry = { id: '00000000-0000-4000-8000-000000000602', noteId: note.id, content: capturedNoteContent('Captured text'), plainText: 'Captured text', comments: [], revision: 1, createdAt: note.createdAt, updatedAt: note.updatedAt, source }
|
||||
const search = vi.fn<DesktopApi['magicNotes']['search']>()
|
||||
|
||||
@@ -57,7 +57,7 @@ const canvasContent: MagicNoteCanvasContent = { version: 2, kind: 'paged-canvas'
|
||||
const canvasImages = [{ pageId: 'page-1', dataUrl: 'data:image/png;base64,YQ==' }]
|
||||
const detail: MagicNoteDetail = {
|
||||
id: noteId, title: '发布笔记', preview: '整理发布清单', entryCount: 1,
|
||||
pinned: false, revision: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:01:00.000Z',
|
||||
pinned: false, tags: [], revision: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:01:00.000Z',
|
||||
entries: [{
|
||||
id: entryId, noteId, content: { version: 1, ops: [{ insert: '整理发布清单\n' }] }, plainText: '整理发布清单',
|
||||
comments: [{ id: 'comment', kind: 'suggestion', content: '先核对发布材料。' }],
|
||||
@@ -115,7 +115,7 @@ beforeEach(async () => {
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
changeListener = undefined
|
||||
analysisListener = undefined
|
||||
list.mockResolvedValue({ notes: [detail, second] })
|
||||
list.mockResolvedValue({ notes: [detail, second], tags: [] })
|
||||
get.mockImplementation(async (id) => id === secondNoteId ? second : detail)
|
||||
listTodos.mockResolvedValue({ todos: [todo, otherTodo] })
|
||||
create.mockResolvedValue({ ...second, title: '新笔记' })
|
||||
@@ -188,6 +188,11 @@ function newEntry(): void {
|
||||
expect(screen.queryByRole('button', { name: '新增记录' })).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
function openIndex(): void {
|
||||
const show = screen.queryByRole('button', { name: '展开记录索引' })
|
||||
if (show) fireEvent.click(show)
|
||||
}
|
||||
|
||||
function showAi(): void {
|
||||
const show = screen.queryByRole('button', { name: '显示 AI 评论' })
|
||||
if (show) fireEvent.click(show)
|
||||
@@ -200,6 +205,127 @@ async function openTodo(): Promise<void> {
|
||||
await screen.findByRole('heading', { name: todo.title })
|
||||
}
|
||||
|
||||
describe('MagicNotesWorkspace tags', () => {
|
||||
const workTag = { id: '00000000-0000-4000-8000-000000000701', name: 'Work', noteCount: 2 }
|
||||
const releaseTag = { id: '00000000-0000-4000-8000-000000000702', name: '发布', noteCount: 1 }
|
||||
const tagged = { ...detail, tags: ['Work', '发布'] }
|
||||
const taggedSecond = { ...second, tags: ['Work'] }
|
||||
|
||||
beforeEach(() => {
|
||||
list.mockResolvedValue({ notes: [tagged, taggedSecond], tags: [workTag, releaseTag] })
|
||||
get.mockImplementation(async (id) => id === secondNoteId ? taggedSecond : tagged)
|
||||
})
|
||||
|
||||
it('filters overview cards by all selected tags, shows card chips and keeps the filter in the side list', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const filter = await screen.findByRole('group', { name: '按标签筛选' })
|
||||
const cards = document.querySelector('.magic-notes-card-grid')!
|
||||
expect(within(cards as HTMLElement).getAllByText('Work')).toHaveLength(2)
|
||||
fireEvent.click(within(filter).getByRole('button', { name: 'Work,2 篇' }))
|
||||
expect(screen.getByText(detail.title)).toBeVisible()
|
||||
expect(screen.getByText(second.title)).toBeVisible()
|
||||
fireEvent.click(within(filter).getByRole('button', { name: '发布,1 篇' }))
|
||||
expect(screen.getByText(detail.title)).toBeVisible()
|
||||
expect(screen.queryByText(second.title)).not.toBeInTheDocument()
|
||||
expect(within(filter).getByRole('button', { name: '全部' })).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await openNote()
|
||||
expect(screen.queryByRole('group', { name: '按标签筛选' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Work · 发布')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除标签筛选' }))
|
||||
expect(screen.getByRole('button', { name: new RegExp(`${second.title}.*条记录`) })).toBeVisible()
|
||||
})
|
||||
|
||||
it('matches tag names from the search box and clears tag filters from the empty state', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText(detail.title)
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '发布' } })
|
||||
expect(screen.getByText(detail.title)).toBeVisible()
|
||||
expect(screen.queryByText(second.title)).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
|
||||
list.mockResolvedValue({ notes: [tagged, taggedSecond], tags: [workTag, releaseTag, { id: '00000000-0000-4000-8000-000000000703', name: 'Empty', noteCount: 1 }] })
|
||||
await act(async () => changeListener?.())
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Empty,1 篇' })).toBeVisible())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Empty,1 篇' }))
|
||||
expect(screen.getByText('没有符合条件的笔记')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
|
||||
expect(screen.getByRole('button', { name: '全部' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText(second.title)).toBeVisible()
|
||||
})
|
||||
|
||||
it('adds an existing or new tag with the keyboard and removes one with the chip button', async () => {
|
||||
update.mockImplementation(async (input) => ({ ...tagged, tags: input.tags ?? tagged.tags, revision: input.expectedRevision + 1 }))
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const tagList = screen.getByRole('list', { name: '笔记标签' })
|
||||
expect(within(tagList).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['Work', '发布'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除标签 发布' }))
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith({ noteId, tags: ['Work'], expectedRevision: 1 }))
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '添加标签' }))
|
||||
const input = screen.getByRole('combobox', { name: '新标签名称' })
|
||||
expect(input).toHaveFocus()
|
||||
fireEvent.change(input, { target: { value: '发' } })
|
||||
expect(screen.getByRole('option', { name: '发布' })).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith({ noteId, tags: ['Work', '发布'], expectedRevision: 2 }))
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Q4 计划' } })
|
||||
expect(screen.getByRole('option', { name: '新建“Q4 计划”' })).toBeVisible()
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith({ noteId, tags: ['Work', '发布', 'Q4 计划'], expectedRevision: 3 }))
|
||||
|
||||
fireEvent.change(input, { target: { value: 'a,b' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByRole('combobox', { name: '新标签名称' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '添加标签' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('renames, merges and deletes tags from the manager dialog', async () => {
|
||||
const renameTag = vi.fn<DesktopApi['magicNotes']['renameTag']>()
|
||||
const removeTag = vi.fn<DesktopApi['magicNotes']['removeTag']>().mockResolvedValue()
|
||||
Object.assign(window.goodbuddy.magicNotes, { renameTag, removeTag })
|
||||
renameTag.mockResolvedValueOnce({ tag: { ...workTag, name: 'Job' }, merged: false })
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText(detail.title)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Work,2 篇' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '管理标签' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '管理标签' })
|
||||
expect(within(dialog).getByRole('button', { name: '关闭' })).toHaveFocus()
|
||||
expect(within(dialog).getByText('2 篇笔记')).toBeVisible()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '重命名标签 Work' }))
|
||||
const input = within(dialog).getByRole('textbox', { name: '重命名标签 Work' })
|
||||
fireEvent.change(input, { target: { value: '发布' } })
|
||||
expect(within(dialog).getByText('已有同名标签“发布”,保存后两个标签会合并。')).toBeVisible()
|
||||
fireEvent.change(input, { target: { value: 'a#b' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent('标签不能包含逗号、# 或控制字符')
|
||||
expect(renameTag).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: ' Job ' } })
|
||||
list.mockResolvedValue({ notes: [{ ...tagged, tags: ['Job', '发布'] }, { ...taggedSecond, tags: ['Job'] }], tags: [{ ...workTag, name: 'Job' }, releaseTag] })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(renameTag).toHaveBeenCalledWith({ tagId: workTag.id, name: 'Job' }))
|
||||
expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'success', message: '标签已重命名' }))
|
||||
// The active filter follows the renamed tag.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Job,2 篇' })).toHaveAttribute('aria-pressed', 'true'))
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '删除标签 Job' }))
|
||||
expect(within(dialog).getByText('从 2 篇笔记中移除“Job”?')).toBeVisible()
|
||||
list.mockResolvedValue({ notes: [{ ...tagged, tags: ['发布'] }, { ...taggedSecond, tags: [] }], tags: [releaseTag] })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '删除' }))
|
||||
await waitFor(() => expect(removeTag).toHaveBeenCalledWith(workTag.id))
|
||||
await waitFor(() => expect(within(dialog).queryByText('Job')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: '全部' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText(second.title)).toBeVisible()
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog', { name: '管理标签' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MagicNotesWorkspace overview navigation', () => {
|
||||
it('opens an externally requested note and entry after initial loading and renders its source', async () => {
|
||||
const source = { kind: 'message' as const, conversationId: 'conversation-a', messageIds: ['message-a'], capturedAt: detail.createdAt, conversationTitle: 'Original discussion' }
|
||||
@@ -302,6 +428,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
||||
await openNote()
|
||||
expect(screen.getAllByRole('article')).toHaveLength(3)
|
||||
expect(screen.getByTestId('magic-note-editor')).toBeVisible()
|
||||
openIndex()
|
||||
expect(screen.getByRole('navigation', { name: '记录' })).toBeVisible()
|
||||
fireEvent.click(within(document.getElementById(`magic-note-entry-${entryId}`)!).getByRole('button', { name: '编辑' }))
|
||||
fireEvent.click(screen.getByTestId('magic-note-editor'))
|
||||
@@ -364,7 +491,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
||||
expect(screen.getAllByRole('article')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('starts with adaptive cards, one navigation button and search without fetching a detail', async () => {
|
||||
it('starts with a compact notes list, one navigation button and search without fetching a detail', async () => {
|
||||
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText(detail.title)
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
@@ -376,18 +503,21 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
||||
expect(screen.getByRole('button', { name: '新建笔记' })).toBeVisible()
|
||||
expect(screen.queryByLabelText('笔记标题')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('AI 评论')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('separator')).not.toBeInTheDocument()
|
||||
// The overview is a full-width page of all notes; no split separator or empty detail pane.
|
||||
expect(screen.queryByRole('separator', { name: '调整笔记列表宽度' })).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.magic-notes-workspace')).toHaveClass('magic-notes-workspace--overview')
|
||||
expect(container.querySelector('.magic-notes-detail-container')).not.toBeVisible()
|
||||
expect(container.querySelector('.magic-notes-card-grid')).toBeVisible()
|
||||
expect(container.querySelector('.magic-notes-layout')).not.toHaveClass('magic-notes-layout--detail')
|
||||
const card = screen.getByRole('button', { name: /发布笔记.*条记录/ })
|
||||
expect(card.querySelector('time')).toHaveAttribute('datetime', detail.updatedAt)
|
||||
expect(card).not.toHaveAttribute('aria-pressed')
|
||||
const css = readFileSync('src/renderer/src/styles.css', 'utf8')
|
||||
expect(css).toMatch(/\.magic-notes-card-grid\s*\{[^}]*grid-template-columns: repeat\(auto-fill, minmax\(min\(100%, 240px\), 1fr\)\)/)
|
||||
expect(css).toMatch(/\.magic-notes-workspace--notes \.magic-notes-card-grid\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\)/)
|
||||
expect(css).toMatch(/\.magic-notes-page #magic-library-switch\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.magic-notes-page #magic-library-switch\s*\{[^}]*width: fit-content/)
|
||||
const switchButton = screen.getByRole('button', { name: '切换到待办' })
|
||||
expect(switchButton.nextElementSibling).toBe(screen.getByRole('button', { name: '新建笔记' }))
|
||||
expect(screen.getByRole('region', { name: '笔记列表' })).toContainElement(screen.getByRole('button', { name: '新建笔记' }))
|
||||
expect(switchButton.querySelector('.lucide-list-todo')).toBeInTheDocument()
|
||||
switchButton.focus()
|
||||
fireEvent.click(switchButton)
|
||||
@@ -400,45 +530,186 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
||||
fireEvent.click(switchButton)
|
||||
expect(screen.getByRole('button', { name: '切换到待办' })).toBe(switchButton)
|
||||
await waitFor(() => expect(switchButton).toHaveFocus())
|
||||
expect(css).not.toContain('magic-notes-list-resize-handle')
|
||||
expect(css).not.toContain('--magic-notes-list-width')
|
||||
expect(css).toContain('container: magic-notes-detail / inline-size')
|
||||
expect(css).toMatch(/grid-template-rows: minmax\(0, 1fr\) minmax\(0, min\(40%, 280px\)\)/)
|
||||
expect(css).toMatch(/\.magic-notes-layout--overview,\s*\.magic-notes-layout--ai-hidden\s*\{\s*grid-template-rows: minmax\(0, 1fr\)/)
|
||||
expect(css).toMatch(/\.magic-notes-layout--overview,\s*\.magic-notes-layout--ai-hidden\s*\{\s*grid-template-columns: minmax\(0, 1fr\);\s*grid-template-rows: minmax\(0, 1fr\)/)
|
||||
})
|
||||
|
||||
it('opens a card and restores overview search, scroll and keyboard focus on return', async () => {
|
||||
it('keeps the notes list, search and new action available alongside detail and restores focus on return', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText(detail.title)
|
||||
const overview = screen.getByRole('region', { name: '笔记列表' })
|
||||
const list = overview.querySelector('.magic-notes-list')!
|
||||
const search = screen.getByRole('searchbox')
|
||||
fireEvent.change(search, { target: { value: '发布' } })
|
||||
overview.scrollTop = 180
|
||||
list.scrollTop = 180
|
||||
const card = screen.getByRole('button', { name: /发布笔记.*条记录/ })
|
||||
await openNote()
|
||||
expect(overview).not.toBeVisible()
|
||||
expect(screen.getByRole('heading', { level: 1, name: detail.title })).toBeVisible()
|
||||
expect(document.querySelector('.page-header__description')).toBeNull()
|
||||
expect(overview).toBeVisible()
|
||||
expect(card).toHaveAttribute('aria-current', 'true')
|
||||
// The page header stays stable; the note title is the editable heading in the stream.
|
||||
expect(screen.getByRole('heading', { level: 1, name: '魔法笔记' })).toBeVisible()
|
||||
expect(screen.getByRole('textbox', { name: '笔记标题' })).toHaveValue(detail.title)
|
||||
expect(screen.getByRole('separator', { name: '调整笔记列表宽度' })).toBeVisible()
|
||||
// Back to overview lives in the page header's top-right actions while a note is open.
|
||||
expect(screen.getByRole('button', { name: '返回总览' }).closest('.page-header__actions')).not.toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '切换到待办' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新建笔记' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('searchbox')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '新建笔记' })).toBeVisible()
|
||||
expect(screen.getByRole('region', { name: '笔记记录' })).toBeVisible()
|
||||
expect(screen.getByRole('region', { name: '笔记记录' }).parentElement).toHaveClass('magic-notes-layout--detail')
|
||||
const css = readFileSync('src/renderer/src/styles.css', 'utf8')
|
||||
expect(css).toMatch(/\.magic-notes-layout--detail\s*\{\s*border: 0;\s*border-radius: 0;/)
|
||||
expect(css).toMatch(/\.magic-notes-stream-pane\s*\{\s*padding: 0;/)
|
||||
expect(css).toMatch(/\.magic-notes-stream-pane\s*\{[^}]*padding: 0 var\(--magic-note-stream-gutter\)/)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toHaveFocus())
|
||||
back()
|
||||
expect(overview).toBeVisible()
|
||||
expect(screen.getByRole('heading', { level: 1, name: '魔法笔记' })).toBeVisible()
|
||||
expect(search).toHaveValue('发布')
|
||||
expect(overview.scrollTop).toBe(180)
|
||||
expect(list.scrollTop).toBe(180)
|
||||
expect(screen.queryByRole('region', { name: '笔记记录' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(card).toHaveFocus())
|
||||
await openNote()
|
||||
expect(get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('switches directly between notes without moving the list and restores each reading position', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const list = document.querySelector('.magic-notes-list')!
|
||||
list.scrollTop = 180
|
||||
const stream = screen.getByRole('region', { name: '笔记记录' })
|
||||
fireEvent.scroll(stream, { target: { scrollTop: 360 } })
|
||||
await openNote(second.title)
|
||||
await waitFor(() => expect(stream.scrollTop).toBe(0))
|
||||
expect(list.scrollTop).toBe(180)
|
||||
expect(document.getElementById(`magic-note-select-${secondNoteId}`)).toHaveAttribute('aria-current', 'true')
|
||||
fireEvent.scroll(stream, { target: { scrollTop: 120 } })
|
||||
await openNote()
|
||||
await waitFor(() => expect(stream.scrollTop).toBe(360))
|
||||
expect(list.scrollTop).toBe(180)
|
||||
await openNote(second.title)
|
||||
await waitFor(() => expect(stream.scrollTop).toBe(120))
|
||||
const calls = get.mock.calls.length
|
||||
await openNote(second.title)
|
||||
expect(get).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('resizes and collapses the notes sidebar and remembers desktop preferences', async () => {
|
||||
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const separator = screen.getByRole('separator', { name: '调整笔记列表宽度' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '280')
|
||||
fireEvent.keyDown(separator, { key: 'ArrowRight' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '296')
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '420')
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '240')
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起笔记列表' }))
|
||||
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: '笔记记录' })).toBeVisible()
|
||||
expect(screen.queryByRole('separator', { name: '调整笔记列表宽度' })).not.toBeInTheDocument()
|
||||
view.unmount()
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
expect(screen.getByRole('button', { name: '展开笔记列表' })).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开笔记列表' }))
|
||||
expect(screen.getByRole('separator', { name: '调整笔记列表宽度' })).toHaveAttribute('aria-valuenow', '240')
|
||||
})
|
||||
|
||||
it('pins and deletes through the persistent sidebar while detail is open', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const sidebar = screen.getByRole('region', { name: '笔记列表' })
|
||||
const more = within(sidebar).getByRole('button', { name: `更多笔记操作 ${detail.title}` })
|
||||
fireEvent.click(more)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶笔记' }))
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith({ noteId, pinned: true, expectedRevision: 1 }))
|
||||
await waitFor(() => expect(more).not.toBeDisabled())
|
||||
expect(screen.getByDisplayValue(detail.title)).toBeVisible()
|
||||
fireEvent.click(more)
|
||||
expect(screen.getByRole('menuitem', { name: '取消置顶' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
list.mockResolvedValue({ notes: [second], tags: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(noteId))
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: '笔记记录' })).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['composer', 'entry', 'title', 'canvas'] as const)('protects the current %s draft through sidebar delete cancellation, failure and success', async (draft) => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
if (draft === 'title') fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: '未保存标题' } })
|
||||
else if (draft === 'canvas') fireEvent.click(screen.getByRole('button', { name: '画布' }))
|
||||
else {
|
||||
if (draft === 'entry') fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
fireEvent.click(screen.getByTestId('magic-note-editor'))
|
||||
}
|
||||
const requestDelete = async () => {
|
||||
fireEvent.click(within(screen.getByRole('region', { name: '笔记列表' })).getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await screen.findByRole('alertdialog')
|
||||
}
|
||||
await requestDelete()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
if (draft === 'canvas') expect(canvas.flush).toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
expect(screen.getByRole('region', { name: '笔记记录' })).toBeVisible()
|
||||
if (draft === 'title') expect(screen.getByLabelText('笔记标题')).toHaveValue('未保存标题')
|
||||
await requestDelete()
|
||||
remove.mockRejectedValueOnce(new Error('delete failed'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'delete failed' })))
|
||||
await requestDelete()
|
||||
expect(remove).toHaveBeenCalledTimes(1)
|
||||
list.mockResolvedValue({ notes: [second], tags: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: '笔记记录' })).not.toBeInTheDocument())
|
||||
await openNote(second.title)
|
||||
back()
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retains the current editing and title drafts when deleting another sidebar note', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: '未保存标题' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
const editor = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(editor)
|
||||
fireEvent.click(within(screen.getByRole('region', { name: '笔记列表' })).getByRole('button', { name: `更多笔记操作 ${second.title}` }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
|
||||
list.mockResolvedValue({ notes: [detail], tags: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(secondNoteId))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: `更多笔记操作 ${second.title}` })).not.toBeInTheDocument())
|
||||
expect(screen.getByLabelText('笔记标题')).toHaveValue('未保存标题')
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(editor)
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
back()
|
||||
expect(screen.getByRole('alertdialog')).toBeVisible()
|
||||
})
|
||||
|
||||
it.each(['composer', 'title'] as const)('guards direct note switching with a dirty %s', async (draft) => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
if (draft === 'title') fireEvent.change(screen.getByRole('textbox', { name: '笔记标题' }), { target: { value: '未保存标题' } })
|
||||
else fireEvent.click(screen.getByTestId('magic-note-editor'))
|
||||
fireEvent.click(document.getElementById(`magic-note-select-${secondNoteId}`)!)
|
||||
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||
expect(get).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
expect(screen.getByRole('textbox', { name: '笔记标题' })).toHaveValue(draft === 'title' ? '未保存标题' : detail.title)
|
||||
fireEvent.click(document.getElementById(`magic-note-select-${secondNoteId}`)!)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '放弃草稿并切换' }))
|
||||
await screen.findByDisplayValue(second.title)
|
||||
})
|
||||
|
||||
it.each(['composer', 'entry', 'title'] as const)('guards returning with a dirty %s and cancels or discards through the existing dialog', async (draft) => {
|
||||
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
@@ -670,7 +941,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'delete failed' })))
|
||||
expect(screen.getByRole('alertdialog')).toBeVisible()
|
||||
list.mockResolvedValue({ notes: [second] })
|
||||
list.mockResolvedValue({ notes: [second], tags: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await waitFor(() => expect(screen.queryByText(detail.title)).not.toBeInTheDocument())
|
||||
expect(screen.getByText(second.title)).toBeVisible()
|
||||
@@ -756,6 +1027,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
fireEvent.click(screen.getByTestId('magic-note-editor'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存记录' })).toBeEnabled())
|
||||
openIndex()
|
||||
expect(screen.getByRole('button', { name: /Local B/ })).toHaveAttribute('aria-current', 'true')
|
||||
fireEvent.click(within(document.getElementById(`magic-note-entry-${createdEntryId}`)!).getByRole('button', { name: '编辑' }))
|
||||
expect(screen.getByTestId('magic-note-editor').closest('article')).toHaveAttribute('id', `magic-note-entry-${createdEntryId}`)
|
||||
@@ -920,15 +1192,35 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
expect(screen.getByRole('button', { name: /核对发布材料.*发布笔记/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('opens AI for notes with comments, keeps it closed for notes without, and persists a manual pin', async () => {
|
||||
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote(second.title)
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
fireEvent.click(document.getElementById(`magic-note-select-${noteId}`)!)
|
||||
await screen.findByDisplayValue(detail.title)
|
||||
// The first note already has comments, so AI opens without a pin.
|
||||
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏 AI 评论' }))
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toMatchObject({ aiPanePinned: false })
|
||||
fireEvent.click(document.getElementById(`magic-note-select-${secondNoteId}`)!)
|
||||
await screen.findByDisplayValue(second.title)
|
||||
fireEvent.click(screen.getByRole('button', { name: '显示 AI 评论' }))
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toMatchObject({ aiPanePinned: true })
|
||||
view.unmount()
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote(second.title)
|
||||
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('retains AI visibility and width across navigation and remounts', async () => {
|
||||
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
showAi()
|
||||
const toggle = screen.getByRole('button', { name: '隐藏 AI 评论', expanded: true })
|
||||
expect(toggle).toHaveClass('secondary-button')
|
||||
expect(toggle).toHaveClass('icon-button')
|
||||
expect(toggle).toHaveAttribute('title', '隐藏 AI 评论')
|
||||
expect(toggle).toHaveAttribute('aria-controls', 'magic-notes-ai-pane')
|
||||
expect(toggle).toHaveTextContent('隐藏 AI 评论')
|
||||
const pane = screen.getByRole('complementary', { name: 'AI 评论' })
|
||||
expect(pane).toHaveAttribute('id', toggle.getAttribute('aria-controls'))
|
||||
expect(within(pane).queryByText('AI 评论', { exact: true })).not.toBeInTheDocument()
|
||||
@@ -942,17 +1234,13 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
expect(toggle).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '显示 AI 评论', expanded: false })).toBe(toggle)
|
||||
expect(toggle).toHaveAttribute('title', '显示 AI 评论')
|
||||
expect(toggle).toHaveTextContent('显示 AI 评论')
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
back()
|
||||
await openNote()
|
||||
expect(screen.getByRole('button', { name: '显示 AI 评论' })).toBeVisible()
|
||||
view.unmount()
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
fireEvent.click(screen.getByRole('button', { name: '显示 AI 评论' }))
|
||||
showAi()
|
||||
expect(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })).toHaveAttribute('aria-valuenow', width)
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toMatchObject({ indexPaneOpen: true, indexPaneWidth: 168, aiPaneOpen: true, aiPaneWidth: Number(width) })
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toMatchObject({ recordIndexOpen: false, indexPaneWidth: 168, aiPaneWidth: Number(width) })
|
||||
})
|
||||
|
||||
it('resizes AI with pointer and keyboard and disables resizing in narrow layouts', async () => {
|
||||
@@ -979,8 +1267,13 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(700)
|
||||
back()
|
||||
await openNote()
|
||||
expect(screen.getByRole('separator')).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('separator')).toHaveAttribute('tabindex', '-1')
|
||||
// 601-800px keeps AI beside the editor, so it stays resizable.
|
||||
expect(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })).toHaveAttribute('aria-disabled', 'false')
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(560)
|
||||
back()
|
||||
await openNote()
|
||||
expect(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })).toHaveAttribute('tabindex', '-1')
|
||||
const toggle = screen.getByRole('button', { name: '隐藏 AI 评论', expanded: true })
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
@@ -994,6 +1287,8 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
expect(screen.queryByRole('separator', { name: '调整记录索引宽度' })).not.toBeInTheDocument()
|
||||
openIndex()
|
||||
const separator = screen.getByRole('separator', { name: '调整记录索引宽度' })
|
||||
const layout = separator.closest('.magic-notes-layout') as HTMLElement
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '168')
|
||||
@@ -1046,11 +1341,10 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
get.mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve }))
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /发布笔记.*条记录/ }))
|
||||
showAi()
|
||||
fireEvent.click(await screen.findByRole('button', { name: '显示 AI 评论' }))
|
||||
const pane = screen.getByRole('complementary', { name: 'AI 评论' })
|
||||
expect(within(pane).getByText('正在加载笔记…')).toBeVisible()
|
||||
await act(async () => resolveDetail({ ...detail, entries: [], entryCount: 0 }))
|
||||
showAi()
|
||||
expect(within(pane).getByText('写完一句并停止输入 5 秒后,评论会显示在这里。')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1058,7 +1352,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
expect(container.querySelector('.magic-note-detail-header > :first-child')).toBe(screen.getByLabelText('笔记标题'))
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('danger-button--quiet')
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('icon-button', 'magic-note-entry__delete-button')
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
expect(container.querySelector('.magic-note-entry__editor')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toBeDisabled()
|
||||
@@ -1290,32 +1584,27 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
await openNote()
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
const editor = screen.getByTestId('magic-note-editor')
|
||||
const toggle = screen.getByRole('button', { name: '收起记录索引' })
|
||||
expect(toggle.parentElement).toHaveClass('page-header__actions')
|
||||
const back = screen.getByRole('button', { name: '返回总览' })
|
||||
expect(back).toHaveTextContent('返回总览')
|
||||
expect(back.nextElementSibling).toBe(toggle)
|
||||
expect(toggle).toHaveTextContent('收起记录索引')
|
||||
// The record index starts collapsed; its icon toggle sits in the note toolbar.
|
||||
const toggle = screen.getByRole('button', { name: '展开记录索引' })
|
||||
expect(toggle.closest('[role="toolbar"]')).toHaveAccessibleName('笔记工具栏')
|
||||
expect(screen.getByRole('button', { name: '收起笔记列表' }).nextElementSibling).toBe(toggle)
|
||||
const aiToggle = screen.getByRole('button', { name: '隐藏 AI 评论' })
|
||||
expect(toggle.nextElementSibling).toBe(aiToggle)
|
||||
expect(aiToggle).toHaveTextContent('隐藏 AI 评论')
|
||||
expect(toggle).toHaveAttribute('aria-controls', 'magic-notes-index')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(document.querySelector('.magic-notes-index-pane')).toHaveAttribute('hidden')
|
||||
expect(document.querySelector('.magic-notes-layout')).toHaveClass('magic-notes-layout--index-hidden')
|
||||
toggle.focus()
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveFocus()
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(toggle).toHaveAttribute('title', '展开记录索引')
|
||||
expect(toggle).toHaveTextContent('展开记录索引')
|
||||
expect(document.querySelector('.magic-notes-index-pane')).toHaveAttribute('hidden')
|
||||
expect(document.querySelector('.magic-notes-layout')).toHaveClass('magic-notes-layout--index-hidden')
|
||||
expect(screen.queryByRole('navigation', { name: '记录' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏 AI 评论' }))
|
||||
expect(aiToggle).toHaveTextContent('显示 AI 评论')
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开记录索引' }))
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(toggle).toHaveAttribute('title', '收起记录索引')
|
||||
expect(screen.getByRole('navigation', { name: '记录' })).toBeVisible()
|
||||
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
|
||||
fireEvent.click(aiToggle)
|
||||
expect(aiToggle).toHaveAttribute('aria-label', '显示 AI 评论')
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.queryByRole('navigation', { name: '记录' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(editor)
|
||||
})
|
||||
|
||||
@@ -1324,8 +1613,8 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const toggle = screen.getByRole('button', { name: '展开记录索引' })
|
||||
expect(screen.getByRole('button', { name: '返回总览' }).nextElementSibling).toBe(toggle)
|
||||
expect(toggle).toHaveTextContent('展开记录索引')
|
||||
expect(toggle.closest('[role="toolbar"]')).toHaveAccessibleName('笔记工具栏')
|
||||
expect(screen.getByRole('button', { name: '返回总览' }).closest('.page-header__actions')).not.toBeNull()
|
||||
expect(document.querySelector('.magic-notes-index-pane')).toHaveAttribute('hidden')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
@@ -1341,7 +1630,8 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
const css = readFileSync('src/renderer/src/styles.css', 'utf8')
|
||||
expect(css).toMatch(/\.magic-notes-layout--index-hidden\s*\{\s*grid-template-columns: minmax\(300px, 1fr\) 9px/)
|
||||
expect(css).toMatch(/\.magic-notes-layout--index-hidden\.magic-notes-layout--ai-hidden\s*\{\s*grid-template-columns: minmax\(0, 1fr\)/)
|
||||
expect(css).toMatch(/@container magic-notes-page \(max-width: 800px\)\s*\{\s*\.magic-notes-layout\s*\{\s*grid-template-columns: minmax\(0, 1fr\)/)
|
||||
expect(css).toMatch(/@container magic-notes-detail \(max-width: 800px\)\s*\{\s*\.magic-notes-layout\s*\{\s*grid-template-columns: minmax\(300px, 1fr\) 9px var\(--magic-notes-ai-width, 280px\)/)
|
||||
expect(css).toMatch(/@container magic-notes-detail \(max-width: 600px\)\s*\{\s*\.magic-notes-layout\s*\{\s*grid-template-columns: minmax\(0, 1fr\)/)
|
||||
})
|
||||
it.each(['immediate', 'after-save-auto', 'after-save-manual'] as const)('keeps canvas actions above the editor without duplicate hints or footer actions (%s)', async (mode) => {
|
||||
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: mode })
|
||||
@@ -1815,6 +2105,7 @@ describe('MagicNotesWorkspace external refresh', () => {
|
||||
get.mockResolvedValue({ ...detail, entries: [remaining, ...detail.entries] })
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
openIndex()
|
||||
expect(document.querySelector('.magic-note-record[aria-current="true"]')).toHaveTextContent(detail.entries[0]!.plainText)
|
||||
const afterDelete = { ...detail, entries: [remaining] }
|
||||
get.mockResolvedValue(afterDelete)
|
||||
@@ -1866,17 +2157,16 @@ describe('MagicNotesWorkspace external refresh', () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const renamed = { ...detail, title: 'Agent-renamed note', pinned: true, revision: 2 }
|
||||
list.mockResolvedValue({ notes: [second, renamed] })
|
||||
list.mockResolvedValue({ notes: [second, renamed], tags: [] })
|
||||
get.mockResolvedValue(renamed)
|
||||
act(() => { changeListener?.(); changeListener?.(); changeListener?.() })
|
||||
await screen.findByDisplayValue(renamed.title)
|
||||
expect(get).toHaveBeenLastCalledWith(noteId)
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(screen.queryByRole('button', { name: '切换到待办' })).not.toBeInTheDocument()
|
||||
list.mockResolvedValue({ notes: [second] })
|
||||
list.mockResolvedValue({ notes: [second], tags: [] })
|
||||
act(() => changeListener?.())
|
||||
await screen.findByRole('button', { name: '切换到待办' })
|
||||
expect(screen.queryByLabelText('笔记标题')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByLabelText('笔记标题')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: '切换到待办' })).toBeVisible()
|
||||
expect(screen.getByText(second.title)).toBeVisible()
|
||||
expect(get).not.toHaveBeenCalledWith(secondNoteId)
|
||||
})
|
||||
@@ -1893,7 +2183,7 @@ describe('MagicNotesWorkspace external refresh', () => {
|
||||
expect(screen.getByRole('searchbox')).toHaveValue('发布')
|
||||
expect(screen.getByRole('button', { name: /发布笔记.*条记录/ })).toBeVisible()
|
||||
expect(onNotify).not.toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'refresh failed' }))
|
||||
list.mockResolvedValue({ notes: [second] })
|
||||
list.mockResolvedValue({ notes: [second], tags: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await screen.findByText('没有符合条件的笔记')
|
||||
expect(screen.queryByText(/refresh failed/)).not.toBeInTheDocument()
|
||||
@@ -1921,7 +2211,7 @@ describe('MagicNotesWorkspace external refresh', () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved title' } })
|
||||
list.mockResolvedValue({ notes: [] })
|
||||
list.mockResolvedValue({ notes: [], tags: [] })
|
||||
listTodos.mockResolvedValue({ todos: [] })
|
||||
act(() => changeListener?.())
|
||||
await screen.findByText(/这篇笔记已在其他入口删除/)
|
||||
@@ -1933,7 +2223,7 @@ describe('MagicNotesWorkspace external refresh', () => {
|
||||
it('coalesces overview refreshes without opening a note', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText(detail.title)
|
||||
list.mockResolvedValue({ notes: [{ ...detail, title: 'Agent note' }] })
|
||||
list.mockResolvedValue({ notes: [{ ...detail, title: 'Agent note' }], tags: [] })
|
||||
act(() => { changeListener?.(); changeListener?.(); changeListener?.() })
|
||||
await screen.findByText('Agent note')
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
@@ -1981,7 +2271,7 @@ describe('MagicNotesWorkspace external refresh', () => {
|
||||
newEntry()
|
||||
const editor = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(editor)
|
||||
list.mockResolvedValue({ notes: [second] })
|
||||
list.mockResolvedValue({ notes: [second], tags: [] })
|
||||
act(() => changeListener?.())
|
||||
await screen.findByText(/这篇笔记已在其他入口删除/)
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(editor)
|
||||
@@ -2017,7 +2307,7 @@ describe('MagicNotesWorkspace external refresh', () => {
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
|
||||
back()
|
||||
if (action === 'select') await openNote(second.title)
|
||||
await act(async () => finish({ notes: [detail, second] }))
|
||||
await act(async () => finish({ notes: [detail, second], tags: [] }))
|
||||
if (action === 'select') expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title)
|
||||
else {
|
||||
expect(screen.getByRole('button', { name: '切换到待办' })).toBeVisible()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ListTree,
|
||||
Pencil,
|
||||
Bot,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
@@ -43,9 +45,20 @@ import type {
|
||||
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'
|
||||
@@ -83,6 +96,7 @@ type ValidationTarget =
|
||||
| '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' }
|
||||
@@ -106,18 +120,22 @@ const magicNotesLayoutStorageKey =
|
||||
'goodbuddy.magic-notes-layout.v1'
|
||||
|
||||
type MagicNotesLayoutPreferences = {
|
||||
indexPaneOpen: boolean
|
||||
notesPaneOpen: boolean
|
||||
notesPaneWidth: number
|
||||
recordIndexOpen: boolean
|
||||
indexPaneWidth: number
|
||||
aiPaneOpen: boolean
|
||||
aiPanePinned: boolean
|
||||
aiPaneWidth: number
|
||||
todoPaneWidth: number
|
||||
}
|
||||
|
||||
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
|
||||
const defaults = {
|
||||
indexPaneOpen: true,
|
||||
notesPaneOpen: true,
|
||||
notesPaneWidth: 280,
|
||||
recordIndexOpen: false,
|
||||
indexPaneWidth: defaultIndexPaneWidth,
|
||||
aiPaneOpen: true,
|
||||
aiPanePinned: false,
|
||||
aiPaneWidth: defaultAiPaneWidth,
|
||||
todoPaneWidth: 320
|
||||
}
|
||||
@@ -130,13 +148,17 @@ function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
|
||||
MagicNotesLayoutPreferences
|
||||
>
|
||||
return {
|
||||
notesPaneOpen: parsed.notesPaneOpen !== false,
|
||||
notesPaneWidth: typeof parsed.notesPaneWidth === 'number' && Number.isFinite(parsed.notesPaneWidth)
|
||||
? Math.min(420, Math.max(240, parsed.notesPaneWidth)) : defaults.notesPaneWidth,
|
||||
todoPaneWidth: typeof parsed.todoPaneWidth === 'number' && Number.isFinite(parsed.todoPaneWidth)
|
||||
? Math.max(240, parsed.todoPaneWidth) : defaults.todoPaneWidth,
|
||||
indexPaneOpen: parsed.indexPaneOpen !== false,
|
||||
// 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,
|
||||
aiPaneOpen: parsed.aiPaneOpen !== false,
|
||||
aiPanePinned: parsed.aiPanePinned === true,
|
||||
aiPaneWidth:
|
||||
typeof parsed.aiPaneWidth === 'number' &&
|
||||
Number.isFinite(parsed.aiPaneWidth)
|
||||
@@ -201,6 +223,7 @@ function noteSummary(note: MagicNoteDetail): MagicNoteSummary {
|
||||
preview: note.preview,
|
||||
entryCount: note.entryCount,
|
||||
pinned: note.pinned,
|
||||
tags: note.tags ?? [],
|
||||
revision: note.revision,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt
|
||||
@@ -418,7 +441,20 @@ export function MagicNotesWorkspace({
|
||||
),
|
||||
[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'>()
|
||||
@@ -468,18 +504,22 @@ export function MagicNotesWorkspace({
|
||||
() => loadMagicNotesLayoutPreferences(),
|
||||
[]
|
||||
)
|
||||
const [aiPaneOpen, setAiPaneOpen] = useState(
|
||||
initialLayoutPreferences.aiPaneOpen
|
||||
)
|
||||
// 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.indexPaneOpen)
|
||||
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 [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'>()
|
||||
const [resizingPane, setResizingPane] = useState<'ai' | 'index' | 'todo' | 'notes'>()
|
||||
const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
)
|
||||
@@ -499,6 +539,26 @@ export function MagicNotesWorkspace({
|
||||
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
|
||||
@@ -508,6 +568,7 @@ export function MagicNotesWorkspace({
|
||||
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('')
|
||||
@@ -533,8 +594,11 @@ export function MagicNotesWorkspace({
|
||||
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'; pointerId: number; width: number } | undefined>(undefined)
|
||||
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)
|
||||
@@ -613,13 +677,35 @@ export function MagicNotesWorkspace({
|
||||
|
||||
useEffect(() => {
|
||||
persistMagicNotesLayoutPreferences({
|
||||
indexPaneOpen,
|
||||
notesPaneOpen,
|
||||
notesPaneWidth,
|
||||
recordIndexOpen: indexPaneOpen,
|
||||
indexPaneWidth,
|
||||
aiPaneOpen,
|
||||
aiPanePinned,
|
||||
aiPaneWidth,
|
||||
todoPaneWidth
|
||||
})
|
||||
}, [indexPaneOpen, indexPaneWidth, aiPaneOpen, aiPaneWidth, todoPaneWidth])
|
||||
}, [indexPaneOpen, indexPaneWidth, aiPanePinned, aiPaneWidth, todoPaneWidth, notesPaneOpen, notesPaneWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const workspace = workspaceRef.current
|
||||
if (!workspace) return
|
||||
const update = (): void => setWorkspaceWidth(workspace.getBoundingClientRect().width || window.innerWidth)
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(workspace)
|
||||
return () => observer.disconnect()
|
||||
}, [loadStatus])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const stream = streamRef.current
|
||||
if (!stream || !detail?.id) return
|
||||
const position = noteScrollPositions.current.get(detail.id) ?? 0
|
||||
if (requestedEntryIdRef.current) return
|
||||
// Read-only rich text mounts in an effect; restore after it has laid out.
|
||||
const frame = requestAnimationFrame(() => { stream.scrollTop = position })
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [detail?.id, detailView])
|
||||
|
||||
useEffect(() => {
|
||||
const layout = todoLayoutRef.current
|
||||
@@ -868,6 +954,7 @@ export function MagicNotesWorkspace({
|
||||
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)
|
||||
@@ -875,6 +962,7 @@ export function MagicNotesWorkspace({
|
||||
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
|
||||
@@ -900,6 +988,7 @@ export function MagicNotesWorkspace({
|
||||
clearDraftAnalysis()
|
||||
composerContentRef.current = undefined
|
||||
setComposerKey((current) => current + 1)
|
||||
setComposerHasDraft(false)
|
||||
}, [clearDraftAnalysis])
|
||||
|
||||
const discardEditingDraft = useCallback((): void => {
|
||||
@@ -938,6 +1027,7 @@ export function MagicNotesWorkspace({
|
||||
applyDetail(created)
|
||||
detailRequestRef.current += 1
|
||||
requestedNoteIdRef.current = created.id
|
||||
requestedEntryIdRef.current = undefined
|
||||
setSelectedNoteId(created.id)
|
||||
setSelectedEntryId('')
|
||||
setDetailView('notes')
|
||||
@@ -978,7 +1068,8 @@ export function MagicNotesWorkspace({
|
||||
: target.kind === 'library-view'
|
||||
? document.getElementById('magic-library-switch')
|
||||
: target.kind === 'note'
|
||||
? document.getElementById('magic-notes-back')
|
||||
? document.getElementById('magic-notes-back') ??
|
||||
document.getElementById(`magic-note-select-${target.noteId}`)
|
||||
: target.kind === 'edit-entry'
|
||||
? document
|
||||
.getElementById(
|
||||
@@ -1000,6 +1091,10 @@ export function MagicNotesWorkspace({
|
||||
(target: DraftSwitchTarget): void => {
|
||||
setPendingDraftSwitch(undefined)
|
||||
setValidation(undefined)
|
||||
if (target.kind === 'delete-note') {
|
||||
target.remove()
|
||||
return
|
||||
}
|
||||
if (target.kind === 'leave') {
|
||||
discardComposerDraft()
|
||||
discardEditingDraft()
|
||||
@@ -1044,6 +1139,7 @@ export function MagicNotesWorkspace({
|
||||
if (target.value === 'todos') {
|
||||
discardComposerDraft()
|
||||
discardEditingDraft()
|
||||
setDetailView(undefined)
|
||||
}
|
||||
setLibraryView(target.value)
|
||||
setCreating(false)
|
||||
@@ -1069,7 +1165,7 @@ export function MagicNotesWorkspace({
|
||||
return
|
||||
}
|
||||
setDeletingNote(false)
|
||||
if (!detailView && libraryView === 'notes') overviewFocusRef.current = `magic-note-select-${target.noteId}`
|
||||
if (libraryView === 'notes') overviewFocusRef.current = `magic-note-select-${target.noteId}`
|
||||
setDetailView('notes')
|
||||
focusSwitchTarget(target)
|
||||
void loadDetail(target.noteId, target.entryId).then(() => {
|
||||
@@ -1086,7 +1182,6 @@ export function MagicNotesWorkspace({
|
||||
[
|
||||
createNote,
|
||||
detail,
|
||||
detailView,
|
||||
discardComposerDraft,
|
||||
discardEditingDraft,
|
||||
focusSwitchTarget,
|
||||
@@ -1138,7 +1233,7 @@ export function MagicNotesWorkspace({
|
||||
if (
|
||||
(wouldClearComposer && hasContent(composerContentRef.current)) ||
|
||||
(target.kind !== 'entry-type' && hasDirtyEditingDraft()) ||
|
||||
((target.kind === 'overview' || target.kind === 'leave') && current.detail && current.titleDraft !== current.detail.title)
|
||||
((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
|
||||
@@ -1231,6 +1326,7 @@ export function MagicNotesWorkspace({
|
||||
const preserveNewerSelection =
|
||||
detailRequestRef.current !== detailRequestAtStart
|
||||
setNotes(snapshot.notes)
|
||||
setNoteTags(snapshot.tags ?? [])
|
||||
setTodos(todoSnapshot.todos)
|
||||
setSelectedTodoId((current) =>
|
||||
todoSnapshot.todos.some((todo) => todo.id === current)
|
||||
@@ -1336,22 +1432,90 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
}, [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 query
|
||||
? notes.filter(
|
||||
(note) =>
|
||||
note.title.toLocaleLowerCase().includes(query) ||
|
||||
note.preview.toLocaleLowerCase().includes(query)
|
||||
)
|
||||
: notes
|
||||
}, [notes, search])
|
||||
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(
|
||||
() => !detailView && libraryView === 'notes' && !pendingDraftSwitch
|
||||
() => libraryView === 'notes' && !pendingDraftSwitch
|
||||
? visibleNotes.find((note) => note.id === noteActionsId)
|
||||
: undefined,
|
||||
[libraryView, detailView, noteActionsId, pendingDraftSwitch, visibleNotes]
|
||||
[libraryView, noteActionsId, pendingDraftSwitch, visibleNotes]
|
||||
)
|
||||
if (noteActionsId && !actionNote) {
|
||||
setNoteActionsId('')
|
||||
@@ -1423,8 +1587,13 @@ export function MagicNotesWorkspace({
|
||||
await window.goodbuddy.magicNotes.remove(note.id)
|
||||
notifySuccess(t('notifications.noteDeleted'))
|
||||
closeNoteActions()
|
||||
await refreshNotes()
|
||||
requestAnimationFrame(() => document.getElementById('magic-note-new')?.focus({ preventScroll: true }))
|
||||
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 {
|
||||
@@ -1537,7 +1706,17 @@ export function MagicNotesWorkspace({
|
||||
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 <= 900
|
||||
const paneResizeDisabled = (pane: 'ai' | 'index' | 'todo' | 'notes'): boolean =>
|
||||
pane === 'notes' ? isNarrowWorkspace : pane === 'todo' ? todoLayoutWidth <= 700 : pane === 'ai' ? isStackedLayout : isNarrowLayout
|
||||
const notesPaneWidthLimits = { minimum: 240, maximum: Math.min(420, Math.max(240, workspaceWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth)) }
|
||||
const displayedNotesWidth = clampMagicNotesPaneWidth(notesPaneWidth, notesPaneWidthLimits)
|
||||
// Overview shows every note full width; the split list appears only beside an open note.
|
||||
const notesExpanded = isNarrowWorkspace ? !detailView : notesPaneOpen || !detailView
|
||||
const notesSplit = libraryView === 'notes' && Boolean(detailView) && !isNarrowWorkspace && notesPaneOpen
|
||||
const todoPaneWidthLimits = { minimum: 240, maximum: Math.max(240, todoLayoutWidth - 301) }
|
||||
const displayedTodoWidth = clampMagicNotesPaneWidth(todoPaneWidth, todoPaneWidthLimits)
|
||||
const indexExpanded = isNarrowLayout ? narrowIndexOpen : indexPaneOpen
|
||||
@@ -1550,7 +1729,7 @@ export function MagicNotesWorkspace({
|
||||
(aiPaneOpen ? minimumAiPaneWidth + magicNotesResizeHandleWidth : 0)))
|
||||
})
|
||||
const aiPaneWidthLimits = getAiPaneWidthLimits(
|
||||
layoutInnerWidth, indexPaneOpen ? displayedIndexWidth + magicNotesResizeHandleWidth : 0
|
||||
layoutInnerWidth, indexPaneOpen && !isNarrowLayout ? displayedIndexWidth + magicNotesResizeHandleWidth : 0
|
||||
)
|
||||
const displayedAiWidth = clampMagicNotesPaneWidth(aiPaneWidth, aiPaneWidthLimits)
|
||||
const indexPaneWidthLimits = {
|
||||
@@ -1567,6 +1746,7 @@ export function MagicNotesWorkspace({
|
||||
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)
|
||||
@@ -1576,39 +1756,41 @@ export function MagicNotesWorkspace({
|
||||
if (!resize || resize.pointerId !== event.pointerId) return
|
||||
const isAi = resize.pane === 'ai'
|
||||
const isTodo = resize.pane === 'todo'
|
||||
const limits = isTodo ? todoPaneWidthLimits : isAi ? aiPaneWidthLimits : indexPaneWidthLimits
|
||||
if (isTodo ? magicNotesLayoutWidth <= 700 : isNarrowLayout) { finishPaneResize(event); return }
|
||||
const bounds = isTodo ? todoLayoutRef.current!.getBoundingClientRect() : getLayoutBounds()
|
||||
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 = isTodo ? todoLayoutRef.current : magicNotesLayoutRef.current
|
||||
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(isTodo ? 'accessibility.todoPaneWidth' : isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width: 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', event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
const limits = pane === 'todo' ? todoPaneWidthLimits : pane === 'ai' ? aiPaneWidthLimits : indexPaneWidthLimits
|
||||
if (event.button !== 0 || (pane === 'todo' ? magicNotesLayoutWidth <= 700 : isNarrowLayout) || limits.maximum <= limits.minimum) return
|
||||
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 === 'todo' ? displayedTodoWidth : pane === 'ai' ? displayedAiWidth : displayedIndexWidth }
|
||||
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'): React.JSX.Element => {
|
||||
const renderPaneSeparator = (pane: 'ai' | 'index' | 'todo' | 'notes'): React.JSX.Element => {
|
||||
const isAi = pane === 'ai'
|
||||
const isTodo = pane === 'todo'
|
||||
const limits = isTodo ? todoPaneWidthLimits : isAi ? aiPaneWidthLimits : indexPaneWidthLimits
|
||||
const width = isTodo ? displayedTodoWidth : isAi ? displayedAiWidth : displayedIndexWidth
|
||||
const enabled = (isTodo ? magicNotesLayoutWidth > 700 : !isNarrowLayout) && limits.maximum > limits.minimum
|
||||
const setWidth = isTodo ? setTodoPaneWidth : isAi ? setAiPaneWidth : setIndexPaneWidth
|
||||
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={isTodo ? 'magic-todo-list' : isAi ? 'magic-notes-ai-pane' : 'magic-notes-index'}
|
||||
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(isTodo ? 'accessibility.resizeTodoPane' : isAi ? 'accessibility.resizeAiPane' : 'accessibility.resizeIndexPane')}
|
||||
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(isTodo ? 'accessibility.todoPaneWidth' : isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { 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
|
||||
@@ -1793,6 +1975,7 @@ export function MagicNotesWorkspace({
|
||||
setPendingDraftSwitch(undefined)
|
||||
clearDraftAnalysis()
|
||||
setComposerKey((current) => current + 1)
|
||||
setComposerHasDraft(false)
|
||||
notifySuccess(t('notifications.entrySaved'))
|
||||
if (analysisPreparationError !== undefined) notifyError(t('canvas.savedAnalysisFailed', { error: analysisPreparationError }))
|
||||
try {
|
||||
@@ -1969,51 +2152,16 @@ export function MagicNotesWorkspace({
|
||||
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-notes-index-toggle"
|
||||
type="button"
|
||||
className="secondary-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)}
|
||||
>
|
||||
{indexExpanded ? <PanelLeftClose aria-hidden="true" size={15} /> : <PanelLeftOpen aria-hidden="true" size={15} />}
|
||||
{t(indexExpanded ? 'records.hide' : 'records.show')}
|
||||
</button>
|
||||
)}
|
||||
{detailView && (
|
||||
<button
|
||||
aria-controls="magic-notes-ai-pane"
|
||||
aria-expanded={aiPaneOpen}
|
||||
aria-label={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
|
||||
className="secondary-button"
|
||||
onClick={() => setAiPaneOpen((current) => !current)}
|
||||
title={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
|
||||
type="button"
|
||||
>
|
||||
{aiPaneOpen ? (
|
||||
<PanelRightClose aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
|
||||
</button>
|
||||
<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 && (
|
||||
<>
|
||||
@@ -2027,7 +2175,7 @@ export function MagicNotesWorkspace({
|
||||
{libraryView === 'notes' ? <ListTodo aria-hidden="true" size={15} /> : <BookOpen aria-hidden="true" size={15} />}
|
||||
{t(libraryView === 'notes' ? 'actions.switchToTodos' : 'actions.switchToNotes')}
|
||||
</button>
|
||||
<button
|
||||
{libraryView === 'todos' && <button
|
||||
id="magic-note-new"
|
||||
className="primary-button"
|
||||
disabled={Boolean(busy)}
|
||||
@@ -2040,16 +2188,16 @@ export function MagicNotesWorkspace({
|
||||
>
|
||||
<Plus aria-hidden="true" size={15} />
|
||||
{t('actions.newNote')}
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
help={detailView ? undefined : t('page.description')}
|
||||
help={t('page.description')}
|
||||
headingId="magic-notes-title"
|
||||
icon={<Sparkles size={20} />}
|
||||
scope={{ kind: 'global' }}
|
||||
title={detailView && detail ? detail.title : t('page.title')}
|
||||
title={t('page.title')}
|
||||
/>
|
||||
|
||||
{loadStatus === 'error' ? (
|
||||
@@ -2087,29 +2235,10 @@ export function MagicNotesWorkspace({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={magicNotesLayoutRef}
|
||||
ref={workspaceRef}
|
||||
aria-busy={Boolean(busy)}
|
||||
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 && !isNarrowLayout)
|
||||
? ' magic-notes-layout--resizing'
|
||||
: ''
|
||||
}${
|
||||
resizingPane && !isNarrowLayout
|
||||
? ` magic-notes-layout--${resizingPane}-resizing`
|
||||
: ''
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
'--magic-notes-ai-width': `${displayedAiWidth}px`,
|
||||
'--magic-notes-index-width': `${displayedIndexWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`magic-notes-workspace${notesSplit ? ' magic-notes-workspace--notes' : ''}${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}
|
||||
>
|
||||
<section
|
||||
aria-label={t(
|
||||
@@ -2118,7 +2247,7 @@ export function MagicNotesWorkspace({
|
||||
: 'todos.listLabel'
|
||||
)}
|
||||
className={`magic-notes-overview${libraryView === 'todos' ? ' magic-notes-overview--todos' : ''}`}
|
||||
hidden={Boolean(detailView)}
|
||||
hidden={libraryView === 'notes' ? !notesExpanded : Boolean(detailView)}
|
||||
>
|
||||
{libraryView === 'notes' ? (
|
||||
<div
|
||||
@@ -2128,6 +2257,16 @@ export function MagicNotesWorkspace({
|
||||
<div className="magic-notes-pane-heading">
|
||||
<strong>{t('notes.heading')}</strong>
|
||||
<span>{notes.length}</span>
|
||||
<MagicNoteTagManagerButton
|
||||
compact={Boolean(detailView)}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
/>
|
||||
<button id="magic-note-new" type="button" className="icon-button"
|
||||
disabled={Boolean(busy)} aria-label={t('actions.newNote')} title={t('actions.newNote')}
|
||||
onClick={() => { setValidation(undefined); setCreating(true) }}>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<label className="magic-notes-search">
|
||||
<span className="sr-only">{t('notes.searchLabel')}</span>
|
||||
@@ -2138,6 +2277,15 @@ export function MagicNotesWorkspace({
|
||||
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"
|
||||
@@ -2202,14 +2350,14 @@ export function MagicNotesWorkspace({
|
||||
) : visibleNotes.length === 0 ? (
|
||||
<>
|
||||
<p className="magic-notes-muted">
|
||||
{search.trim()
|
||||
{search.trim() || activeTagFilter.length > 0
|
||||
? t('notes.noMatches')
|
||||
: t('notes.empty')}
|
||||
</p>
|
||||
{search.trim() && (
|
||||
{(search.trim() || activeTagFilter.length > 0) && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setSearch('')}
|
||||
onClick={() => { setSearch(''); setTagFilter([]) }}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.clearFilters')}
|
||||
@@ -2222,9 +2370,10 @@ export function MagicNotesWorkspace({
|
||||
<button
|
||||
id={`magic-note-select-${note.id}`}
|
||||
className="magic-note-list-item"
|
||||
aria-current={selectedNoteId === note.id ? 'true' : undefined}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
requestDraftSwitch({
|
||||
selectedNoteId !== note.id && requestDraftSwitch({
|
||||
kind: 'note',
|
||||
noteId: note.id
|
||||
})
|
||||
@@ -2234,27 +2383,22 @@ export function MagicNotesWorkspace({
|
||||
{note.pinned && (
|
||||
<Pin aria-label={t('status.pinned')} size={12} />
|
||||
)}
|
||||
{note.title}
|
||||
<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>
|
||||
<span className="magic-note-list-item__meta">
|
||||
<span>
|
||||
{t(
|
||||
note.entryCount === 1
|
||||
? 'notes.entryCountOne'
|
||||
: 'notes.entryCountOther',
|
||||
{ count: note.entryCount }
|
||||
)}
|
||||
</span>
|
||||
<time dateTime={note.updatedAt}>
|
||||
{t('notes.updatedAt', {
|
||||
date: dateFormatter.format(
|
||||
new Date(note.updatedAt)
|
||||
)
|
||||
})}
|
||||
</time>
|
||||
{!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
|
||||
@@ -2313,7 +2457,13 @@ export function MagicNotesWorkspace({
|
||||
message={t('confirmations.deleteNote', { title: actionNote.title })}
|
||||
onCancel={() => setDeletingNote(false)}
|
||||
onRequestConfirm={() => setDeletingNote(true)}
|
||||
onConfirm={() => void deleteNote(actionNote)}
|
||||
onConfirm={() => {
|
||||
if (actionNote.id === requestedNoteIdRef.current) {
|
||||
void requestDraftSwitch({ kind: 'delete-note', remove: () => { void deleteNote(actionNote) } })
|
||||
} else {
|
||||
void deleteNote(actionNote)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>, document.body
|
||||
)}
|
||||
@@ -2486,6 +2636,13 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</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) => {
|
||||
@@ -2503,7 +2660,51 @@ export function MagicNotesWorkspace({
|
||||
<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' && !isNarrowWorkspace && (
|
||||
<button 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={() => 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 ? (
|
||||
@@ -2546,6 +2747,7 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
<header className="magic-note-detail-header">
|
||||
<input
|
||||
className="magic-note-detail-title"
|
||||
aria-describedby={
|
||||
validation?.target === 'note-title'
|
||||
? 'magic-note-title-error'
|
||||
@@ -2583,8 +2785,19 @@ export function MagicNotesWorkspace({
|
||||
{validation.message}
|
||||
</p>
|
||||
)}
|
||||
<MagicNoteTagEditor
|
||||
key={detail.id}
|
||||
tags={detail.tags ?? []}
|
||||
allTags={noteTags}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={updateNoteTags}
|
||||
/>
|
||||
|
||||
{!editingEntry && <div className="magic-note-composer" ref={composerRef} inert={Boolean(busy)}>
|
||||
{!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>)}
|
||||
@@ -2621,6 +2834,7 @@ export function MagicNotesWorkspace({
|
||||
ariaLabel={t('notes.newEntryLabel')}
|
||||
onChange={(content) => {
|
||||
composerContentRef.current = content
|
||||
setComposerHasDraft(hasContent(content))
|
||||
clearValidation('new-entry')
|
||||
if (
|
||||
commentMode === 'immediate' &&
|
||||
@@ -2730,24 +2944,23 @@ export function MagicNotesWorkspace({
|
||||
<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>
|
||||
</div> : <div className="magic-note-entry__actions">
|
||||
{(commentMode === 'after-save-manual' || entry.content.version === 2) && editingEntry?.id !== entry.id && (
|
||||
<button
|
||||
className="secondary-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 size={14} />
|
||||
{busy === `analyze-${entry.id}`
|
||||
? t('actions.analyzing')
|
||||
: entry.analyzedAt
|
||||
? t('actions.analyzeAgain')
|
||||
: t('actions.analyze')}
|
||||
<Bot aria-hidden="true" size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
className="icon-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}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -2757,11 +2970,12 @@ export function MagicNotesWorkspace({
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('actions.deleteEntry')}
|
||||
className="danger-button danger-button--quiet"
|
||||
title={t('actions.deleteEntry')}
|
||||
className="icon-button magic-note-entry__delete-button"
|
||||
disabled={Boolean(busy) || Boolean(editingEntry)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -2771,8 +2985,7 @@ export function MagicNotesWorkspace({
|
||||
setDeletingEntryId(entry.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
{t('actions.deleteEntry')}
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
</button>
|
||||
</div>}
|
||||
</header>
|
||||
@@ -3054,9 +3267,19 @@ export function MagicNotesWorkspace({
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{tagManagerOpen && (
|
||||
<MagicNoteTagManager
|
||||
tags={noteTags}
|
||||
onClose={() => setTagManagerOpen(false)}
|
||||
onRename={renameNoteTag}
|
||||
onDelete={deleteNoteTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export const magicNotes = {
|
||||
analyzing: 'Analyzing…',
|
||||
openSourceNote: 'Open source note',
|
||||
backToOverview: 'Back to overview',
|
||||
hideNotes: 'Hide notes list',
|
||||
showNotes: 'Show notes list',
|
||||
backToTodos: 'Back to to-dos',
|
||||
backToTodoList: 'Back to to-do list',
|
||||
hideAiComments: 'Hide AI comments',
|
||||
@@ -100,6 +102,7 @@ export const magicNotes = {
|
||||
notes: {
|
||||
listLabel: 'Notes list',
|
||||
streamLabel: 'Note entries',
|
||||
toolbarLabel: 'Note toolbar',
|
||||
heading: 'All notes',
|
||||
searchLabel: 'Search notes in the current scope',
|
||||
searchPlaceholder: 'Search notes',
|
||||
@@ -110,9 +113,6 @@ export const magicNotes = {
|
||||
entryCountOne: '{{count}} entry',
|
||||
entryCountOther: '{{count}} entries',
|
||||
updatedAt: 'Updated {{date}}',
|
||||
emptySelectionTitle: 'No note selected',
|
||||
emptySelectionDescription:
|
||||
'Select a note on the left, or create a note to start writing.',
|
||||
newEntryLabel: 'New entry content',
|
||||
composerImmediateHint:
|
||||
'Type -, 1., [ ], or [x] then Space to start a list; AI comments after 5 seconds of inactivity',
|
||||
@@ -122,6 +122,40 @@ export const magicNotes = {
|
||||
editEntryLabel: 'Edit entry content',
|
||||
entryAt: '{{date}} entry'
|
||||
},
|
||||
tags: {
|
||||
filterLabel: 'Filter by tag',
|
||||
all: 'All',
|
||||
tagWithCount: '{{name}} ({{count}})',
|
||||
clearFilter: 'Clear tag filter',
|
||||
activeFilter: 'Filtered:',
|
||||
listLabel: 'Note tags',
|
||||
add: 'Tag',
|
||||
addLabel: 'Add tag',
|
||||
inputLabel: 'New tag name',
|
||||
inputPlaceholder: 'Type and press Enter',
|
||||
suggestionsLabel: 'Existing tags',
|
||||
create: 'Create "{{name}}"',
|
||||
remove: 'Remove tag {{name}}',
|
||||
limitReached: 'A note can have at most {{count}} tags',
|
||||
manage: 'Manage tags',
|
||||
managerTitle: 'Manage tags',
|
||||
managerDescription: 'Renaming or deleting a tag updates every note. Notes themselves are never deleted.',
|
||||
managerEmpty: 'No tags yet. Open a note and add one below its title.',
|
||||
noteCountOne: '{{count}} note',
|
||||
noteCountOther: '{{count}} notes',
|
||||
rename: 'Rename',
|
||||
renameLabel: 'Rename tag {{name}}',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
delete: 'Delete',
|
||||
deleteLabel: 'Delete tag {{name}}',
|
||||
deleteConfirm: 'Remove "{{name}}" from every note that uses it ({{count}})?',
|
||||
mergeHint: 'A tag named "{{name}}" already exists. Saving merges the two tags.',
|
||||
renamed: 'Tag renamed',
|
||||
merged: 'Tag merged into "{{name}}"',
|
||||
deleted: 'Tag deleted'
|
||||
},
|
||||
todos: {
|
||||
listLabel: 'To-do list',
|
||||
detailLabel: 'To-do details',
|
||||
@@ -194,6 +228,8 @@ export const magicNotes = {
|
||||
'Choose “Analyze with AI” on an entry to show comments here.'
|
||||
},
|
||||
accessibility: {
|
||||
resizeNotesPane: 'Resize notes list',
|
||||
notesPaneWidth: 'Notes list width: {{width}} pixels',
|
||||
resizeAiPane: 'Resize the editor and AI comments panes',
|
||||
resizeIndexPane: 'Resize the record index',
|
||||
resizeTodoPane: 'Resize the to-do list and details',
|
||||
|
||||
@@ -46,6 +46,8 @@ export const magicNotes = {
|
||||
analyzing: '分析中…',
|
||||
openSourceNote: '打开原笔记修改',
|
||||
backToOverview: '返回总览',
|
||||
hideNotes: '收起笔记列表',
|
||||
showNotes: '展开笔记列表',
|
||||
backToTodos: '返回待办',
|
||||
backToTodoList: '返回待办列表',
|
||||
hideAiComments: '隐藏 AI 评论',
|
||||
@@ -95,6 +97,7 @@ export const magicNotes = {
|
||||
notes: {
|
||||
listLabel: '笔记列表',
|
||||
streamLabel: '笔记记录',
|
||||
toolbarLabel: '笔记工具栏',
|
||||
heading: '全部笔记',
|
||||
searchLabel: '搜索当前范围的笔记',
|
||||
searchPlaceholder: '搜索笔记',
|
||||
@@ -105,8 +108,6 @@ export const magicNotes = {
|
||||
entryCountOne: '{{count}} 条记录',
|
||||
entryCountOther: '{{count}} 条记录',
|
||||
updatedAt: '更新于 {{date}}',
|
||||
emptySelectionTitle: '还没有选择笔记',
|
||||
emptySelectionDescription: '从左侧选择笔记,或新建一篇笔记开始记录。',
|
||||
newEntryLabel: '新记录内容',
|
||||
composerImmediateHint:
|
||||
'输入 -、1.、[ ] 或 [x] 后按空格可创建列表;停止输入 5 秒后自动评论',
|
||||
@@ -116,6 +117,40 @@ export const magicNotes = {
|
||||
editEntryLabel: '编辑记录内容',
|
||||
entryAt: '{{date}} 的记录'
|
||||
},
|
||||
tags: {
|
||||
filterLabel: '按标签筛选',
|
||||
all: '全部',
|
||||
tagWithCount: '{{name}},{{count}} 篇',
|
||||
clearFilter: '清除标签筛选',
|
||||
activeFilter: '已筛选:',
|
||||
listLabel: '笔记标签',
|
||||
add: '标签',
|
||||
addLabel: '添加标签',
|
||||
inputLabel: '新标签名称',
|
||||
inputPlaceholder: '输入后按回车',
|
||||
suggestionsLabel: '已有标签',
|
||||
create: '新建“{{name}}”',
|
||||
remove: '移除标签 {{name}}',
|
||||
limitReached: '每篇笔记最多 {{count}} 个标签',
|
||||
manage: '管理标签',
|
||||
managerTitle: '管理标签',
|
||||
managerDescription: '重命名或删除标签会同步到所有笔记,笔记本身不会被删除。',
|
||||
managerEmpty: '还没有标签。打开一篇笔记,在标题下方添加。',
|
||||
noteCountOne: '{{count}} 篇笔记',
|
||||
noteCountOther: '{{count}} 篇笔记',
|
||||
rename: '重命名',
|
||||
renameLabel: '重命名标签 {{name}}',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
delete: '删除',
|
||||
deleteLabel: '删除标签 {{name}}',
|
||||
deleteConfirm: '从 {{count}} 篇笔记中移除“{{name}}”?',
|
||||
mergeHint: '已有同名标签“{{name}}”,保存后两个标签会合并。',
|
||||
renamed: '标签已重命名',
|
||||
merged: '标签已合并到“{{name}}”',
|
||||
deleted: '标签已删除'
|
||||
},
|
||||
todos: {
|
||||
listLabel: '待办列表',
|
||||
detailLabel: '待办详情',
|
||||
@@ -182,6 +217,8 @@ export const magicNotes = {
|
||||
manualHint: '在记录上点击“AI 分析”,评论会显示在这里。'
|
||||
},
|
||||
accessibility: {
|
||||
resizeNotesPane: '调整笔记列表宽度',
|
||||
notesPaneWidth: '笔记列表宽度:{{width}} 像素',
|
||||
resizeAiPane: '调整编辑区与 AI 评论宽度',
|
||||
resizeIndexPane: '调整记录索引宽度',
|
||||
resizeTodoPane: '调整待办列表与详情宽度',
|
||||
|
||||
+635
-31
@@ -1278,6 +1278,496 @@
|
||||
var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
|
||||
.magic-notes-workspace {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes {
|
||||
grid-template-columns: var(--magic-notes-notes-width, 280px) 9px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-workspace--list-hidden,
|
||||
.magic-notes-workspace--narrow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-detail-container {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
container: magic-notes-detail / inline-size;
|
||||
}
|
||||
|
||||
.magic-notes-detail-container[hidden] { display: none; }
|
||||
|
||||
.magic-notes-workspace--notes > .magic-notes-overview {
|
||||
padding: 0 var(--space-2) 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-notes-library-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-notes-card-grid {
|
||||
overflow: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-notes-library-panel {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-notes-card-grid {
|
||||
gap: 2px;
|
||||
margin-inline: calc(-1 * var(--space-1));
|
||||
padding-inline: var(--space-1);
|
||||
}
|
||||
|
||||
/* Compact two-line list rows: title + date, then a one-line preview. */
|
||||
.magic-notes-workspace--notes .magic-note-list-item {
|
||||
min-height: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
gap: 2px;
|
||||
grid-template-rows: auto auto;
|
||||
border-color: transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-note-list-item:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-note-list-item__title {
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.magic-note-list-item__title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.magic-note-list-item__title > time {
|
||||
flex: none;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-note-list-item__preview {
|
||||
font-size: var(--font-caption);
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
/* The more button overlays the date on hover, so the title keeps its width. */
|
||||
.magic-notes-workspace--notes .magic-note-row .magic-note-list-item__title {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-note-row:is(:hover, :focus-within) .magic-note-list-item__title > time {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--notes .magic-note-more.icon-button {
|
||||
top: 50%;
|
||||
right: var(--space-1);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--surface-raised);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.magic-note-list-item[aria-current='true'],
|
||||
.magic-note-list-item[aria-current='true']:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: transparent;
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.magic-note-list-item[aria-current='true'] { border-color: Highlight; }
|
||||
}
|
||||
|
||||
/* Heading: title and count grouped left, new-note action right. */
|
||||
#magic-library-panel-notes > .magic-notes-pane-heading {
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
#magic-library-panel-notes > .magic-notes-pane-heading > .icon-button,
|
||||
#magic-library-panel-notes > .magic-notes-pane-heading > #magic-note-tag-manager {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#magic-library-panel-notes > .magic-notes-pane-heading > #magic-note-tag-manager + .icon-button {
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager-button {
|
||||
min-height: 28px;
|
||||
margin-block: -4px;
|
||||
padding-inline: var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
/* Tags: neutral chips, no colours by design. */
|
||||
.magic-note-tag-chips {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: var(--space-1);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.4;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--more {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--toggle {
|
||||
min-height: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--toggle small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--toggle:hover:not(:disabled) {
|
||||
border-color: var(--border-control);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--toggle[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--toggle[aria-pressed='true'] small {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--toggle:focus-visible,
|
||||
.magic-note-tag-add:focus-visible,
|
||||
.magic-note-tag-chip--removable button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, var(--accent));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.magic-note-tag-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-active-tag-filter {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: 2px 2px 2px var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-active-tag-filter__names {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-note-active-tag-filter .icon-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.magic-note-list-item > .magic-note-tag-chips {
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.magic-note-tag-editor {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-tag-editor > .magic-note-tag-chips {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--removable {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--removable button {
|
||||
display: inline-grid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.magic-note-tag-chip--removable button:hover:not(:disabled) {
|
||||
background: var(--surface-hover, var(--border-subtle));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.magic-note-tag-add {
|
||||
display: inline-flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px dashed var(--border-control);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-caption);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.magic-note-tag-add:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.magic-note-tag-add:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.magic-note-tag-combobox {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.magic-note-tag-combobox > input {
|
||||
width: 180px;
|
||||
min-height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.magic-note-tag-combobox > input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--accent-subtle);
|
||||
}
|
||||
|
||||
.magic-note-tag-suggestions {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-popover, var(--shadow-dialog));
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.magic-note-tag-suggestions > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px var(--space-2);
|
||||
border-radius: calc(var(--radius-control) - 2px);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-tag-suggestions > li[aria-selected='true'] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__body {
|
||||
overflow: auto;
|
||||
padding: var(--space-2) var(--space-5) var(--space-5);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__row {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__row > .magic-note-tag-chip {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__count {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__row .icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__delete:hover:not(:disabled) {
|
||||
color: var(--danger-strong);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__rename {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding-block: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__rename > input {
|
||||
min-width: 0;
|
||||
min-height: var(--control-height);
|
||||
flex: 1;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__rename > small {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__hint {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__confirm {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding-block: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__confirm > span {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
/* Overview: all notes as a responsive grid of compact cards. */
|
||||
.magic-notes-workspace--overview > .magic-notes-overview {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--overview .magic-notes-library-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--overview .magic-notes-card-grid {
|
||||
overflow: auto;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.magic-notes-workspace--overview .magic-note-list-item {
|
||||
min-height: 116px;
|
||||
gap: var(--space-2);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--overview .magic-note-list-item:has(> .magic-note-tag-chips) {
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
}
|
||||
|
||||
.magic-note-list-item__meta--overview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.magic-notes-workspace--overview .magic-note-list-item__meta--overview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.magic-notes-layout--index-hidden {
|
||||
grid-template-columns: minmax(300px, 1fr) 9px var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
@@ -1956,22 +2446,81 @@
|
||||
}
|
||||
|
||||
.magic-notes-stream-pane {
|
||||
padding: 0;
|
||||
--magic-note-stream-gutter: var(--space-6);
|
||||
padding: 0 var(--magic-note-stream-gutter) var(--space-6);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Keep a readable line length on wide windows. */
|
||||
.magic-notes-stream-pane > :not(.magic-note-detail-header, .magic-note-detail-toolbar),
|
||||
.magic-notes-stream-pane > .magic-note-detail-header > .magic-note-detail-title {
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.magic-notes-stream-pane > * {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@container magic-notes-detail (max-width: 600px) {
|
||||
.magic-notes-stream-pane { --magic-note-stream-gutter: var(--space-3); }
|
||||
}
|
||||
|
||||
.magic-notes-layout--detail {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.magic-note-detail-header {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
margin-bottom: var(--space-3);
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-detail-toolbar {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
margin-inline: calc(-1 * var(--magic-note-stream-gutter, var(--space-4)));
|
||||
padding: var(--space-1) var(--magic-note-stream-gutter, var(--space-4));
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-detail-toolbar .icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.magic-note-detail-toolbar__meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
padding-inline: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-note-detail-toolbar__ai[aria-expanded='true'] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.magic-note-detail-header > .magic-note-detail-title {
|
||||
width: 100%;
|
||||
margin: var(--space-3) auto 0;
|
||||
padding: var(--space-1) 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 1px 0 transparent;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.magic-notes-ai-source {
|
||||
@@ -1981,20 +2530,17 @@
|
||||
|
||||
.magic-note-detail-header > input {
|
||||
min-width: 0;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
font-size: var(--font-body);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.magic-note-detail-header > input:hover,
|
||||
.magic-note-detail-header > input:hover {
|
||||
box-shadow: 0 1px 0 var(--border-subtle);
|
||||
}
|
||||
|
||||
.magic-note-detail-header > input:focus {
|
||||
border-color: var(--border-control);
|
||||
background: var(--surface-raised);
|
||||
outline: none;
|
||||
box-shadow: 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.magic-notes-field-error {
|
||||
@@ -2110,6 +2656,50 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.magic-note-entry__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.magic-note-entry__actions .icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled) {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger-strong);
|
||||
}
|
||||
|
||||
/* Entry actions stay quiet until the entry is hovered or focused. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.magic-note-entry__actions { opacity: 0; transition: opacity var(--motion-fast) ease-out; }
|
||||
.magic-note-entry:hover .magic-note-entry__actions,
|
||||
.magic-note-entry:focus-within .magic-note-entry__actions { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Idle composer collapses to a single prompt line; focus or a draft expands it. */
|
||||
.magic-notes-stream-pane .magic-note-composer:not(.magic-note-composer--active):not(:focus-within) :is(.magic-note-composer__header, .magic-note-editor__toolbar.ql-toolbar.ql-snow, footer) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.magic-notes-stream-pane .magic-note-composer:not(.magic-note-composer--active):not(:focus-within) > .magic-note-editor .magic-note-editor__content,
|
||||
.magic-notes-stream-pane .magic-note-composer:not(.magic-note-composer--active):not(:focus-within) > .magic-note-editor .magic-note-editor__content .ql-editor {
|
||||
min-height: 0;
|
||||
max-height: 46px;
|
||||
overflow: hidden;
|
||||
cursor: text;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.magic-notes-stream-pane .magic-note-composer:not(.magic-note-composer--active):not(:focus-within) {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.magic-notes-stream-pane .magic-note-composer:not(.magic-note-composer--active):not(:focus-within):hover {
|
||||
border-color: var(--border-control);
|
||||
}
|
||||
|
||||
.magic-note-entry-type {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
@@ -2428,7 +3018,35 @@
|
||||
margin-block: var(--space-2);
|
||||
}
|
||||
|
||||
@container magic-notes-page (max-width: 800px) {
|
||||
/* Medium detail width: keep the editor and AI side by side, record index becomes a drawer. */
|
||||
@container magic-notes-detail (max-width: 800px) {
|
||||
.magic-notes-layout {
|
||||
grid-template-columns: minmax(300px, 1fr) 9px var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
|
||||
.magic-notes-layout--overview,
|
||||
.magic-notes-layout--ai-hidden { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
.magic-notes-index-pane--drawer-open {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 168px;
|
||||
padding: var(--space-2);
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
background: var(--surface-raised);
|
||||
border-right: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.magic-notes-index-resize-handle { display: none; }
|
||||
}
|
||||
|
||||
/* Narrow detail width: stack the AI pane below the note stream. */
|
||||
@container magic-notes-detail (max-width: 600px) {
|
||||
.magic-notes-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, min(40%, 280px));
|
||||
@@ -2436,24 +3054,10 @@
|
||||
|
||||
.magic-notes-layout--overview,
|
||||
.magic-notes-layout--ai-hidden {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-layout--overview { grid-template-columns: minmax(0, 1fr); }
|
||||
.magic-notes-index-pane--drawer-open {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0;
|
||||
top: calc(var(--control-height) + var(--space-3));
|
||||
bottom: 0;
|
||||
width: 168px;
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
background: var(--surface-raised);
|
||||
border-right: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.magic-notes-pane-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user