Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d17f48f
feat(typography): add guarded CJK typography transforms
cropflre Oct 10, 2026
a97d591
feat(typography): add atomic Markdown and rich-text editor commands
cropflre Oct 10, 2026
d31d1a5
feat(ui): expose manual Chinese typography actions on desktop and mobile
cropflre Oct 10, 2026
9cff2f0
fix(typography): use shared translations and typed ProseMirror marks
cropflre Oct 10, 2026
0b8123b
test(typography): exercise protected spans, contextual punctuation an…
cropflre Oct 10, 2026
4160268
fix(typography): protect Unicode email local parts and isolate undo h…
cropflre Oct 10, 2026
281f1fa
test(typography): run focused CI and use static translation keys
cropflre Oct 10, 2026
228bb87
test(typography): avoid Tiptap auto trailing paragraph in undo fixture
cropflre Oct 10, 2026
408f6a5
fix(typography): protect Nowen links/frontmatter and speed up large-n…
cropflre Oct 10, 2026
39ff8dc
test(typography): protect math in rich text and nested straight quotes
cropflre Oct 10, 2026
481331b
fix(cjk): clear five new eslint violations without changing matching …
cropflre Oct 10, 2026
c13139b
test(attachments): assert unsigned URLs stay blocked until signed map…
cropflre Oct 10, 2026
be3d4cb
test(markdown): await signed image preview and note scope updates
cropflre Oct 10, 2026
fcd7f2d
merge(release/v1.5.2): sync PR #823 with fixed i18n baseline
cropflre Oct 10, 2026
57f8d14
ci(cjk): enforce lint for new typography implementation modules
cropflre Oct 10, 2026
f8365bc
merge(release/v1.5.2): bring audited shared lint baseline into PR #823
cropflre Oct 10, 2026
36612c9
Merge branch 'release/v1.5.2' into feat/issue-822-cjk-typography
cropflre Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/cjk-typography-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CJK Typography CI

on:
pull_request:
paths:
- "frontend/src/lib/cjkTypography.ts"
- "frontend/src/lib/tiptapCjkTypography.ts"
- "frontend/src/lib/markdownCommands.ts"
- "frontend/src/lib/__tests__/cjkTypography.test.ts"
- "frontend/src/components/CjkTypographyMenuActions.tsx"
- "frontend/src/components/MarkdownEditorImpl.tsx"
- "frontend/src/components/TiptapEditor.tsx"
- "frontend/src/components/TiptapEditorRuntime.tsx"
- "frontend/src/components/EditorPane.tsx"
- "frontend/src/components/editors/types.ts"
- "frontend/src/i18n/coverageTranslations.ts"
- ".github/workflows/cjk-typography-ci.yml"
push:
branches: ["release/v1.5.2"]
paths:
- "frontend/src/lib/cjkTypography.ts"
- "frontend/src/lib/tiptapCjkTypography.ts"
- "frontend/src/lib/__tests__/cjkTypography.test.ts"

jobs:
cjk-typography:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Guarded transform, selection, and undo regressions
run: npm run test:run -- src/lib/__tests__/cjkTypography.test.ts
- name: Lint CJK typography modules
run: npx eslint src/lib/cjkTypography.ts src/lib/tiptapCjkTypography.ts src/components/CjkTypographyMenuActions.tsx
- name: Frontend TypeScript check
run: npx tsc -b
40 changes: 40 additions & 0 deletions frontend/src/components/CjkTypographyMenuActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Languages, Quote, TextCursorInput } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { CjkTypographyAction } from "@/lib/cjkTypography";

const actions: Array<{ action: CjkTypographyAction; icon: typeof Languages }> = [
{ action: "spacing", icon: TextCursorInput },
{ action: "punctuation", icon: Languages },
{ action: "cornerQuotes", icon: Quote },
];

