Skip to content

Commit 871d708

Browse files
committed
feat(notes): 正文多色荧光笔 ==[色]文本==——修订单色裁决 + hName 缺陷修复(原落 div 无样式)+ 双编辑器色板入口(REQ-239)
1 parent ae9e89e commit 871d708

9 files changed

Lines changed: 277 additions & 25 deletions

File tree

‎app/scripts/gen-mark-css.mjs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const PALETTE = {
2+
red: { light: "#E5484D", dark: "#FF6369" },
3+
orange: { light: "#F76B15", dark: "#FF8B3D" },
4+
yellow: { light: "#F5D90A", dark: "#FFE45C" },
5+
green: { light: "#30A46C", dark: "#3DD68C" },
6+
teal: { light: "#12A594", dark: "#29E0CB" },
7+
blue: { light: "#0091FF", dark: "#5EB1FF" },
8+
purple: { light: "#8E4EC6", dark: "#C59BFF" },
9+
pink: { light: "#D6409F", dark: "#FF8AD8" },
10+
brown: { light: "#8D6E63", dark: "#B89B8A" },
11+
gray: { light: "#8E8E93", dark: "#9E9EA3" },
12+
black: { light: "#1C1C1E", dark: "#F2F2F7" },
13+
white: { light: "#FFFFFF", dark: "#1C1C1E" },
14+
};
15+
function lum(hex) {
16+
const raw = hex.replace("#", "");
17+
const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw;
18+
const [r, g, b] = [0, 2, 4].map((i) => {
19+
const c = parseInt(full.slice(i, i + 2), 16) / 255;
20+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
21+
});
22+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
23+
}
24+
function ratio(a, b) {
25+
const la = lum(a), lb = lum(b);
26+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
27+
return (hi + 0.05) / (lo + 0.05);
28+
}
29+
function onColor(hex) {
30+
return ratio(hex, "#000000") >= ratio(hex, "#FFFFFF") ? "#1C1C1E" : "#FFFFFF";
31+
}
32+
for (const theme of ["light", "dark"]) {
33+
console.log(`/* ${theme} */`);
34+
for (const [id, c] of Object.entries(PALETTE)) {
35+
console.log(`.note-mark-${id} { background: ${c[theme]}; color: ${onColor(c[theme])}; }`);
36+
}
37+
}

‎app/src/components/NoteEditView.tsx‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
insertAtCursor, wrapSelection, headingLines, promoteHeading, demoteHeading, mergeSelection, splitAtCursor,
1717
type EditResult,
1818
} from "./markdownEdit";
19+
// v0.16.1:正文多色荧光笔——色板复用(选中色 → 包裹 ==[色]…==)
20+
import NoteColorPicker from "./NoteColorPicker";
1921

