fix: improve note icon property editing

This commit is contained in:
Test
2026-04-08 20:40:14 +02:00
parent 16e5dd466a
commit f8112e9385
6 changed files with 608 additions and 57 deletions

View File

@@ -202,17 +202,31 @@
flex-shrink: 0;
}
.title-section__add-icon {
/* "Add icon" button above the title when no emoji — Notion-style */
padding-top: 24px;
margin-left: 8px;
min-height: 28px;
.title-section__heading {
position: relative;
}
.title-section__inline-add-icon {
position: absolute;
left: 8px;
top: 0;
z-index: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.title-section:hover .title-section__inline-add-icon,
.title-section__inline-add-icon:focus-within {
opacity: 1;
pointer-events: auto;
}
.title-section__row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding-top: 8px;
margin-left: 8px;
}
@@ -235,6 +249,8 @@
/* --- Note Icon Area --- */
.note-icon-area {
display: flex;
align-items: flex-start;
flex-shrink: 0;
}
@@ -251,16 +267,24 @@
}
.note-icon-button--active {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 36px;
line-height: 1;
transition: transform 0.1s;
margin-right: 10px;
}
.note-icon-button--active:hover:not(:disabled) {
transform: scale(1.1);
}
.note-icon-button--active :is(img, svg) {
display: block;
}
.note-icon-button--add {
display: flex;
align-items: center;

View File

@@ -0,0 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IconEditableValue } from './IconEditableValue'
function renderIconValue(overrides: Partial<React.ComponentProps<typeof IconEditableValue>> = {}) {
const onSave = vi.fn()
const onCancel = vi.fn()
const onStartEdit = vi.fn()
render(
<IconEditableValue
value=""
onSave={onSave}
onCancel={onCancel}
isEditing
onStartEdit={onStartEdit}
{...overrides}
/>,
)
return { onSave, onCancel, onStartEdit }
}
describe('IconEditableValue', () => {
it('shows searchable icon results with previews while editing', () => {
renderIconValue()
expect(screen.getByTestId('icon-editable-input')).toHaveAttribute('role', 'combobox')
expect(screen.getByTestId('icon-picker-results')).toBeInTheDocument()
expect(screen.getAllByRole('option').length).toBeGreaterThan(0)
})
it('selects the first matching icon on Enter for icon-name queries', () => {
const { onSave } = renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'rocket' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('rocket')
})
it('supports keyboard navigation before selecting an icon', () => {
const { onSave } = renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'file' } })
const options = screen.getAllByRole('option')
expect(options.length).toBeGreaterThan(1)
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith(options[1].textContent ?? '')
})
it('keeps manual URL values instead of forcing an icon suggestion', () => {
const { onSave } = renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'https://example.com/icon.png' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('https://example.com/icon.png')
})
it('shows an empty state when no icon matches the query', () => {
renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'totally-not-a-real-icon' } })
expect(screen.getByTestId('icon-picker-empty')).toHaveTextContent('No icons found')
})
})

View File

@@ -0,0 +1,181 @@
import { useId, useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { isEmoji } from '../utils/emoji'
import { ICON_OPTIONS } from '../utils/iconRegistry'
const MAX_ICON_RESULTS = 24
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function normalizeIconQuery(query: string): string {
return query.trim().toLowerCase()
}
function matchesIconQuery(name: string, query: string): boolean {
if (!query) return true
const normalized = normalizeIconQuery(query)
const spacedName = name.replace(/-/g, ' ')
const dashedQuery = normalized.replace(/\s+/g, '-')
return name.includes(dashedQuery) || spacedName.includes(normalized)
}
function filterIconOptions(query: string) {
return ICON_OPTIONS
.filter((option) => matchesIconQuery(option.name, query))
.slice(0, MAX_ICON_RESULTS)
}
function shouldSelectIconSuggestion(value: string, suggestionCount: number): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (isEmoji(trimmed) || isHttpUrl(trimmed)) return false
return suggestionCount > 0
}
interface IconEditableValueProps {
value: string
onSave: (newValue: string) => void
onCancel: () => void
isEditing: boolean
onStartEdit: () => void
}
function IconEditableInput({
value,
onSave,
onCancel,
}: Pick<IconEditableValueProps, 'value' | 'onSave' | 'onCancel'>) {
const [editValue, setEditValue] = useState(value)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const listboxId = useId()
const filteredIcons = useMemo(() => filterIconOptions(editValue), [editValue])
const commitTypedValue = () => onSave(editValue)
const selectIcon = (iconName: string) => {
setEditValue(iconName)
onSave(iconName)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
if (filteredIcons.length === 0) return
setHighlightedIndex((current) => (current + 1) % filteredIcons.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (filteredIcons.length === 0) return
setHighlightedIndex((current) => (current - 1 + filteredIcons.length) % filteredIcons.length)
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (shouldSelectIconSuggestion(editValue, filteredIcons.length)) {
selectIcon(filteredIcons[highlightedIndex]?.name ?? editValue)
return
}
commitTypedValue()
return
}
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}
return (
<div className="relative flex flex-col gap-2" data-testid="icon-editable-value">
<Input
type="text"
value={editValue}
onChange={(event) => {
setEditValue(event.target.value)
setHighlightedIndex(0)
}}
onKeyDown={handleKeyDown}
onBlur={commitTypedValue}
autoFocus
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded
aria-activedescendant={filteredIcons[highlightedIndex] ? `${listboxId}-option-${highlightedIndex}` : undefined}
placeholder="Emoji, icon name, or URL"
className="h-8 text-[12px]"
data-testid="icon-editable-input"
/>
<div
id={listboxId}
role="listbox"
className="max-h-44 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-sm"
data-testid="icon-picker-results"
>
{filteredIcons.length === 0 ? (
<div className="px-2 py-6 text-center text-[12px] text-muted-foreground" data-testid="icon-picker-empty">
No icons found
</div>
) : (
filteredIcons.map(({ name, Icon }, index) => (
<button
key={name}
id={`${listboxId}-option-${index}`}
type="button"
role="option"
aria-selected={index === highlightedIndex}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] transition-colors',
index === highlightedIndex
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-accent hover:text-foreground',
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectIcon(name)}
data-testid={`icon-picker-option-${name}`}
>
<Icon size={14} aria-hidden="true" className="shrink-0" />
<span className="truncate">{name}</span>
</button>
))
)}
</div>
</div>
)
}
export function IconEditableValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit,
}: IconEditableValueProps) {
if (isEditing) {
return <IconEditableInput key={value} value={value} onSave={onSave} onCancel={onCancel} />
}
return (
<span
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="icon-editable-display"
>
{value || '\u2014'}
</span>
)
}