/** Explicit one-click tools; no automatic input transformation or settings toggle. */
export default function CjkTypographyMenuActions({
onAction, disabled = false, mobile = false,
}: {
onAction: (action: CjkTypographyAction) => void;
disabled?: boolean;
mobile?: boolean;
}) {
const { t } = useTranslation();
return (
<div data-cjk-typography-actions className="py-1">
<div className="px-3 py-1 text-[11px] font-medium text-tx-tertiary">
{t("cjkTypography.title")}
</div>
{actions.map(({ action, icon: Icon }) => (
<button key={action} type="button"
disabled={disabled}
onClick={() => onAction(action)}
className={"w-full flex items-center gap-2.5 px-3 py-2 text-left text-sm text-tx-secondary " +
(mobile ? "active:bg-app-hover " : "hover:bg-app-hover ") +
"transition-colors disabled:opacity-40"}>
<Icon size={15} className="shrink-0 text-tx-tertiary" />
<span>{action === "spacing" ? t("cjkTypography.spacing")
: action === "punctuation" ? t("cjkTypography.punctuation")
: t("cjkTypography.cornerQuotes")}</span>
</button>
))}
</div>
);
}
21 changes: 21 additions & 0 deletions frontend/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ import {
type NoteFormatConversionRequest,
} from "@/lib/noteFormatConversion";
import NoteThemeMenuSelect from "@/components/NoteThemeMenuSelect";
import CjkTypographyMenuActions from "@/components/CjkTypographyMenuActions";
import type { CjkTypographyAction } from "@/lib/cjkTypography";
import NoteColorMarkPicker from "@/components/NoteColorMarkPicker";
import type { NoteColorMark } from "@/types";
import MindMapEmbedInsertDialog from "@/components/MindMapEmbedInsertDialog";
Expand Down Expand Up @@ -1593,6 +1595,16 @@ function OrdinaryEditorPane({
};
}, [showDesktopMoreMenu]);

const applyCjkTypography = (action: CjkTypographyAction) => {
setShowMobileMenu(false);
setShowDesktopMoreMenu(false);
// The underlying editor checks read-only again, guarding stale menu state.
if (!activeNote || !canEditActiveNote || effectiveLocked || isTrashed ||
modeSwitching || noteIsFullHtmlDoc) return;
const applied = editorHandleRef.current?.applyCjkTypography?.(action) === true;
if (!applied) toast.info(t("cjkTypography.noChanges"));
};