2022
interface Props {
2123
note: Note;
@@ -45,6 +47,8 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
4547
const [dirty, setDirty] = useState(false);
4648
const [saving, setSaving] = useState(false);
4749
const [status, setStatus] = useState("");
50+
// v0.16.1:荧光笔色板弹层开合(与 RichEditorView 同模式)
51+
const [highlightOpen, setHighlightOpen] = useState(false);
4852
const taRef = useRef<HTMLTextAreaElement>(null);
4953
// L14:双计时器 ref(idle debounce + maxWait,常量见文件头注释)
5054
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -203,7 +207,7 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
203207

204208
// 工具栏操作(v0.10.1:local-image 需异步导入——dialog 选文件后复制进
205209
// data_dir 再插入相对引用;import 失败明确提示不落脏数据)
206-
const toolbarAction = async (action: string) => {
210+
const toolbarAction = async (action: string, color?: string | null) => {
207211
const ta = taRef.current;
208212
if (!ta) return;
209213
// 读取当前 DOM 值与选区(受控组件下与 content state 一致)
@@ -240,6 +244,11 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
240244
break;
241245
}
242246
case "latex": applyEdit(wrapSelection(v, s, e, "$", "$")); break;
247+
// v0.16.1:正文多色荧光笔——默认黄 / 指定色(==文本== / ==[色]文本==)
248+
case "highlight-default": applyEdit(wrapSelection(v, s, e, "==", "==")); break;
249+
case "highlight-color":
250+
if (color) applyEdit(wrapSelection(v, s, e, `==[${color}]`, "=="));
251+
break;
243252
case "image": {
244253
// v0.15:外链图自动下载复制(防源站删除丢资源)——失败降级插原 URL + 提示
245254
const alt = prompt("图片描述:") || "";
@@ -322,6 +331,34 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
322331
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("local-image")} title="插入本地图片(复制进应用数据目录)">🖼 图片</button>
323332
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("image")} title="插入外链图(自动下载为本地副本)">🌐 链接图</button>
324333
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("latex")} title="LaTeX">Σ</button>
334+
{/* v0.16.1:正文多色荧光笔——默认黄 / 12 色板(降级 textarea 与 CM 版同入口) */}
335+
<div style={{ position: "relative" }}>
336+
<button style={TOOLBAR_BTN} data-testid="highlight-open" onClick={() => setHighlightOpen((v) => !v)} title="荧光笔:==文本==(默认黄);==[色]文本==">🖍 荧光</button>
337+
{highlightOpen && (
338+
<>
339+
<div onClick={() => setHighlightOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 30, background: "transparent" }} />
340+
<div
341+
data-testid="highlight-pop"
342+
style={{ position: "absolute", top: "100%", left: 0, zIndex: 31, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 6, padding: 8, boxShadow: "0 4px 12px rgba(0,0,0,0.12)", maxWidth: 200 }}
343+
>
344+
<button
345+
data-testid="highlight-default"
346+
style={{ ...TOOLBAR_BTN, width: "100%", marginBottom: 6, background: "#fefce8" }}
347+
onClick={() => { void toolbarAction("highlight-default"); setHighlightOpen(false); }}
348+
>
349+
🖍 默认黄 ==文本==
350+
</button>
351+
<NoteColorPicker
352+
value={null}
353+
onChange={(c) => {
354+
if (c) void toolbarAction("highlight-color", c);
355+
setHighlightOpen(false);
356+
}}
357+
/>
358+
</div>
359+
</>
360+
)}
361+
</div>
325362
<span style={{ flex: 1 }} />
326363
{/* A1段落操作提示 */}
327364
<span style={{ fontSize: 10, color: "#9ca3af" }}>Ctrl+Shift+↑↓层级 · M合并 · S拆分</span>

‎app/src/components/NoteMarkdown.test.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// @vitest-environment jsdom
22
/**
3-
* NoteMarkdown.test.tsx — 换行语义锁定(v0.15 remark-breaks)。
3+
* NoteMarkdown.test.tsx — 换行语义锁定(v0.15 remark-breaks)+ 荧光笔渲染链
4+
* (v0.16.1 多色 + hName 修复)。
45
*
56
* @ai-context: 修复"编辑态换行、阅读态连上"——单换行(软换行)必须渲染为 <br>;
67
* 段落分隔(\n\n)仍是两段不产生 <br>;`\` 强断行语义保留。
78
* 锁契约防未来 remark 插件顺序回归。
9+
* @ai-context: v0.16.1——==[red]重点== 渲染 <mark class="note-mark note-mark-red">;
10+
* ==默认== 渲染 <mark class="note-mark">(插件 hName 修复:原落 div,
11+
* components.mark 按 hast tagName 匹配实际从未命中=无样式)。
812
*/
913
import { afterEach, describe, expect, it, vi } from "vitest";
1014
import { cleanup, render } from "@testing-library/react";
@@ -68,4 +72,20 @@ describe("NoteMarkdown 换行语义(v0.15 remark-breaks)", () => {
6872
expect((li as HTMLElement).textContent).toContain("续行内容");
6973
expect((li as HTMLElement).querySelector("br")).toBeTruthy();
7074
});
75+
76+
it("==[red]重点== 渲染 <mark class=note-mark note-mark-red>(v0.16.1 多色)", () => {
77+
const { container } = renderMd("==[red]重点==");
78+
const m = container.querySelector("mark.note-mark.note-mark-red");
79+
expect(m).toBeTruthy();
80+
expect(m?.textContent).toBe("重点");
81+
});
82+
83+
it("==默认== 渲染 <mark class=note-mark>(v0.16.1 hName 修复——原落 div 无样式)", () => {
84+
const { container } = renderMd("==默认==");
85+
const m = container.querySelector("mark.note-mark");
86+
expect(m).toBeTruthy();
87+
expect(m?.textContent).toBe("默认");
88+
// 除 mark 外无其他元素(不落 div 容器)
89+
expect(container.querySelector("div")).toBeNull();
90+
});
7191
});

