Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix. - Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension + FindReplace bar (match-case, replace-all back-to-front, scroll without popping the selection bubble). - Read-aloud (Web Speech, offline) on the word card and selection bubble. - Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret, Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the right-click handler into a shared openWordLookup(pos). - Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx), de-dupes filenames, dated name; sidebar download links. TestExportAll. - Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so CJK is untouched. - Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover. - English phonetic (chosen over pinyin for an English learner): ECDICT-built phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line; scripts/build_phonetic.py (full build + --seed fallback). - Selection bubble no longer blocks copy/paste: deferred to pointer-up and made click-through except on its buttons. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
117 lines
3.6 KiB
TypeScript
117 lines
3.6 KiB
TypeScript
import { useState } from 'react'
|
||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||
import { TagChip } from './TagChip'
|
||
import { TagPicker } from './TagPicker'
|
||
|
||
interface Props {
|
||
doc: DocSummary
|
||
active: boolean
|
||
roster: Tag[]
|
||
onSelect: () => void
|
||
onDelete: () => void
|
||
onDuplicate: () => void
|
||
onToggleTag: (docId: string, tag: Tag) => void
|
||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||
}
|
||
|
||
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||
// a delete affordance on hover, and a rose wash when it's the open document.
|
||
export function DocListItem({
|
||
doc,
|
||
active,
|
||
roster,
|
||
onSelect,
|
||
onDelete,
|
||
onDuplicate,
|
||
onToggleTag,
|
||
onCreateTag,
|
||
}: Props) {
|
||
const [picking, setPicking] = useState(false)
|
||
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||
|
||
return (
|
||
<div
|
||
onClick={onSelect}
|
||
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||
style={{
|
||
borderRadius: 'var(--radius-card)',
|
||
background: active ? 'var(--color-surface)' : 'transparent',
|
||
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div
|
||
className="truncate text-sm font-semibold"
|
||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||
>
|
||
{doc.title || 'Untitled'}
|
||
</div>
|
||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||
on hover for the pointer experience. */}
|
||
<button
|
||
type="button"
|
||
aria-label="Tag document"
|
||
title="Tags"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setPicking((v) => !v)
|
||
}}
|
||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||
>
|
||
🏷️
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Duplicate document"
|
||
title="副本 · Duplicate"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDuplicate()
|
||
}}
|
||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||
>
|
||
⧉
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Delete document"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDelete()
|
||
}}
|
||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||
>
|
||
×
|
||
</button>
|
||
|
||
{picking && (
|
||
<TagPicker
|
||
roster={roster}
|
||
assignedIds={assignedIds}
|
||
onToggle={(t) => onToggleTag(doc.id, t)}
|
||
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||
onClose={() => setPicking(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{doc.tags.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{doc.tags.map((t) => (
|
||
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|