diff --git a/.github/workflows/cjk-typography-ci.yml b/.github/workflows/cjk-typography-ci.yml
new file mode 100644
index 000000000..e4c9fd67b
--- /dev/null
+++ b/.github/workflows/cjk-typography-ci.yml
@@ -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
diff --git a/frontend/src/components/CjkTypographyMenuActions.tsx b/frontend/src/components/CjkTypographyMenuActions.tsx
new file mode 100644
index 000000000..6591be458
--- /dev/null
+++ b/frontend/src/components/CjkTypographyMenuActions.tsx
@@ -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 (
+
+
+ {t("cjkTypography.title")}
+
+ {actions.map(({ action, icon: Icon }) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/EditorPane.tsx b/frontend/src/components/EditorPane.tsx
index 7fbdcd735..c2a5c13a8 100644
--- a/frontend/src/components/EditorPane.tsx
+++ b/frontend/src/components/EditorPane.tsx
@@ -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";
@@ -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;
@@ -2857,6 +2869,11 @@ const moveToTrash = useCallback(async () => {
{t('editor.showOutline')}
+
{/* AI ���ɱ��� */}
+
{canSplitDocument && onSplitDocument && (