View File

@@ -19,6 +19,7 @@ import { getStatusStyle } from '../utils/statusStyles'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
import { IconEditableValue } from './IconEditableValue'
function parseDateValue(value: string): Date | undefined {
const iso = toISODate(value)
@@ -301,9 +302,52 @@ type SmartCellProps = {
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
}
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode
interface ScalarEditProps {
value: string
isEditing: boolean
onStartEdit: () => void
onSave: (nextValue: string) => void
onCancel: () => void
}
function createScalarEditProps({
propKey,
value,
isEditing,
onStartEdit,
onSave,
}: {
propKey: string
value: FrontmatterValue
isEditing: boolean
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
}): ScalarEditProps {
return {
value: String(value ?? ''),
isEditing,
onStartEdit: () => onStartEdit(propKey),
onSave: (nextValue: string) => onSave(propKey, nextValue),
onCancel: () => onStartEdit(null),
}
}
function renderScalarDisplayMode({
propKey,
value,
isEditing,
resolvedMode,
vaultStatuses,
vaultTags,
onSave,
onSaveList,
onStartEdit,
onUpdate,
editProps,
}: SmartCellProps & {
resolvedMode: PropertyDisplayMode
editProps: ScalarEditProps
}) {
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
@@ -332,6 +376,24 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses
}
}
function ScalarValueCell(props: SmartCellProps) {
const { propKey, value, displayMode, isEditing, onStartEdit, onSave } = props
const editProps = createScalarEditProps({
propKey,
value,
isEditing,
onStartEdit,
onSave,
})
if (propKey.toLowerCase() === 'icon') {
return <IconEditableValue {...editProps} />
}
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode
return renderScalarDisplayMode({ ...props, resolvedMode, editProps })
}
export function SmartPropertyValueCell(props: SmartCellProps) {
const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props
if (Array.isArray(value)) {

View File

@@ -0,0 +1,120 @@
import { createRef } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { EditorContentLayout } from './EditorContentLayout'
vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
}))
vi.mock('../TitleField', () => ({
TitleField: () => <div data-testid="title-field-input" />,
}))
vi.mock('../NoteIcon', () => ({
NoteIcon: ({ icon }: { icon: string | null }) => (
icon
? <button type="button" data-testid="note-icon-display" />
: <button type="button" data-testid="note-icon-add" />
),
}))
vi.mock('../ArchivedNoteBanner', () => ({
ArchivedNoteBanner: () => <div data-testid="archived-banner" />,
}))
vi.mock('../ConflictNoteBanner', () => ({
ConflictNoteBanner: () => <div data-testid="conflict-banner" />,
}))
vi.mock('../RawEditorView', () => ({
RawEditorView: () => <div data-testid="raw-editor-view" />,
}))
vi.mock('../SingleEditorView', () => ({
SingleEditorView: () => <div data-testid="single-editor-view" />,
}))
vi.mock('../DiffView', () => ({
DiffView: () => <div data-testid="diff-view" />,
}))
function createModel(overrides: Record<string, unknown> = {}) {
return {
activeTab: {
entry: {
path: '/vault/project/demo.md',
filename: 'demo.md',
title: 'Demo Note',
},
content: 'Body',
},
isLoadingNewTab: false,
entries: [],
editor: {},
diffMode: false,
diffContent: null,
diffLoading: false,
onToggleDiff: vi.fn(),
effectiveRawMode: false,
onToggleRaw: vi.fn(),
onRawContentChange: vi.fn(),
onSave: vi.fn(),
showEditor: true,
isArchived: false,
onUnarchiveNote: undefined,
path: '/vault/project/demo.md',
isConflicted: false,
onKeepMine: vi.fn(),
onKeepTheirs: vi.fn(),
breadcrumbBarRef: createRef<HTMLDivElement>(),
wordCount: 12,
titleSectionRef: createRef<HTMLDivElement>(),
showTitleSection: true,
hasDisplayIcon: false,
entryIcon: null,
vaultPath: '/vault',
onTitleChange: vi.fn(),
cssVars: {},
onNavigateWikilink: vi.fn(),
onEditorChange: vi.fn(),
isDeletedPreview: false,
rawLatestContentRef: { current: null },
forceRawMode: false,
showAIChat: false,
onToggleAIChat: vi.fn(),
inspectorCollapsed: true,
onToggleInspector: vi.fn(),
showDiffToggle: false,
onToggleFavorite: vi.fn(),
onToggleOrganized: vi.fn(),
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
...overrides,
} as never
}
describe('EditorContentLayout', () => {
it('does not render a standalone add-icon row when the note has no icon', () => {
const { container } = render(<EditorContentLayout {...createModel()} />)
expect(container.querySelector('.title-section__add-icon')).toBeNull()
expect(container.querySelector('.title-section__inline-add-icon')).not.toBeNull()
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
})
it('keeps the existing icon and title inside the same title row', () => {
const { container } = render(
<EditorContentLayout
{...createModel({
hasDisplayIcon: true,
entryIcon: 'rocket',
})}
/>,
)
const titleRow = container.querySelector('.title-section__row')
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
})
})

