mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
bases
This commit is contained in:
parent
5131fb7f9e
commit
5aba6025dc
8 changed files with 1547 additions and 290 deletions
|
|
@ -12,6 +12,7 @@ import { ChatSidebar } from './components/chat-sidebar';
|
|||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
||||
import { useDebounce } from './hooks/use-debounce';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
|
|
@ -46,7 +47,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter, extractTags } from '@/lib/frontmatter'
|
||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
|
|
@ -106,6 +107,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
|||
const TITLEBAR_BUTTONS_COLLAPSED = 5
|
||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
|
||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
|
|
@ -233,6 +235,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
|
|||
}
|
||||
|
||||
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||
|
||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
if (!usage) return null
|
||||
|
|
@ -470,6 +473,7 @@ function App() {
|
|||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
|
|
@ -510,9 +514,8 @@ function App() {
|
|||
const initialContentRef = useRef<string>('')
|
||||
const renameInProgressRef = useRef(false)
|
||||
|
||||
// Frontmatter state: store raw frontmatter per file path, tags for active file
|
||||
// Frontmatter state: store raw frontmatter per file path
|
||||
const frontmatterByPathRef = useRef<Map<string, string | null>>(new Map())
|
||||
const [activeFileTags, setActiveFileTags] = useState<string[]>([])
|
||||
|
||||
// Version history state
|
||||
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
||||
|
|
@ -621,6 +624,8 @@ function App() {
|
|||
|
||||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
}, [])
|
||||
|
||||
|
|
@ -818,20 +823,45 @@ function App() {
|
|||
}
|
||||
}, [runId, processingRunIds])
|
||||
|
||||
// Load directory tree
|
||||
// Load directory tree (knowledge + bases)
|
||||
const loadDirectory = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readdir', {
|
||||
path: 'knowledge',
|
||||
opts: { recursive: true, includeHidden: false }
|
||||
})
|
||||
return buildTree(result)
|
||||
const [knowledgeResult, basesResult] = await Promise.all([
|
||||
window.ipc.invoke('workspace:readdir', {
|
||||
path: 'knowledge',
|
||||
opts: { recursive: true, includeHidden: false, includeStats: true }
|
||||
}),
|
||||
window.ipc.invoke('workspace:readdir', {
|
||||
path: 'bases',
|
||||
opts: { recursive: false, includeHidden: false, includeStats: true }
|
||||
}).catch(() => [] as DirEntry[]),
|
||||
])
|
||||
const knowledgeTree = buildTree(knowledgeResult)
|
||||
const basesChildren: TreeNode[] = (basesResult as DirEntry[])
|
||||
.filter((e) => e.name.endsWith('.base'))
|
||||
.map((e) => ({ ...e, kind: 'file' as const }))
|
||||
if (basesChildren.length > 0) {
|
||||
const basesFolder: TreeNode = {
|
||||
name: 'Bases',
|
||||
path: 'bases',
|
||||
kind: 'dir',
|
||||
children: basesChildren,
|
||||
}
|
||||
return [...knowledgeTree, basesFolder]
|
||||
}
|
||||
return knowledgeTree
|
||||
} catch (err) {
|
||||
console.error('Failed to load directory:', err)
|
||||
return []
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Ensure bases/ directory exists on startup
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('workspace:mkdir', { path: 'bases', recursive: true })
|
||||
.catch((err: unknown) => console.error('Failed to ensure bases directory:', err))
|
||||
}, [])
|
||||
|
||||
// Load initial tree
|
||||
useEffect(() => {
|
||||
loadDirectory().then(setTree)
|
||||
|
|
@ -905,7 +935,6 @@ function App() {
|
|||
editorPathRef.current = pathToReload
|
||||
initialContentByPathRef.current.set(pathToReload, body)
|
||||
initialContentRef.current = body
|
||||
setActiveFileTags(extractTags(fm))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -923,6 +952,31 @@ function App() {
|
|||
setLastSaved(null)
|
||||
return
|
||||
}
|
||||
if (selectedPath === BASES_DEFAULT_TAB_PATH) {
|
||||
// Virtual default base — no file to load, use DEFAULT_BASE_CONFIG
|
||||
if (!baseConfigByPath[selectedPath]) {
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } }))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (selectedPath.endsWith('.base')) {
|
||||
// Load base config from file only if not already cached
|
||||
if (!baseConfigByPath[selectedPath]) {
|
||||
window.ipc.invoke('workspace:readFile', { path: selectedPath, encoding: 'utf8' })
|
||||
.then((result: { data: string }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(result.data) as BaseConfig
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: parsed }))
|
||||
} catch {
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } }))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } }))
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (selectedPath.endsWith('.md')) {
|
||||
const cachedContent = editorContentByPathRef.current.get(selectedPath)
|
||||
const hasBaseline = initialContentByPathRef.current.has(selectedPath)
|
||||
|
|
@ -934,7 +988,6 @@ function App() {
|
|||
editorContentRef.current = cachedContent
|
||||
editorPathRef.current = selectedPath
|
||||
initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
|
||||
setActiveFileTags(extractTags(frontmatterByPathRef.current.get(selectedPath) ?? null))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -943,42 +996,43 @@ function App() {
|
|||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad })
|
||||
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
||||
if (stat.kind === 'file') {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad })
|
||||
// For .md files (from the knowledge tree), skip stat and read directly.
|
||||
// For other file types, stat first to check if it's a file vs directory.
|
||||
const isKnownFile = pathToLoad.endsWith('.md')
|
||||
if (!isKnownFile) {
|
||||
const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad })
|
||||
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
||||
setFileContent(result.data)
|
||||
const { raw: fm, body } = splitFrontmatter(result.data)
|
||||
frontmatterByPathRef.current.set(pathToLoad, fm)
|
||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
const isSameEditorFile = editorPathRef.current === pathToLoad
|
||||
const knownBaseline = initialContentByPathRef.current.get(pathToLoad)
|
||||
const hasKnownBaseline = knownBaseline !== undefined
|
||||
const hasUnsavedEdits =
|
||||
hasKnownBaseline
|
||||
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline)
|
||||
const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits
|
||||
if (!shouldPreserveActiveDraft) {
|
||||
setEditorContent(body)
|
||||
if (pathToLoad.endsWith('.md')) {
|
||||
setEditorCacheForPath(pathToLoad, body)
|
||||
}
|
||||
editorContentRef.current = body
|
||||
editorPathRef.current = pathToLoad
|
||||
initialContentByPathRef.current.set(pathToLoad, body)
|
||||
initialContentRef.current = body
|
||||
setLastSaved(null)
|
||||
setActiveFileTags(extractTags(fm))
|
||||
} else {
|
||||
// Still update the editor's path so subsequent autosaves write to the correct file.
|
||||
editorPathRef.current = pathToLoad
|
||||
if (stat.kind !== 'file') {
|
||||
setFileContent('')
|
||||
setEditorContent('')
|
||||
editorContentRef.current = ''
|
||||
initialContentRef.current = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad })
|
||||
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
||||
setFileContent(result.data)
|
||||
const { raw: fm, body } = splitFrontmatter(result.data)
|
||||
frontmatterByPathRef.current.set(pathToLoad, fm)
|
||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
const isSameEditorFile = editorPathRef.current === pathToLoad
|
||||
const wouldClobberActiveEdits =
|
||||
isSameEditorFile
|
||||
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(body)
|
||||
if (!wouldClobberActiveEdits) {
|
||||
setEditorContent(body)
|
||||
if (pathToLoad.endsWith('.md')) {
|
||||
setEditorCacheForPath(pathToLoad, body)
|
||||
}
|
||||
editorContentRef.current = body
|
||||
editorPathRef.current = pathToLoad
|
||||
initialContentByPathRef.current.set(pathToLoad, body)
|
||||
initialContentRef.current = body
|
||||
setLastSaved(null)
|
||||
} else {
|
||||
setFileContent('')
|
||||
setEditorContent('')
|
||||
editorContentRef.current = ''
|
||||
initialContentRef.current = ''
|
||||
// Still update the editor's path so subsequent autosaves write to the correct file.
|
||||
editorPathRef.current = pathToLoad
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load file:', err)
|
||||
|
|
@ -2177,7 +2231,7 @@ function App() {
|
|||
|
||||
const closeFileTab = useCallback((tabId: string) => {
|
||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||
if (closingTab && !isGraphTabPath(closingTab.path)) {
|
||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||
removeEditorCacheForPath(closingTab.path)
|
||||
initialContentByPathRef.current.delete(closingTab.path)
|
||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||
|
|
@ -2186,13 +2240,20 @@ function App() {
|
|||
editorPathRef.current = null
|
||||
}
|
||||
}
|
||||
if (closingTab && isBaseFilePath(closingTab.path)) {
|
||||
setBaseConfigByPath((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[closingTab.path]
|
||||
return next
|
||||
})
|
||||
}
|
||||
setFileTabs(prev => {
|
||||
if (prev.length <= 1) {
|
||||
// Last file tab - close it and go back to chat
|
||||
setActiveFileTabId(null)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
return []
|
||||
return []
|
||||
}
|
||||
const idx = prev.findIndex(t => t.id === tabId)
|
||||
if (idx === -1) return prev
|
||||
|
|
@ -2206,7 +2267,7 @@ function App() {
|
|||
setIsGraphOpen(true)
|
||||
} else {
|
||||
setIsGraphOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
return next
|
||||
|
|
@ -2314,7 +2375,7 @@ function App() {
|
|||
|
||||
if (activeFileTabId) {
|
||||
const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId)
|
||||
if (activeTab && !isGraphTabPath(activeTab.path)) {
|
||||
if (activeTab && !isGraphTabPath(activeTab.path) && !isBaseFilePath(activeTab.path)) {
|
||||
setFileTabs((prev) => prev.map((tab) => (
|
||||
tab.id === activeFileTabId ? { ...tab, path } : tab
|
||||
)))
|
||||
|
|
@ -2459,6 +2520,46 @@ function App() {
|
|||
void navigateToView({ type: 'file', path })
|
||||
}, [navigateToView])
|
||||
|
||||
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
|
||||
}, [])
|
||||
|
||||
const handleBaseSave = useCallback(async (name: string | null) => {
|
||||
if (!selectedPath) return
|
||||
const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH
|
||||
const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG
|
||||
|
||||
if (isDefault && name) {
|
||||
// Save as new base file
|
||||
const safeName = name.replace(/[\\/]/g, '-').trim()
|
||||
const newPath = `bases/${safeName}.base`
|
||||
const fileConfig = { ...config, name: safeName }
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: newPath,
|
||||
data: JSON.stringify(fileConfig, null, 2),
|
||||
})
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [newPath]: fileConfig }))
|
||||
// Refresh tree then navigate to the new file
|
||||
const newTree = await loadDirectory()
|
||||
setTree(newTree)
|
||||
void navigateToView({ type: 'file', path: newPath })
|
||||
} catch (err) {
|
||||
console.error('Failed to save base:', err)
|
||||
}
|
||||
} else if (!isDefault) {
|
||||
// Save in place
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: selectedPath,
|
||||
data: JSON.stringify(config, null, 2),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to save base:', err)
|
||||
}
|
||||
}
|
||||
}, [selectedPath, baseConfigByPath, loadDirectory, navigateToView])
|
||||
|
||||
const navigateToFullScreenChat = useCallback(() => {
|
||||
// Only treat this as navigation when coming from another view
|
||||
if (currentViewState.type !== 'chat') {
|
||||
|
|
@ -2771,6 +2872,13 @@ function App() {
|
|||
}
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
openBases: () => {
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
},
|
||||
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
|
||||
collapseAll: () => setExpandedPaths(new Set()),
|
||||
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
||||
|
|
@ -3270,7 +3378,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
onCloseTab={closeFileTab}
|
||||
allowSingleTabClose={fileTabs.length === 1 && isGraphOpen}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -3283,7 +3391,7 @@ function App() {
|
|||
onCloseTab={closeChatTab}
|
||||
/>
|
||||
)}
|
||||
{selectedPath && (
|
||||
{selectedPath && selectedPath.endsWith('.md') && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
|
@ -3372,7 +3480,18 @@ function App() {
|
|||
)}
|
||||
</ContentHeader>
|
||||
|
||||
{isGraphOpen ? (
|
||||
{selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
tree={tree}
|
||||
onSelectNote={(path) => navigateToFile(path)}
|
||||
config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG}
|
||||
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
|
||||
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
|
||||
onSave={(name) => void handleBaseSave(name)}
|
||||
/>
|
||||
</div>
|
||||
) : isGraphOpen ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<GraphView
|
||||
nodes={graphData.nodes}
|
||||
|
|
@ -3418,7 +3537,20 @@ function App() {
|
|||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||
tags={isActive ? activeFileTags : undefined}
|
||||
frontmatter={frontmatterByPathRef.current.get(tab.path) ?? null}
|
||||
onFrontmatterChange={(newRaw) => {
|
||||
frontmatterByPathRef.current.set(tab.path, newRaw)
|
||||
// Write updated frontmatter to disk immediately
|
||||
const currentBody = editorContentRef.current
|
||||
const fullContent = joinFrontmatter(newRaw, currentBody)
|
||||
initialContentByPathRef.current.set(tab.path, splitFrontmatter(fullContent).body)
|
||||
initialContentRef.current = splitFrontmatter(fullContent).body
|
||||
void window.ipc.invoke('workspace:writeFile', {
|
||||
path: tab.path,
|
||||
data: fullContent,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
}}
|
||||
onHistoryHandlersChange={(handlers) => {
|
||||
if (handlers) {
|
||||
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { ChevronRight, X, Plus } from 'lucide-react'
|
||||
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||
|
||||
interface FrontmatterPropertiesProps {
|
||||
raw: string | null
|
||||
onRawChange: (raw: string | null) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
type FieldEntry = { key: string; value: string | string[] }
|
||||
|
||||
function fieldsFromRaw(raw: string | null): FieldEntry[] {
|
||||
const record = extractAllFrontmatterValues(raw)
|
||||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
function fieldsToRaw(fields: FieldEntry[]): string | null {
|
||||
const record: Record<string, string | string[]> = {}
|
||||
for (const { key, value } of fields) {
|
||||
if (key.trim()) record[key.trim()] = value
|
||||
}
|
||||
return buildFrontmatter(record)
|
||||
}
|
||||
|
||||
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [fields, setFields] = useState<FieldEntry[]>(() => fieldsFromRaw(raw))
|
||||
const [editingNewKey, setEditingNewKey] = useState(false)
|
||||
const newKeyRef = useRef<HTMLInputElement>(null)
|
||||
const lastCommittedRaw = useRef(raw)
|
||||
|
||||
// Sync local fields when raw changes externally (e.g. tab switch)
|
||||
useEffect(() => {
|
||||
if (raw !== lastCommittedRaw.current) {
|
||||
setFields(fieldsFromRaw(raw))
|
||||
lastCommittedRaw.current = raw
|
||||
}
|
||||
}, [raw])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingNewKey && newKeyRef.current) {
|
||||
newKeyRef.current.focus()
|
||||
}
|
||||
}, [editingNewKey])
|
||||
|
||||
const commit = useCallback((updated: FieldEntry[]) => {
|
||||
const newRaw = fieldsToRaw(updated)
|
||||
lastCommittedRaw.current = newRaw
|
||||
onRawChange(newRaw)
|
||||
}, [onRawChange])
|
||||
|
||||
// For scalar fields: update local state immediately, commit on blur
|
||||
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
||||
setFields(prev => {
|
||||
const next = [...prev]
|
||||
next[index] = { ...next[index], value: newValue }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const commitField = useCallback((index: number) => {
|
||||
setFields(prev => {
|
||||
commit(prev)
|
||||
return prev
|
||||
})
|
||||
}, [commit])
|
||||
|
||||
// For array fields and structural changes: update + commit immediately
|
||||
const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => {
|
||||
setFields(prev => {
|
||||
const next = updater(prev)
|
||||
commit(next)
|
||||
return next
|
||||
})
|
||||
}, [commit])
|
||||
|
||||
const removeField = useCallback((index: number) => {
|
||||
updateAndCommit(prev => prev.filter((_, i) => i !== index))
|
||||
}, [updateAndCommit])
|
||||
|
||||
const addField = useCallback((key: string) => {
|
||||
const trimmed = key.trim()
|
||||
if (!trimmed) return
|
||||
if (fields.some(f => f.key === trimmed)) return
|
||||
updateAndCommit(prev => [...prev, { key: trimmed, value: '' }])
|
||||
setEditingNewKey(false)
|
||||
}, [fields, updateAndCommit])
|
||||
|
||||
const count = fields.length
|
||||
|
||||
return (
|
||||
<div className="frontmatter-properties">
|
||||
<button
|
||||
className="frontmatter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`frontmatter-chevron ${expanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
<span className="frontmatter-label">
|
||||
Properties{count > 0 ? ` (${count})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="frontmatter-fields">
|
||||
{fields.map((field, index) => (
|
||||
<div key={`${field.key}-${index}`} className="frontmatter-row">
|
||||
<span className="frontmatter-key" title={field.key}>
|
||||
{field.key}
|
||||
</span>
|
||||
<div className="frontmatter-value-area">
|
||||
{Array.isArray(field.value) ? (
|
||||
<ArrayField
|
||||
value={field.value}
|
||||
editable={editable}
|
||||
onChange={(v) => updateAndCommit(prev => {
|
||||
const next = [...prev]
|
||||
next[index] = { ...next[index], value: v }
|
||||
return next
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="frontmatter-input"
|
||||
value={field.value}
|
||||
readOnly={!editable}
|
||||
onChange={(e) => updateLocalValue(index, e.target.value)}
|
||||
onBlur={() => commitField(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editable && (
|
||||
<button
|
||||
className="frontmatter-remove"
|
||||
onClick={() => removeField(index)}
|
||||
type="button"
|
||||
title="Remove property"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editable && (
|
||||
editingNewKey ? (
|
||||
<div className="frontmatter-row frontmatter-new-row">
|
||||
<input
|
||||
ref={newKeyRef}
|
||||
className="frontmatter-input frontmatter-new-key-input"
|
||||
placeholder="Property name"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addField(e.currentTarget.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingNewKey(false)
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (e.currentTarget.value.trim()) {
|
||||
addField(e.currentTarget.value)
|
||||
} else {
|
||||
setEditingNewKey(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="frontmatter-add"
|
||||
onClick={() => setEditingNewKey(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>Add property</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrayField({
|
||||
value,
|
||||
editable,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[]
|
||||
editable: boolean
|
||||
onChange: (v: string[]) => void
|
||||
}) {
|
||||
const removeItem = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const addItem = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
onChange([...value, trimmed])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="frontmatter-array">
|
||||
{value.map((item, i) => (
|
||||
<span key={i} className="frontmatter-chip">
|
||||
<span className="frontmatter-chip-text">{item}</span>
|
||||
{editable && (
|
||||
<button
|
||||
className="frontmatter-chip-remove"
|
||||
onClick={() => removeItem(i)}
|
||||
type="button"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{editable && (
|
||||
<input
|
||||
className="frontmatter-chip-input"
|
||||
placeholder="Add..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addItem(e.currentTarget.value)
|
||||
e.currentTarget.value = ''
|
||||
} else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) {
|
||||
removeItem(value.length - 1)
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (e.currentTarget.value.trim()) {
|
||||
addItem(e.currentTarget.value)
|
||||
e.currentTarget.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
return result
|
||||
}
|
||||
import { EditorToolbar } from './editor-toolbar'
|
||||
import { TagPills } from './tag-pills'
|
||||
import { FrontmatterProperties } from './frontmatter-properties'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||
|
|
@ -201,7 +201,8 @@ interface MarkdownEditorProps {
|
|||
editorSessionKey?: number
|
||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||
editable?: boolean
|
||||
tags?: string[]
|
||||
frontmatter?: string | null
|
||||
onFrontmatterChange?: (raw: string | null) => void
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -290,7 +291,8 @@ export function MarkdownEditor({
|
|||
editorSessionKey = 0,
|
||||
onHistoryHandlersChange,
|
||||
editable = true,
|
||||
tags,
|
||||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -724,7 +726,13 @@ export function MarkdownEditor({
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
/>
|
||||
{tags && <TagPills tags={tags} />}
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
raw={frontmatter}
|
||||
onRawChange={onFrontmatterChange}
|
||||
editable={editable}
|
||||
/>
|
||||
)}
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{wikiLinks ? (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
Table2,
|
||||
Plug,
|
||||
LoaderIcon,
|
||||
Settings,
|
||||
|
|
@ -101,6 +102,7 @@ type KnowledgeActions = {
|
|||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => void
|
||||
openGraph: () => void
|
||||
openBases: () => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
|
|
@ -855,6 +857,7 @@ function KnowledgeSection({
|
|||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
||||
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
interface TagPillsProps {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export function TagPills({ tags }: TagPillsProps) {
|
||||
if (tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="tag-pills-row">
|
||||
{tags.map((tag, i) => (
|
||||
<span key={`${tag}-${i}`} className="tag-pill">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,6 +29,209 @@ export function joinFrontmatter(raw: string | null, body: string): string {
|
|||
return raw + '\n' + body
|
||||
}
|
||||
|
||||
/** Structured frontmatter fields extracted from categorized YAML. */
|
||||
export type FrontmatterFields = {
|
||||
relationship: string | null
|
||||
relationship_sub: string[]
|
||||
topic: string[]
|
||||
email_type: string[]
|
||||
action: string[]
|
||||
status: string | null
|
||||
source: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract structured tag categories from raw frontmatter YAML.
|
||||
*
|
||||
* Handles both the new categorized format (top-level keys) and the legacy
|
||||
* flat `tags:` list. For legacy notes the flat tags are mapped into
|
||||
* categories using known tag values.
|
||||
*/
|
||||
export function extractFrontmatterFields(raw: string | null): FrontmatterFields {
|
||||
const fields: FrontmatterFields = {
|
||||
relationship: null,
|
||||
relationship_sub: [],
|
||||
topic: [],
|
||||
email_type: [],
|
||||
action: [],
|
||||
status: null,
|
||||
source: [],
|
||||
}
|
||||
if (!raw) return fields
|
||||
|
||||
const lines = raw.split('\n')
|
||||
let currentKey: string | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
// Top-level key detection
|
||||
const topMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (topMatch || line === '---') {
|
||||
currentKey = null
|
||||
}
|
||||
|
||||
if (topMatch) {
|
||||
const key = topMatch[1]
|
||||
const value = topMatch[2].trim()
|
||||
|
||||
if (key in fields) {
|
||||
currentKey = key
|
||||
if (value) {
|
||||
const field = fields[key as keyof FrontmatterFields]
|
||||
if (Array.isArray(field)) {
|
||||
(field as string[]).push(value)
|
||||
} else {
|
||||
// single-value field
|
||||
;(fields as Record<string, unknown>)[key] = value
|
||||
}
|
||||
currentKey = null // inline value, no list follows
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Legacy flat tags: — parse and distribute into categories
|
||||
if (key === 'tags') {
|
||||
currentKey = '__legacy_tags'
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// List items under a categorized key
|
||||
if (currentKey && currentKey !== '__legacy_tags') {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const value = itemMatch[1].trim()
|
||||
const field = fields[currentKey as keyof FrontmatterFields]
|
||||
if (Array.isArray(field)) {
|
||||
(field as string[]).push(value)
|
||||
} else {
|
||||
;(fields as Record<string, unknown>)[currentKey] = value
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Legacy flat tag items → map into categories
|
||||
if (currentKey === '__legacy_tags') {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const tag = itemMatch[1].trim()
|
||||
const cat = LEGACY_TAG_TO_CATEGORY[tag]
|
||||
if (cat) {
|
||||
const field = fields[cat as keyof FrontmatterFields]
|
||||
if (Array.isArray(field)) {
|
||||
(field as string[]).push(tag)
|
||||
} else if (!(fields as Record<string, unknown>)[cat]) {
|
||||
;(fields as Record<string, unknown>)[cat] = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list values are string[].
|
||||
* Skips `---` delimiters and blank lines.
|
||||
*/
|
||||
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {}
|
||||
if (!raw) return result
|
||||
|
||||
const lines = raw.split('\n')
|
||||
let currentKey: string | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---' || line.trim() === '') {
|
||||
currentKey = null
|
||||
continue
|
||||
}
|
||||
|
||||
// Top-level key: value
|
||||
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
|
||||
if (topMatch) {
|
||||
const key = topMatch[1]
|
||||
const value = topMatch[2].trim()
|
||||
if (value) {
|
||||
result[key] = value
|
||||
currentKey = null
|
||||
} else {
|
||||
// List will follow
|
||||
currentKey = key
|
||||
result[key] = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// List item under current key
|
||||
if (currentKey) {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const arr = result[currentKey]
|
||||
if (Array.isArray(arr)) {
|
||||
arr.push(itemMatch[1].trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
|
||||
* Returns null if no non-empty fields remain.
|
||||
*/
|
||||
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||
const lines: string[] = []
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue
|
||||
lines.push(`${key}:`)
|
||||
for (const item of value) {
|
||||
if (item.trim()) lines.push(` - ${item.trim()}`)
|
||||
}
|
||||
} else {
|
||||
const trimmed = (value ?? '').trim()
|
||||
if (!trimmed) continue
|
||||
lines.push(`${key}: ${trimmed}`)
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
return `---\n${lines.join('\n')}\n---`
|
||||
}
|
||||
|
||||
/** Map known tag values → category for legacy flat-list frontmatter. */
|
||||
const LEGACY_TAG_TO_CATEGORY: Record<string, string> = {
|
||||
// relationship
|
||||
investor: 'relationship', customer: 'relationship', prospect: 'relationship',
|
||||
partner: 'relationship', vendor: 'relationship', product: 'relationship',
|
||||
candidate: 'relationship', team: 'relationship', advisor: 'relationship',
|
||||
personal: 'relationship', press: 'relationship', community: 'relationship',
|
||||
government: 'relationship',
|
||||
// relationship_sub
|
||||
primary: 'relationship_sub', secondary: 'relationship_sub',
|
||||
'executive-assistant': 'relationship_sub', cc: 'relationship_sub',
|
||||
'referred-by': 'relationship_sub', former: 'relationship_sub',
|
||||
champion: 'relationship_sub', blocker: 'relationship_sub',
|
||||
// topic
|
||||
sales: 'topic', support: 'topic', legal: 'topic', finance: 'topic',
|
||||
hiring: 'topic', fundraising: 'topic', travel: 'topic', event: 'topic',
|
||||
shopping: 'topic', health: 'topic', learning: 'topic', research: 'topic',
|
||||
// email_type
|
||||
intro: 'email_type', followup: 'email_type',
|
||||
// action
|
||||
'action-required': 'action', urgent: 'action', waiting: 'action',
|
||||
// status
|
||||
active: 'status', archived: 'status', stale: 'status',
|
||||
// source
|
||||
email: 'source', meeting: 'source', browser: 'source',
|
||||
'web-search': 'source', manual: 'source', import: 'source',
|
||||
}
|
||||
|
||||
/** Tag category keys used in the categorized frontmatter format. */
|
||||
const TAG_CATEGORY_KEYS = new Set([
|
||||
'relationship',
|
||||
|
|
|
|||
|
|
@ -237,34 +237,200 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tag pills row shown between toolbar and editor content */
|
||||
.tag-pills-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
/* Frontmatter properties panel between toolbar and editor content */
|
||||
.frontmatter-properties {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--background);
|
||||
flex-shrink: 0;
|
||||
max-height: 4.5em;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 8px;
|
||||
border-radius: 9999px;
|
||||
.frontmatter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 4px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.frontmatter-toggle:hover {
|
||||
color: var(--foreground);
|
||||
background-color: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.frontmatter-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frontmatter-chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.frontmatter-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.frontmatter-fields {
|
||||
padding: 2px 12px 6px 30px;
|
||||
}
|
||||
|
||||
.frontmatter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.frontmatter-key {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.frontmatter-value-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frontmatter-input {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 4px;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.frontmatter-input:focus {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.frontmatter-input:read-only {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.frontmatter-new-key-input {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frontmatter-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.frontmatter-row:hover .frontmatter-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.frontmatter-remove:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.frontmatter-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 4px;
|
||||
margin-top: 2px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
}
|
||||
|
||||
.frontmatter-add:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Array field chips */
|
||||
.frontmatter-array {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.frontmatter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9999px;
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dark .tag-pill {
|
||||
.dark .frontmatter-chip {
|
||||
background-color: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.frontmatter-chip-text {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.frontmatter-chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.frontmatter-chip-remove:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.frontmatter-chip-input {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
width: 60px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.frontmatter-chip-input::placeholder {
|
||||
color: color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
.editor-toolbar .separator {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue