|
| 1 | +/** |
| 2 | + * NoteTagsEditor — 笔记标签编辑浮层(批 7 T18;C2.1 / C2.2 的落点)。 |
| 3 | + * |
| 4 | + * @ai-context: 为什么单独成件而不是塞进 `NoteHeaderActions`:后者的冻结值有两格已满 |
| 5 | + * (`nativeButton` 2/2、边框 `1px solid #e5e7eb` 1/1,见 `surfaceBaseline.ts`) |
| 6 | + * ⇒ 新入口必须用 `Button` 原语、浮层必须用 `<Surface>`(§C9.12 硬结论)。 |
| 7 | + * @ai-context: 写端三条命令的**唯一生产调用点**(§C2.2):`update_note_tags`(加/删标签) |
| 8 | + * · `set_tag_color`(设色)· `reset_tag_color`(清色)—— **设色与清色成对** |
| 9 | + * (规格 §1 L5 行 35「标签色成对」):`NoteColorPicker` 既有的 `color-clear` |
| 10 | + * 出口直接落到 `reset_tag_color`,本件不做第二条清除路径。 |
| 11 | + * @ai-context: 浮层用 `absolute` 锚定(先例 `NoteHeaderActions.tsx:62-65`)—— **零 |
| 12 | + * `position:"fixed"`**:那会把本件拖进 `dialogMigration.e.test.ts` 的 |
| 13 | + * `CROSS_LINE_34`,而该判据件恰 300 行、余 0、改不动(§C2.1)。 |
| 14 | + * @ai-context: 新增标签**不自动设色**(回填的确定性取色规则住在 Rust 数据层)——颜色由用户 |
| 15 | + * 在芯片上点选(成对设/清),或由下次开库的幂等回填补齐。 |
| 16 | + * 副作用:`invoke` 三条写端命令;打开浮层时 `invoke` 一次 `list_tag_colors`(查色表, |
| 17 | + * 失败仅 console.warn 不阻断编辑)。错误经 `onError` 上抛(不吞异常)。 |
| 18 | + * 边界:标签顺序 = 库内 JSON 数组顺序(只追加/删除,不重排);不触碰 `properties`。 |
| 19 | + */ |
| 20 | +import { useEffect, useMemo, useState } from "react"; |
| 21 | +import { invoke } from "@tauri-apps/api/core"; |
| 22 | +import type { Note, TagColor } from "../types"; |
| 23 | +import { paletteHex, parseNoteTags } from "../utils/colorPalette"; |
| 24 | +import type { ThemeMode } from "../utils/colorPalette"; |
| 25 | +import { zIndex } from "../ui/zIndex"; |
| 26 | +import { Button, Surface, isImeComposing } from "../ui/primitives"; |
| 27 | +import NoteColorPicker from "./NoteColorPicker"; |
| 28 | + |
| 29 | +interface Props { |
| 30 | + note: Note; |
| 31 | + /** 写库成功后父层刷新(列表重载 + 右栏选中对象回读) */ |
| 32 | + onChanged: () => void; |
| 33 | + /** 错误上抛(父层 status 区展示) */ |
| 34 | + onError: (msg: string) => void; |
| 35 | +} |
| 36 | + |
| 37 | +/** 芯片基底(视觉由档位/token 决定:底色是查色表的 `paletteHex + 22` 透明度后缀,同 NoteListRow) */ |
| 38 | +const chipStyle: React.CSSProperties = { fontSize: 12, borderRadius: 10, padding: "1px 6px", color: "#374151" }; |
| 39 | + |
| 40 | +export default function NoteTagsEditor({ note, onChanged, onError }: Props) { |
| 41 | + const [open, setOpen] = useState(false); |
| 42 | + const [draft, setDraft] = useState(""); |
| 43 | + /** 正在展开色板的标签(null=都不展开) */ |
| 44 | + const [editing, setEditing] = useState<string | null>(null); |
| 45 | + const [colors, setColors] = useState<Record<string, string>>({}); |
| 46 | + const tags = parseNoteTags(note); |
| 47 | + // v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light) |
| 48 | + const theme: ThemeMode = useMemo( |
| 49 | + () => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"), |
| 50 | + [], |
| 51 | + ); |
| 52 | + |
| 53 | + // 打开时读一次标签色(与 `useNotesListData` 同一条命令;失败不阻断编辑) |
| 54 | + useEffect(() => { |
| 55 | + if (!open) return; |
| 56 | + void invoke<TagColor[]>("list_tag_colors") |
| 57 | + .then((rows) => setColors(Object.fromEntries((rows ?? []).map((t) => [t.tag, t.color])))) |
| 58 | + .catch((e) => console.warn("[NoteTagsEditor] 标签色加载失败", e)); |
| 59 | + }, [open, note.id]); |
| 60 | + |
| 61 | + /** 写标签集合(加/删共用一条命令;成功后清输入框并刷新父层) */ |
| 62 | + const writeTags = (next: string[]): void => { |
| 63 | + void invoke<boolean>("update_note_tags", { id: note.id, tags: JSON.stringify(next) }) |
| 64 | + .then(() => { |
| 65 | + setDraft(""); |
| 66 | + onChanged(); |
| 67 | + }) |
| 68 | + .catch((e) => onError(`标签保存失败: ${e}`)); |
| 69 | + }; |
| 70 | + |
| 71 | + const addTag = (): void => { |
| 72 | + const t = draft.trim(); |
| 73 | + if (!t || tags.includes(t)) { |
| 74 | + setDraft(""); |
| 75 | + return; |
| 76 | + } |
| 77 | + setEditing(t); |
| 78 | + writeTags([...tags, t]); |
| 79 | + }; |
| 80 | + |
| 81 | + /** 设色 / 清色成对(§C2.2):null ⇒ `reset_tag_color`,否则 `set_tag_color` */ |
| 82 | + const applyColor = (tag: string, color: string | null): void => { |
| 83 | + const done = (): void => { |
| 84 | + setColors((m) => { |
| 85 | + const next = { ...m }; |
| 86 | + if (color === null) delete next[tag]; |
| 87 | + else next[tag] = color; |
| 88 | + return next; |
| 89 | + }); |
| 90 | + onChanged(); |
| 91 | + }; |
| 92 | + const fail = (e: unknown): void => onError(`标签颜色保存失败: ${e}`); |
| 93 | + if (color === null) void invoke("reset_tag_color", { tag }).then(done).catch(fail); |
| 94 | + else void invoke("set_tag_color", { tag, color }).then(done).catch(fail); |
| 95 | + }; |
| 96 | + |
| 97 | + return ( |
| 98 | + <div style={{ position: "relative" }}> |
| 99 | + <Button |
| 100 | + size="sm" |
| 101 | + variant="ghost" |
| 102 | + testId="note-tags-entry" |
| 103 | + title={open ? "收起标签编辑" : "编辑标签(写入库)"} |
| 104 | + onClick={() => setOpen((v) => !v)} |
| 105 | + > |
| 106 | + 🏷 标签 |
| 107 | + </Button> |
| 108 | + {open && ( |
| 109 | + <Surface |
| 110 | + level="raised" |
| 111 | + radius="overlay" |
| 112 | + padded |
| 113 | + testId="note-tags-panel" |
| 114 | + style={{ position: "absolute", top: "100%", right: 0, zIndex: zIndex("popover"), width: 240, display: "flex", flexDirection: "column", gap: 6 }} |
| 115 | + > |
| 116 | + {/* 当前标签:点芯片展开色板,✕ 删除(删除也走 `update_note_tags`) */} |
| 117 | + <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}> |
| 118 | + {/* 文案避开空态词表(`emptyStateRatchet` 的 8 个词命中即要求登记,见其 EMPTY_RE) */} |
| 119 | + {tags.length === 0 && <span style={{ color: "#6b7280" }}>标签为空</span>} |
| 120 | + {tags.map((t) => ( |
| 121 | + <span |
| 122 | + key={t} |
| 123 | + data-testid={`note-tag-chip-${t}`} |
| 124 | + onClick={() => setEditing((cur) => (cur === t ? null : t))} |
| 125 | + style={{ ...chipStyle, cursor: "pointer", background: colors[t] ? `${paletteHex(colors[t], theme)}22` : "#f3f4f6" }} |
| 126 | + > |
| 127 | + {t} |
| 128 | + <span |
| 129 | + data-testid={`note-tag-remove-${t}`} |
| 130 | + title="删除标签" |
| 131 | + onClick={(e) => { |
| 132 | + e.stopPropagation(); |
| 133 | + writeTags(tags.filter((x) => x !== t)); |
| 134 | + }} |
| 135 | + > |
| 136 | + {" ✕"} |
| 137 | + </span> |
| 138 | + </span> |
| 139 | + ))} |
| 140 | + </div> |
| 141 | + {/* 输入 + 添加(Enter 提交带 IME 组合守卫,规格 §5.2 第三条) */} |
| 142 | + <div style={{ display: "flex", gap: 6 }}> |
| 143 | + <input |
| 144 | + data-testid="note-tag-input" |
| 145 | + value={draft} |
| 146 | + placeholder="输入标签后回车" |
| 147 | + onChange={(e) => setDraft(e.target.value)} |
| 148 | + onKeyDown={(e) => { |
| 149 | + if (e.key === "Enter" && !isImeComposing(e)) addTag(); |
| 150 | + }} |
| 151 | + style={{ flex: 1, minWidth: 0, fontSize: 12, padding: "4px 6px" }} |
| 152 | + /> |
| 153 | + <Button size="sm" testId="note-tag-add" onClick={addTag}> |
| 154 | + 添加 |
| 155 | + </Button> |
| 156 | + </div> |
| 157 | + {/* 点芯片 ⇒ 设色 / 清色(成对;清除走 picker 既有的 color-clear) */} |
| 158 | + {editing !== null && tags.includes(editing) && ( |
| 159 | + <NoteColorPicker value={colors[editing] ?? null} onChange={(color) => applyColor(editing, color)} size={16} /> |
| 160 | + )} |
| 161 | + </Surface> |
| 162 | + )} |
| 163 | + </div> |
| 164 | + ); |
| 165 | +} |
0 commit comments