const handleUpdate = useCallback(async (data: NoteEditorUpdatePayload) => {
const currentNote = activeNoteRef.current;
if (!currentNote || currentNote.isLocked || viewLockedIdsRef.current.has(currentNote.id)) return;
Expand Down Expand Up @@ -2857,6 +2869,11 @@ const moveToTrash = useCallback(async () => {
<span>{t('editor.showOutline')}</span>
</button>
<NoteThemeMenuSelect noteId={activeNote.id} disabled={!canWriteNote(activeNote)} />
<CjkTypographyMenuActions
mobile
disabled={!canEditActiveNote || effectiveLocked || isTrashed || modeSwitching || noteIsFullHtmlDoc}
onAction={applyCjkTypography}
/>
<div className="h-px bg-app-border mx-2 my-0.5" />
{/* AI ���ɱ��� */}
<button
Expand Down Expand Up @@ -3463,6 +3480,10 @@ const moveToTrash = useCallback(async () => {
<span>{t('editor.attachments')}</span>
</button>
<NoteThemeMenuSelect noteId={activeNote.id} disabled={!canWriteNote(activeNote)} />
<CjkTypographyMenuActions
disabled={!canEditActiveNote || effectiveLocked || isTrashed || modeSwitching || noteIsFullHtmlDoc}
onAction={applyCjkTypography}
/>
{canSplitDocument && onSplitDocument && (
<button
onClick={() => { onSplitDocument(); setShowDesktopMoreMenu(false); }}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/MarkdownEditorImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ import {
insertLink,
insertImage,
replaceSelection,
applyMarkdownCjkTypography,
} from "@/lib/markdownCommands";
import {
MarkdownSlashMenu,
Expand Down Expand Up @@ -1308,6 +1309,11 @@ export default forwardRef<NoteEditorHandle, MarkdownEditorProps>(function Markdo
};
},
isReady: () => !!viewRef.current,
applyCjkTypography: (action) => {
const view = viewRef.current;
if (!view || !editable || !view.state.facet(EditorView.editable)) return false;
return applyMarkdownCjkTypography(view, action);
},
insertMarkdownAtCursor: (md: string) => {
const view = viewRef.current;
if (!view) return false;
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/TiptapEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { emojis } from "@tiptap/extension-emoji";
import { getRichTextExtensions } from "@/lib/richTextExtensions";
import { applyTiptapCjkTypography } from "@/lib/tiptapCjkTypography";
import { EmojiSuggestionList } from "./EmojiSuggestionList";
import RichTextBlockControls from "./RichTextBlockControls";
import { getSlashEditorId } from "./extensions/SlashCommandExtension";
Expand Down Expand Up @@ -3433,6 +3434,10 @@ const TiptapEditor = forwardRef<NoteEditorHandle, TiptapEditorProps>(function Ti
pendingSaveAckRef.current = ack;
},
isReady: () => !!editor && !editor.isDestroyed,
applyCjkTypography: (action) => {
if (!editor || editor.isDestroyed || !editor.isEditable) return false;
return applyTiptapCjkTypography(editor, action);
},
insertMarkdownAtCursor: (md: string) => {
if (!editor || editor.isDestroyed) return false;
try {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/TiptapEditorRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const TiptapEditorRuntime = forwardRef<NoteEditorHandle, RuntimeTiptapEditorProp
isReady: () => baseRef.current?.isReady?.() ?? Boolean(baseRef.current),
insertMarkdownAtCursor: (markdown) => baseRef.current?.insertMarkdownAtCursor?.(markdown) ?? false,
appendMarkdown: (markdown) => baseRef.current?.appendMarkdown?.(markdown) ?? false,
applyCjkTypography: (action) => baseRef.current?.applyCjkTypography?.(action) ?? false,
}), []);

useEffect(() => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/editors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import type { Note, Tag } from "@/types";
import type * as Y from "yjs";
import type { CjkTypographyAction } from "@/lib/cjkTypography";
import type { Awareness } from "y-protocols/awareness";

/** 标题项(大纲/跳转用) */
Expand Down Expand Up @@ -81,6 +82,8 @@ export interface NoteEditorProps {
export interface NoteEditorHandle {
/** 立即触发一次 onUpdate(跳过 debounce)。无 pending 更新时为 no-op。 */
flushSave: () => void;
/** Manually apply a protected CJK typography action as one undoable edit. */
applyCjkTypography?: (action: CjkTypographyAction) => boolean;
/**
* 丢弃 pending 的 debounce 更新(清 timer,不派发 onUpdate)。
* 用于"切换编辑器"这类场景:调用方已经自己以规范化后的内容发起 PUT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,11 @@ describe("useAttachmentImageRenderSource", () => {

expect(apiMock.resolveAttachmentUrl).toHaveBeenCalledWith(rawSrc);
expect(probe().dataset.persistentSrc).toBe(`/api/attachments/${ATTACHMENT_ID}`);
expect(new URL(probe().dataset.renderSrc!).searchParams.get("w")).toBe("320");
expect(new URL(probe().dataset.renderSrc!).searchParams.get("sig")).toBeNull();
// An unsigned private attachment must NEVER be exposed as an <img> URL.
// The original test expected the pre-auth URL to be renderable, which
// contradicts the existing verified-attachment-access security boundary.
expect(probe().dataset.renderSrc).toBe("");
expect(probe().dataset.error).toBe("1");

await act(async () => {
registerAttachmentAccessUrls(
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/i18n/coverageTranslations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ export const zhCNCoverageTranslations = {
"automaticHint": "常用地址不可达时,仅在 Wi-Fi 下尝试经过服务器身份验证的局域网地址。",
"verifyFirst": "请先通过已保存地址连接一次,确认服务器身份后即可自动切换。",
},
cjkTypography: {
"title": "中文排版",
"spacing": "中英文 / 数字间加空格",
"punctuation": "智能标点规范化",
"cornerQuotes": "转换为直角引号",
"noChanges": "没有可调整的内容,或当前编辑模式不支持此操作",
},
syncBadge: {
"conflicts": "{{count}} 个冲突待处理",
"pending": "待同步 {{count}}",
Expand Down Expand Up @@ -771,6 +778,13 @@ export const enCoverageTranslations = {
"automaticHint": "If the preferred address is unreachable, try a verified local network address only when connected to Wi-Fi.",
"verifyFirst": "Connect through the saved address once to verify the server identity before automatic switching.",
},
cjkTypography: {
"title": "Chinese typography",
"spacing": "Space between CJK and English / numbers",
"punctuation": "Normalize punctuation",
"cornerQuotes": "Convert to corner quotation marks",
"noChanges": "Nothing to change, or this editor mode does not support the operation",
},
syncBadge: {
"conflicts": "{{count}} conflicts to resolve",
"pending": "{{count}} pending changes",
Expand Down
138 changes: 138 additions & 0 deletions frontend/src/lib/__tests__/cjkTypography.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { EditorState, EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { history, undo } from "@codemirror/commands";
import { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { transformCjkTypography } from "@/lib/cjkTypography";
import { applyMarkdownCjkTypography } from "@/lib/markdownCommands";
import { applyTiptapCjkTypography } from "@/lib/tiptapCjkTypography";

describe("#822 manual Chinese typography", () => {
it("inserts spaces only at Han / Latin and digit boundaries", () => {
expect(transformCjkTypography("使用PostgreSQL数据库;2024年10月1日", "spacing"))
.toBe("使用 PostgreSQL 数据库;2024 年 10 月 1 日");
expect(transformCjkTypography("hello, world (参见图1)", "spacing"))
.toBe("hello, world (参见图 1)");
});

it("protects fenced code, inline code, TeX, URLs, email, HTML and Markdown destinations", () => {
const tick = String.fromCharCode(96);
const md = [
"使用PostgreSQL数据库,打开[网址介绍](https://example.com/中文doc?q=1.0)看文档",
"联系 dev中文@example.com 或访问 https://example.com/中文code",
"内联"+tick+"code中文ABC"+tick+" 保持",
"公式$E=mc中文2$与\\(a中文3\\)不改",
"<span class='中文ABC'>内容ABC</span>",
tick.repeat(3)+"javascript", "const name = '中文ABC';", tick.repeat(3),
"最后English版本",
].join("\n");
const changed = transformCjkTypography(md, "spacing", "markdown");
expect(changed).toContain("使用 PostgreSQL 数据库");
expect(changed).toContain("网址介绍](https://example.com/中文doc?q=1.0)");
expect(changed).toContain("dev中文@example.com");
expect(changed).toContain("https://example.com/中文code");
expect(changed).toContain(tick+"code中文ABC"+tick);
expect(changed).toContain("$E=mc中文2$");
expect(changed).toContain("\\(a中文3\\)");
expect(changed).toContain("<span class='中文ABC'>");
expect(changed).toContain("const name = '中文ABC';");
expect(changed).toContain("最后 English 版本");
});

it("preserves frontmatter and Nowen note/mindmap reference identifiers", () => {
const md = "---\ntitle: 使用PostgreSQL笔记\n---\n见[[使用PostgreSQL笔记]]或![[mindmap:中文ABC]]和[^脚注A]。";
const result = transformCjkTypography(md, "spacing", "markdown");
expect(result).toContain("title: 使用PostgreSQL笔记");
expect(result).toContain("[[使用PostgreSQL笔记]]");
expect(result).toContain("![[mindmap:中文ABC]]");
expect(result).toContain("[^脚注A]");
});

it("normalizes Chinese punctuation but keeps English clauses and numeric tokens", () => {
expect(transformCjkTypography("你好,今天很好!欢迎(大家).", "punctuation"))
.toBe("你好,今天很好!欢迎(大家)。");
expect(transformCjkTypography("他说,Hello, world! 然后离开了。", "punctuation"))
.toBe("他说,Hello, world! 然后离开了。");
expect(transformCjkTypography("Hello, world!", "punctuation"))
.toBe("Hello, world!");
const numbers = transformCjkTypography("价格3.14,版本v1.0;时间10:30;总数1,000", "punctuation");
expect(numbers).toContain("3.14");
expect(numbers).toContain("v1.0");
expect(numbers).toContain("10:30");
expect(numbers).toContain("1,000");
});

it("keeps inline and block math untouched in rich-text text nodes", () => {
expect(transformCjkTypography("公式$E中文2$混排", "spacing"))
.toBe("公式$E中文2$混排");
expect(transformCjkTypography("公式\\(x中文3\\)不应改写", "spacing"))
.toBe("公式\\(x中文3\\)不应改写");
});

it("converts only matched quotation pairs and skips apostrophes", () => {
expect(transformCjkTypography('“外层‘内层’”', "cornerQuotes"))
.toBe("「外层『内层』」");
expect(transformCjkTypography('"外层\'内层\'"', "cornerQuotes"))
.toBe("「外层『内层』」");
expect(transformCjkTypography("don't students' said 'ok'", "cornerQuotes"))
.toContain("don't students'");
expect(transformCjkTypography('没有闭合的"引号', "cornerQuotes"))
.toBe('没有闭合的"引号');
});

it("is idempotent after a single manual action", () => {
for (const action of ["spacing", "punctuation", "cornerQuotes"] as const) {
const once = transformCjkTypography('他用PostgreSQL, 说“你好”', action);
expect(transformCjkTypography(once, action)).toBe(once);
}
});

it("CodeMirror edits only the selection and undoes all changes in one step", () => {
const source = "第一行PostgreSQL测试\n第二行SQLite数据库";
const host = document.createElement("div");
document.body.append(host);
const view = new EditorView({ parent: host, state: EditorState.create({
doc: source, extensions: [history()],
}) });
view.dispatch({ selection: EditorSelection.create([EditorSelection.range(0, source.indexOf("\n"))]) });
expect(applyMarkdownCjkTypography(view, "spacing")).toBe(true);
expect(view.state.doc.toString()).toBe("第一行 PostgreSQL 测试\n第二行SQLite数据库");
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe(source);
view.destroy(); host.remove();
});

it("CodeMirror protects a selection placed inside inline code", () => {
const tick = String.fromCharCode(96);
const source = "普通中文ABC "+tick+"代码PostgreSQL测试"+tick+" 结束";
const host = document.createElement("div");
document.body.append(host);
const view = new EditorView({ parent: host, state: EditorState.create({
doc: source, extensions: [history()],
}) });
view.dispatch({ selection: EditorSelection.create([EditorSelection.range(0, source.length)]) });
expect(applyMarkdownCjkTypography(view, "spacing")).toBe(true);
expect(view.state.doc.toString()).toContain(tick+"代码PostgreSQL测试"+tick);
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe(source);
view.destroy(); host.remove();
});

it("Tiptap preserves marks and code nodes and supports one-step undo", () => {
const element = document.createElement("div");
document.body.append(element);
const editor = new Editor({ element, extensions: [StarterKit],
content: '<p><strong>使用PostgreSQL数据库</strong> <code>代码Python片段</code></p><pre><code>代码ABC</code></pre><p>结尾</p>',
});
const before = editor.getHTML();
expect(applyTiptapCjkTypography(editor, "spacing")).toBe(true);
expect(editor.getHTML()).toContain("<strong>使用 PostgreSQL 数据库</strong>");
expect(editor.getHTML()).toContain("<code>代码Python片段</code>");
expect(editor.getHTML()).toContain("代码ABC");
expect(editor.commands.undo()).toBe(true);
expect(editor.getHTML()).toBe(before);
editor.destroy(); element.remove();
});
});
Loading
Loading