View File

@@ -150,21 +150,23 @@ function TitleSection({
<div ref={titleSectionRef} className="title-section" data-title-ui-visible={showTitleSection || undefined}>
{showTitleSection && (
<>
{!hasDisplayIcon && (
<div className="title-section__add-icon">
<NoteIcon icon={null} editable />
<div className="title-section__heading">
{!hasDisplayIcon && (
<div className="title-section__inline-add-icon">
<NoteIcon icon={null} editable />
</div>
)}
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
)}
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
<div className="title-section__separator" />
</>
@@ -173,6 +175,98 @@ function TitleSection({
)
}
function EditorChrome({
isArchived,
onUnarchiveNote,
path,
isConflicted,
onKeepMine,
onKeepTheirs,
diffMode,
diffContent,
onToggleDiff,
}: Pick<
EditorContentModel,
'isArchived' | 'onUnarchiveNote' | 'path' | 'isConflicted' | 'onKeepMine' | 'onKeepTheirs' | 'diffMode' | 'diffContent' | 'onToggleDiff'
>) {
return (
<>
{isArchived && onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} />
)}
{isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(path)}
onKeepTheirs={() => onKeepTheirs?.(path)}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
</>
)
}
function EditorCanvas({
showEditor,
cssVars,
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
editor,
entries,
onNavigateWikilink,
onEditorChange,
isDeletedPreview,
}: Pick<
EditorContentModel,
| 'showEditor'
| 'cssVars'
| 'activeTab'
| 'entryIcon'
| 'hasDisplayIcon'
| 'path'
| 'showTitleSection'
| 'titleSectionRef'
| 'vaultPath'
| 'onTitleChange'
| 'editor'
| 'entries'
| 'onNavigateWikilink'
| 'onEditorChange'
| 'isDeletedPreview'
>) {
if (!showEditor) return null
return (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<TitleSection
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
/>
<SingleEditorView
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onChange={onEditorChange}
vaultPath={vaultPath}
editable={!isDeletedPreview}
/>
</div>
</div>
)
}
export function EditorContentLayout(model: EditorContentModel) {
const {
activeTab,
@@ -237,16 +331,17 @@ export function EditorContentLayout(model: EditorContentModel) {
onUnarchiveNote: model.onUnarchiveNote,
}}
/>
{isArchived && onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} />
)}
{isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(path)}
onKeepTheirs={() => onKeepTheirs?.(path)}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<EditorChrome
isArchived={isArchived}
onUnarchiveNote={onUnarchiveNote}
path={path}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
diffMode={diffMode}
diffContent={diffContent}
onToggleDiff={onToggleDiff}
/>
<RawModeEditorSection
activeTab={activeTab}
entries={entries}
@@ -256,30 +351,23 @@ export function EditorContentLayout(model: EditorContentModel) {
rawLatestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
/>
{showEditor && (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<TitleSection
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
/>
<SingleEditorView
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onChange={onEditorChange}
vaultPath={vaultPath}
editable={!isDeletedPreview}
/>
</div>
</div>
)}
<EditorCanvas
showEditor={showEditor}
cssVars={cssVars}
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onEditorChange={onEditorChange}
isDeletedPreview={isDeletedPreview}
/>
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)