‎app/src/components/NoteMarkdown.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,12 @@ export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSe
159159
remarkPlugins={[remarkGfm, remarkMath, remarkMarkHighlight, remarkBreaks]}
160160
rehypePlugins={[rehypeKatex]}
161161
components={{
162-
// v0.14 B:==文本== 荧光笔(remark 插件产出 mark 节点;单色黄,浅/深主题通用)
163-
mark: ({ node, children, ...props }) => (
164-
<mark style={{ background: "#fde68a", borderRadius: 2, padding: "0 1px" }} {...props}>{hl(children)}</mark>
162+
// v0.14 B:==文本== 荧光笔(remark 插件产出 mdast mark 节点 + hName/hProperties)
163+
// v0.16.1:多色——className 由插件注入(note-mark[-{colorId}],样式见 note-mark.css);
164+
// 原实现硬编码单色黄(且无 hName 经 defaultUnknownHandler 落 div——
165+
// 组件按 hast tagName 匹配实际从未命中,现由插件 hName 修复)
166+
mark: ({ node, className, children, ...props }) => (
167+
<mark className={className} {...props}>{hl(children)}</mark>
165168
),
166169
// 任务清单勾选(H1:渲染时记录序号 → 仅替换对应源行)
167170
input: ({ node, ...props }) => {

‎app/src/components/RichEditorView.tsx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { insertTextCommand, wrapSelectionCommand } from "../commands/toolbarComm
2424
import { imageDecorationPlugin } from "./imageDecorationPlugin";
2525
import { clearDraft, readDraft } from "../utils/draftStore";
2626
import NoteEditView, { type NoteEditHandle } from "./NoteEditView";
27+
// v0.16.1:正文多色荧光笔——色板复用(选中色 → 包裹 ==[色]…==)
28+
import NoteColorPicker from "./NoteColorPicker";
2729

2830
interface Props {
2931
note: Note;
@@ -55,6 +57,8 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
5557
const [fallback, setFallback] = useState(false);
5658
const [activeHeading, setActiveHeading] = useState(0);
5759
const [draftPrompt, setDraftPrompt] = useState<{ title: string; content: string } | null>(null);
60+
// v0.16.1:荧光笔色板弹层开合(受控——选色/默认黄/点击外部关闭)
61+
const [highlightOpen, setHighlightOpen] = useState(false);
5862

5963
// refs 快照(卸载/定时器闭包取最新值,防 state 闭包过期——同 NoteEditView)
6064
const titleRef = useRef(title);
@@ -247,6 +251,34 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
247251
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("local-image")} title="插入本地图片(复制进应用数据目录)">🖼 图片</button>
248252
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("image")} title="插入外链图(自动下载为本地副本)">🌐 链接图</button>
249253
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("latex")} title="LaTeX">Σ</button>
254+
{/* v0.16.1:正文多色荧光笔——默认黄 / 12 色板(包裹选区 ==文本== / ==[色]文本==) */}
255+
<div style={{ position: "relative" }}>
256+
<button style={TOOLBAR_BTN} data-testid="highlight-open" onClick={() => setHighlightOpen((v) => !v)} title="荧光笔:==文本==(默认黄);==[色]文本==">🖍 荧光</button>
257+
{highlightOpen && (
258+
<>
259+
<div onClick={() => setHighlightOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 30, background: "transparent" }} />
260+
<div
261+
data-testid="highlight-pop"
262+
style={{ position: "absolute", top: "100%", left: 0, zIndex: 31, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 6, padding: 8, boxShadow: "0 4px 12px rgba(0,0,0,0.12)", maxWidth: 200 }}
263+
>
264+
<button
265+
data-testid="highlight-default"
266+
style={{ ...TOOLBAR_BTN, width: "100%", marginBottom: 6, background: "#fefce8" }}
267+
onClick={() => { wrapSelectionCommand("==", "==")(viewRef.current!); setHighlightOpen(false); }}
268+
>
269+
🖍 默认黄 ==文本==
270+
</button>
271+
<NoteColorPicker
272+
value={null}
273+
onChange={(c) => {
274+
if (c) wrapSelectionCommand(`==[${c}]`, "==")(viewRef.current!);
275+
setHighlightOpen(false);
276+
}}
277+
/>
278+
</div>
279+
</>
280+
)}
281+
</div>
250282
<span style={{ flex: 1 }} />
251283
<span style={{ fontSize: 10, color: "#9ca3af" }}>Ctrl+Shift+↑↓层级 · Ctrl+Z 撤销</span>
252284
<span style={{ width: 1, height: 20, background: "#d1d5db" }} />

‎app/src/main.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React from "react";
22
import ReactDOM from "react-dom/client";
33
import App from "./App";
4+
// v0.16.1:正文多色荧光笔样式(remarkMarkHighlight 注入类名 note-mark[-{colorId}])
5+
import "./note-mark.css";
46

57
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
68
<React.StrictMode>

‎app/src/note-mark.css‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* note-mark.css — 正文多色荧光笔样式(v0.16.1 用户决定①)。
3+
*
4+
* @ai-context: 类名由 remarkMarkHighlight 插件经 mdast data.hProperties 注入
5+
* (note-mark [+ note-mark-{colorId}]);双主题变体与
6+
* utils/colorPalette 12 色板对齐,前景色按 WCAG AA(≥4.5:1)
7+
* 取黑/白(生成脚本 scripts/gen-mark-css.mjs——改色板先重跑脚本
8+
* 再提交,保证定义期一致;纯函数层 isThemeSafe 契约不变)。
9+
* 默认黄(无前缀 ==文本==)走 .note-mark 基础规则,与旧版一致。
10+
*/
11+
.note-mark {
12+
border-radius: 2px;
13+
padding: 0 1px;
14+
background: #fde68a;
15+
color: #1c1c1e;
16+
}
17+
18+
/* light */
19+
.note-mark-red { background: #E5484D; color: #1C1C1E; }
20+
.note-mark-orange { background: #F76B15; color: #1C1C1E; }
21+
.note-mark-yellow { background: #F5D90A; color: #1C1C1E; }
22+
.note-mark-green { background: #30A46C; color: #1C1C1E; }
23+
.note-mark-teal { background: #12A594; color: #1C1C1E; }
24+
.note-mark-blue { background: #0091FF; color: #1C1C1E; }
25+
.note-mark-purple { background: #8E4EC6; color: #FFFFFF; }
26+
.note-mark-pink { background: #D6409F; color: #1C1C1E; }
27+
.note-mark-brown { background: #8D6E63; color: #FFFFFF; }
28+
.note-mark-gray { background: #8E8E93; color: #1C1C1E; }
29+
.note-mark-black { background: #1C1C1E; color: #FFFFFF; }
30+
.note-mark-white { background: #FFFFFF; color: #1C1C1E; }
31+
32+
@media (prefers-color-scheme: dark) {
33+
.note-mark {
34+
background: #ffe45c;
35+
color: #1c1c1e;
36+
}
37+
.note-mark-red { background: #FF6369; color: #1C1C1E; }
38+
.note-mark-orange { background: #FF8B3D; color: #1C1C1E; }
39+
.note-mark-yellow { background: #FFE45C; color: #1C1C1E; }
40+
.note-mark-green { background: #3DD68C; color: #1C1C1E; }
41+
.note-mark-teal { background: #29E0CB; color: #1C1C1E; }
42+
.note-mark-blue { background: #5EB1FF; color: #1C1C1E; }
43+
.note-mark-purple { background: #C59BFF; color: #1C1C1E; }
44+
.note-mark-pink { background: #FF8AD8; color: #1C1C1E; }
45+
.note-mark-brown { background: #B89B8A; color: #1C1C1E; }
46+
.note-mark-gray { background: #9E9EA3; color: #1C1C1E; }
47+
.note-mark-black { background: #F2F2F7; color: #1C1C1E; }
48+
.note-mark-white { background: #1C1C1E; color: #FFFFFF; }
49+
}

‎app/src/utils/remarkMarkHighlight.test.ts‎

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
2-
* ==文本== 荧光笔 remark 插件测试(v0.14 B 视觉系统)。
2+
* ==文本== / ==[色]文本== 荧光笔 remark 插件测试(v0.14 B + v0.16.1 多色扩展)。
33
*
4-
* @ai-context: spec §6——==文本== 渲染为 mark;未闭合/跨行/代码块内不误伤;
5-
* 多个高亮同段落、行内混合文本。插件是纯 mdast 树变换——测试
6-
* 直接构造树验证(不依赖 remark 运行时包)。
4+
* @ai-context: spec §6——==文本== 渲染为 mark(默认黄);==[色id]文本== 彩色;
5+
* 未知色/未闭合/跨行/代码块内不误伤;多个高亮同段落、行内混合文本。
6+
* 插件是纯 mdast 树变换——测试直接构造树验证(不依赖 remark 运行时包)。
77
*/
88
import { describe, expect, it } from "vitest";
99
import { remarkMarkHighlight } from "./remarkMarkHighlight";
@@ -13,6 +13,8 @@ interface MdNode {
1313
type: string;
1414
value?: string;
1515
children?: MdNode[];
16+
data?: { hName: string; hProperties: { className: string[] } };
17+
colorId?: string;
1618
}
1719

1820
/** 构造段落节点(快捷) */
@@ -24,6 +26,25 @@ function text(value: string): MdNode {
2426
return { type: "text", value };
2527
}
2628

29+
/** 默认(无色)mark 节点期望形状(hName/hProperties 供 to-hast 落到 <mark>) */
30+
function mark(value: string): MdNode {
31+
return {
32+
type: "mark",
33+
children: [text(value)],
34+
data: { hName: "mark", hProperties: { className: ["note-mark"] } },
35+
};
36+
}
37+
38+
/** 彩色 mark 节点期望形状 */
39+
function markColored(colorId: string, value: string): MdNode {
40+
return {
41+
type: "mark",
42+
colorId,
43+
children: [text(value)],
44+
data: { hName: "mark", hProperties: { className: ["note-mark", `note-mark-${colorId}`] } },
45+
};
46+
}
47+
2748
/** 应用插件变换,返回 root 的 children */
2849
function apply(...children: MdNode[]): MdNode[] {
2950
const root: MdNode = { type: "root", children };
@@ -44,11 +65,25 @@ function marks(nodes: MdNode[]): string[] {
4465
}
4566

4667
describe("remarkMarkHighlight", () => {
47-
it("闭合 ==文本== → mark 节点", () => {
68+
it("闭合 ==文本== → mark 节点(默认黄:hName=mark + className note-mark)", () => {
4869
const out = apply(para(text("重点是==这个==")));
49-
expect(out).toEqual([
50-
para(text("重点是"), { type: "mark", children: [text("这个")] }),
51-
]);
70+
expect(out).toEqual([para(text("重点是"), mark("这个"))]);
71+
});
72+
73+
it("==[red]文本== → 彩色 mark(className 带 note-mark-red)", () => {
74+
const out = apply(para(text("==[red]红色重点==")));
75+
expect(out).toEqual([para(markColored("red", "红色重点"))]);
76+
});
77+
78+
it("同段落混合:默认黄 + 彩色 + 普通文本", () => {
79+
const out = apply(para(text("==黄== 与 ==[blue]蓝== 都要")));
80+
expect(out).toEqual([para(mark("黄"), text(" 与 "), markColored("blue", "蓝"), text(" 都要"))]);
81+
});
82+
83+
it("未知色 id(==[neon]==)整体原样保留(不拆不猜)", () => {
84+
const out = apply(para(text("==[neon]未知色==")));
85+
expect(out).toEqual([para(text("==[neon]未知色=="))]);
86+
expect(marks(out)).toEqual([]);
5287
});
5388

5489
it("同一段落多个高亮", () => {

0 commit comments

Comments
 (0)