fix: improve note icon property editing
This commit is contained in:
@@ -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;
|
||||
|
||||
76
src/components/IconEditableValue.test.tsx
Normal file
76
src/components/IconEditableValue.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
181
src/components/IconEditableValue.tsx
Normal file
181
src/components/IconEditableValue.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
120
src/components/editor-content/EditorContentLayout.test.tsx
Normal file
120
src/components/editor-content/EditorContentLayout.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user