From d17f48f80c2ca1d06a85ce43c63dcff3f570486f Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:35:26 +0800 Subject: [PATCH 01/14] feat(typography): add guarded CJK typography transforms --- frontend/src/lib/cjkTypography.ts | 226 ++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 frontend/src/lib/cjkTypography.ts diff --git a/frontend/src/lib/cjkTypography.ts b/frontend/src/lib/cjkTypography.ts new file mode 100644 index 000000000..f1ced7968 --- /dev/null +++ b/frontend/src/lib/cjkTypography.ts @@ -0,0 +1,226 @@ +/** + * Manual, dependency-free CJK typography tools. + * NEVER run on editor keystrokes. Ranges are evaluated in the original document + * so Markdown syntax is never re-created from rendered HTML. + */ +export type CjkTypographyAction = 'spacing' | 'punctuation' | 'cornerQuotes'; +export type CjkTypographyFormat = 'plain' | 'markdown'; +export interface TextRange { from: number; to: number } + +const HAN = /\p{Script=Han}/u; +const LATIN_OR_DIGIT = /[A-Za-z0-9]/; +const TICK = String.fromCharCode(96); + +function mergeRanges(input: TextRange[]): TextRange[] { + const ranges = input.filter((r) => r.from < r.to).sort((a, b) => a.from - b.from || a.to - b.to); + const merged: TextRange[] = []; + for (const range of ranges) { + const last = merged[merged.length - 1]; + if (last && range.from <= last.to) last.to = Math.max(last.to, range.to); + else merged.push({ ...range }); + } + return merged; +} + +function escapedAt(text: string, at: number): boolean { + let escapes = 0; + for (let i = at - 1; i >= 0 && text[i] === '\\'; i--) escapes++; + return escapes % 2 === 1; +} + +export function markdownProtectedRanges(text: string): TextRange[] { + const ranges: TextRange[] = []; + // Fences and display math span multiple lines and must take precedence over + // inline parsing (including unmatched backticks in a code sample). + let offset = 0; + let fence: { char: string; size: number } | null = null; + let displayMath = false; + for (const line of text.split('\n')) { + const start = offset; + const end = offset + line.length + (offset + line.length < text.length ? 1 : 0); + const marker = line.match(/^ {0,3}(\x60{3,}|~{3,})/); + if (fence) { + ranges.push({ from: start, to: end }); + if (marker && marker[1][0] === fence.char && marker[1].length >= fence.size && + line.slice(marker[0].length).trim() === '') fence = null; + } else if (marker) { + fence = { char: marker[1][0], size: marker[1].length }; + ranges.push({ from: start, to: end }); + } else if (displayMath || /^\s*\$\$/.test(line)) { + ranges.push({ from: start, to: end }); + const delimiters = line.match(/\$\$/g)?.length || 0; + if (!displayMath) displayMath = delimiters % 2 !== 0; + else if (delimiters % 2 !== 0) displayMath = false; + } + offset = end; + } + + const alreadyProtected = (at: number) => ranges.some((r) => at >= r.from && at < r.to); + function addMatches(regex: RegExp) { + for (const match of text.matchAll(regex)) { + const from = match.index; + if (alreadyProtected(from)) continue; + ranges.push({ from, to: from + match[0].length }); + } + } + + // Entire link destination, including nested parentheses and optional title, + // while keeping the visible [label] available for typography. + for (let i = 0; i < text.length - 1; i++) { + if (text[i] !== ']' || text[i + 1] !== '(' || alreadyProtected(i)) continue; + let depth = 1; + let j = i + 2; + while (j < text.length && text[j] !== '\n' && depth > 0) { + if (!escapedAt(text, j)) { + if (text[j] === '(') depth++; + if (text[j] === ')') depth--; + } + j++; + } + if (depth === 0) { + ranges.push({ from: i + 1, to: j }); + i = j - 1; + } + } + + // URLs, emails, HTML tags and numeric tokens protected before punctuation. + addMatches(/<\/?[A-Za-z][^>\n]*>/g); + addMatches(/(?:https?:\/\/|www\.)[^\s<>()\[\]"']+/gi); + addMatches(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g); + addMatches(/(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?:\/[^\s]*)?/g); + addMatches(/\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b/g); + addMatches(/\.{3,}/g); + addMatches(/^[ \t]{0,3}\[[^\]\n]+\]:[ \t]*[^\n]+/gm); + + // Inline code, math and TeX bracket delimiters. Keep unmatched openers + // untouched rather than stripping a delimiter or consuming following text. + for (let i = 0; i < text.length; i++) { + if (alreadyProtected(i) || escapedAt(text, i)) continue; + if (text[i] === TICK) { + let size = 1; + while (text[i + size] === TICK) size++; + const marker = TICK.repeat(size); + let end = text.indexOf(marker, i + size); + while (end >= 0 && (text[end + size] === TICK || escapedAt(text, end))) { + end = text.indexOf(marker, end + size); + } + if (end >= 0) { + ranges.push({ from: i, to: end + size }); + i = end + size - 1; + } else i += size - 1; + continue; + } + const open = text.startsWith('\\(', i) ? '\\(' + : text.startsWith('\\[', i) ? '\\[' + : text[i] === '$' ? (text[i + 1] === '$' ? '$$' : '$') : null; + if (!open) continue; + const close = open === '\\(' ? '\\)' : open === '\\[' ? '\\]' : open; + let end = text.indexOf(close, i + open.length); + while (end >= 0 && escapedAt(text, end) && open[0] === '$') { + end = text.indexOf(close, end + close.length); + } + if (end >= 0 && (open !== '$' || text.slice(i + 1, end).trim())) { + ranges.push({ from: i, to: end + close.length }); + i = end + close.length - 1; + } + } + return mergeRanges(ranges); +} + +function addCjkSpacing(text: string): string { + return text.replace(/(\p{Script=Han})(?=[A-Za-z0-9])/gu, '$1 ') + .replace(/([A-Za-z0-9])(?=\p{Script=Han})/gu, '$1 '); +} + +const TO_CJK: Record = { + ',': ',', '.': '。', ';': ';', ':': ':', + '?': '?', '!': '!', '(': '(', ')': ')', +}; +const TO_ASCII: Record = Object.fromEntries( + Object.entries(TO_CJK).map(([en, zh]) => [zh, en]), +); + +function isEnglishClause(text: string, pos: number): boolean { + const left = text.slice(Math.max(0, pos - 56), pos); + const right = text.slice(pos + 1, pos + 56); + const latinBefore = /[A-Za-z]$/.test(left); + const latinAfter = /^[ \t]*[A-Za-z]/.test(right); + if (latinBefore && latinAfter) return true; + const fragment = left.split(/[\p{Script=Han},。!?;:\n]/u).pop() || ''; + // "Hello, world!" in a Chinese paragraph remains an English clause. + return latinBefore && (fragment.match(/[A-Za-z]+/g)?.length || 0) >= 2; +} + +function normalizePunctuation(text: string): string { + return text.split(/(\n)/).map((line) => { + if (line === '\n') return line; + const hans = [...line].filter((c) => HAN.test(c)).length; + const latins = [...line].filter((c) => /[A-Za-z]/.test(c)).length; + const chinese = hans >= 2 && hans >= latins * 0.35; + if (!chinese) { + if (hans > 0) return line; // mixed but ambiguous: do not guess + return [...line].map((c) => TO_ASCII[c] ?? c).join(''); + } + return [...line].map((char, i) => { + if (!(char in TO_CJK)) return char; + if (isEnglishClause(line, i)) return char; + if (char === '.') { + if (/[A-Za-z0-9]/.test(line[i - 1] || '') || /[A-Za-z0-9]/.test(line[i + 1] || '')) return char; + } + if (char === '(' && /[A-Za-z]/.test(line[i + 1] || '')) return char; + if (char === ')' && /[A-Za-z]/.test(line[i - 1] || '')) return char; + return TO_CJK[char]; + }).join(''); + }).join(''); +} + +function convertQuotes(text: string): string { + const chars = [...text]; + const pair = new Map(); + const stacks: Record = { double: [], single: [] }; + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + const kind = /["“”]/.test(char) ? 'double' : /['‘’]/.test(char) ? 'single' : ''; + if (!kind) continue; + const previous = chars[i - 1] || ''; + const next = chars[i + 1] || ''; + if (kind === 'single' && /[A-Za-z]/.test(previous) && + (/[A-Za-z]/.test(next) || !stacks.single.length)) continue; + const openings = kind === 'double' ? '“' : '‘'; + const closings = kind === 'double' ? '”' : '’'; + const open = char === openings || (char !== closings && stacks[kind].length === 0); + if (open) stacks[kind].push(i); + else if (stacks[kind].length) { + const start = stacks[kind].pop()!; + pair.set(start, kind === 'double' ? '「' : '『'); + pair.set(i, kind === 'double' ? '」' : '』'); + } + } + return chars.map((char, i) => pair.get(i) ?? char).join(''); +} + +function plainTransform(text: string, action: CjkTypographyAction): string { + if (action === 'spacing') return addCjkSpacing(text); + if (action === 'punctuation') return normalizePunctuation(text); + return convertQuotes(text); +} + +export function transformCjkTypography( + text: string, action: CjkTypographyAction, format: CjkTypographyFormat = 'plain', +): string { + if (!text) return text; + const protectedRanges = format === 'markdown' ? markdownProtectedRanges(text) : []; + // In plain-text contexts avoid changing numeric, URLs and emails too. + if (format === 'plain') { + const re = /(?:https?:\/\/|www\.)[^\s<>()\[\]"']+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b|\.{3,}/gi; + for (const m of text.matchAll(re)) protectedRanges.push({ from: m.index, to: m.index + m[0].length }); + } + let result = ''; + let offset = 0; + for (const range of mergeRanges(protectedRanges)) { + result += plainTransform(text.slice(offset, range.from), action); + result += text.slice(range.from, range.to); + offset = range.to; + } + return result + plainTransform(text.slice(offset), action); +} From a97d591258926c6f7e1a402a46b2b3c64cc346db Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:36:40 +0800 Subject: [PATCH 02/14] feat(typography): add atomic Markdown and rich-text editor commands --- .../src/components/MarkdownEditorImpl.tsx | 6 ++++ frontend/src/components/TiptapEditor.tsx | 5 +++ .../src/components/TiptapEditorRuntime.tsx | 1 + frontend/src/components/editors/types.ts | 3 ++ frontend/src/lib/markdownCommands.ts | 35 +++++++++++++++++++ frontend/src/lib/tiptapCjkTypography.ts | 35 +++++++++++++++++++ 6 files changed, 85 insertions(+) create mode 100644 frontend/src/lib/tiptapCjkTypography.ts diff --git a/frontend/src/components/MarkdownEditorImpl.tsx b/frontend/src/components/MarkdownEditorImpl.tsx index aed208136..0d6e1746f 100644 --- a/frontend/src/components/MarkdownEditorImpl.tsx +++ b/frontend/src/components/MarkdownEditorImpl.tsx @@ -176,6 +176,7 @@ import { insertLink, insertImage, replaceSelection, + applyMarkdownCjkTypography, } from "@/lib/markdownCommands"; import { MarkdownSlashMenu, @@ -1308,6 +1309,11 @@ export default forwardRef(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; diff --git a/frontend/src/components/TiptapEditor.tsx b/frontend/src/components/TiptapEditor.tsx index a0e6bb2a0..527e54845 100644 --- a/frontend/src/components/TiptapEditor.tsx +++ b/frontend/src/components/TiptapEditor.tsx @@ -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"; @@ -3433,6 +3434,10 @@ const TiptapEditor = forwardRef(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 { diff --git a/frontend/src/components/TiptapEditorRuntime.tsx b/frontend/src/components/TiptapEditorRuntime.tsx index 286a3640a..a28ad3f74 100644 --- a/frontend/src/components/TiptapEditorRuntime.tsx +++ b/frontend/src/components/TiptapEditorRuntime.tsx @@ -143,6 +143,7 @@ const TiptapEditorRuntime = forwardRef 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(() => { diff --git a/frontend/src/components/editors/types.ts b/frontend/src/components/editors/types.ts index 56b3a8151..41f4459ab 100644 --- a/frontend/src/components/editors/types.ts +++ b/frontend/src/components/editors/types.ts @@ -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"; /** 标题项(大纲/跳转用) */ @@ -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, diff --git a/frontend/src/lib/markdownCommands.ts b/frontend/src/lib/markdownCommands.ts index 18360b1f8..f02a3483a 100644 --- a/frontend/src/lib/markdownCommands.ts +++ b/frontend/src/lib/markdownCommands.ts @@ -10,6 +10,7 @@ */ import { EditorView } from "@codemirror/view"; import { EditorSelection, Line } from "@codemirror/state"; +import { transformCjkTypography, markdownProtectedRanges, type CjkTypographyAction } from "./cjkTypography"; // --------------------------------------------------------------------------- // 基础工具 @@ -330,3 +331,37 @@ export function replaceSelection(view: EditorView, text: string): boolean { focus(view); return true; } + + +/** + * One CodeMirror transaction for all changes, so Mod-Z rolls back the entire + * action. Protection ranges are read from the WHOLE original document, even + * when the current selection is only a substring of a code span/link URL. + */ +export function applyMarkdownCjkTypography(view: EditorView, action: CjkTypographyAction): boolean { + if (!view.state.facet(EditorView.editable)) return false; + const original = view.state.doc.toString(); + const protectedRanges = markdownProtectedRanges(original); + const selections = view.state.selection.ranges; + const targets = selections.some((range) => range.from !== range.to) + ? selections.filter((range) => range.from !== range.to) + : [{ from: 0, to: original.length }]; + const changes: Array<{ from: number; to: number; insert: string }> = []; + for (const range of targets) { + let cursor = range.from; + const cuts = protectedRanges.filter((block) => block.to > range.from && block.from < range.to); + for (const block of [...cuts, { from: range.to, to: range.to }]) { + const end = Math.min(block.from, range.to); + if (end > cursor) { + const text = original.slice(cursor, end); + const insert = transformCjkTypography(text, action); + if (insert !== text) changes.push({ from: cursor, to: end, insert }); + } + cursor = Math.max(cursor, Math.min(block.to, range.to)); + } + } + if (!changes.length) return false; + view.dispatch({ changes, userEvent: "input" }); + focus(view); + return true; +} diff --git a/frontend/src/lib/tiptapCjkTypography.ts b/frontend/src/lib/tiptapCjkTypography.ts new file mode 100644 index 000000000..621a0a42e --- /dev/null +++ b/frontend/src/lib/tiptapCjkTypography.ts @@ -0,0 +1,35 @@ +import type { Editor } from "@tiptap/core"; +import { transformCjkTypography, type CjkTypographyAction } from "./cjkTypography"; + +/** + * Transform *text nodes*, not HTML/JSON serialization. Preserve marks, + * embedded nodes, links, code and math. All replacements share one PM transaction. + */ +export function applyTiptapCjkTypography(editor: Editor, action: CjkTypographyAction): boolean { + if (!editor.isEditable || editor.isDestroyed) return false; + const { doc, selection } = editor.state; + const hasSelection = !selection.empty; + const from = hasSelection ? selection.from : 0; + const to = hasSelection ? selection.to : doc.content.size; + const patches: Array<{ from: number; to: number; text: string; marks: readonly any[] }> = []; + doc.nodesBetween(from, to, (node, pos, parent) => { + const type = node.type.name.toLowerCase(); + if (/(?:codeblock|code_block|math|formula|equation|diagram|mermaid)/.test(type)) return false; + if (!node.isText || !node.text) return; + if (parent && /(?:codeblock|code_block|math|formula)/.test(parent.type.name.toLowerCase())) return; + if (node.marks.some((mark) => /^(?:code|link|math|inlineMath)$/i.test(mark.type.name))) return; + const start = Math.max(from, pos); + const end = Math.min(to, pos + node.nodeSize); + if (end <= start) return; + const source = node.text.slice(start - pos, end - pos); + const text = transformCjkTypography(source, action); + if (source !== text) patches.push({ from: start, to: end, text, marks: node.marks }); + }); + if (!patches.length) return false; + const transaction = editor.state.tr; + for (const patch of patches.reverse()) { + transaction.replaceWith(patch.from, patch.to, editor.schema.text(patch.text, patch.marks)); + } + editor.view.dispatch(transaction.scrollIntoView()); + return true; +} From d31d1a5188990ff91b7410843279dacd6deb8395 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:37:52 +0800 Subject: [PATCH 03/14] feat(ui): expose manual Chinese typography actions on desktop and mobile --- .../components/CjkTypographyMenuActions.tsx | 38 +++++++++++++++++++ frontend/src/components/EditorPane.tsx | 21 ++++++++++ frontend/src/i18n/coverageTranslations.ts | 14 +++++++ 3 files changed, 73 insertions(+) create mode 100644 frontend/src/components/CjkTypographyMenuActions.tsx diff --git a/frontend/src/components/CjkTypographyMenuActions.tsx b/frontend/src/components/CjkTypographyMenuActions.tsx new file mode 100644 index 000000000..5ef98e32d --- /dev/null +++ b/frontend/src/components/CjkTypographyMenuActions.tsx @@ -0,0 +1,38 @@ +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("editor.cjkTypography.title")} +
+ {actions.map(({ action, icon: Icon }) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/EditorPane.tsx b/frontend/src/components/EditorPane.tsx index 7fbdcd735..5fc5e840a 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("editor.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 && ( ))}
diff --git a/frontend/src/components/EditorPane.tsx b/frontend/src/components/EditorPane.tsx index 5fc5e840a..c2a5c13a8 100644 --- a/frontend/src/components/EditorPane.tsx +++ b/frontend/src/components/EditorPane.tsx @@ -1602,7 +1602,7 @@ function OrdinaryEditorPane({ if (!activeNote || !canEditActiveNote || effectiveLocked || isTrashed || modeSwitching || noteIsFullHtmlDoc) return; const applied = editorHandleRef.current?.applyCjkTypography?.(action) === true; - if (!applied) toast.info(t("editor.cjkTypography.noChanges")); + if (!applied) toast.info(t("cjkTypography.noChanges")); }; const handleUpdate = useCallback(async (data: NoteEditorUpdatePayload) => { diff --git a/frontend/src/lib/tiptapCjkTypography.ts b/frontend/src/lib/tiptapCjkTypography.ts index 621a0a42e..e68ee7bf6 100644 --- a/frontend/src/lib/tiptapCjkTypography.ts +++ b/frontend/src/lib/tiptapCjkTypography.ts @@ -1,4 +1,5 @@ import type { Editor } from "@tiptap/core"; +import type { Mark } from "@tiptap/pm/model"; import { transformCjkTypography, type CjkTypographyAction } from "./cjkTypography"; /** @@ -11,7 +12,7 @@ export function applyTiptapCjkTypography(editor: Editor, action: CjkTypographyAc const hasSelection = !selection.empty; const from = hasSelection ? selection.from : 0; const to = hasSelection ? selection.to : doc.content.size; - const patches: Array<{ from: number; to: number; text: string; marks: readonly any[] }> = []; + const patches: Array<{ from: number; to: number; text: string; marks: readonly Mark[] }> = []; doc.nodesBetween(from, to, (node, pos, parent) => { const type = node.type.name.toLowerCase(); if (/(?:codeblock|code_block|math|formula|equation|diagram|mermaid)/.test(type)) return false; From 0b8123ba501883b37c677f977bde7abac785a3bc Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:40:06 +0800 Subject: [PATCH 05/14] test(typography): exercise protected spans, contextual punctuation and atomic undo --- .../src/lib/__tests__/cjkTypography.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 frontend/src/lib/__tests__/cjkTypography.test.ts diff --git a/frontend/src/lib/__tests__/cjkTypography.test.ts b/frontend/src/lib/__tests__/cjkTypography.test.ts new file mode 100644 index 000000000..f3e1e8cfe --- /dev/null +++ b/frontend/src/lib/__tests__/cjkTypography.test.ts @@ -0,0 +1,120 @@ +// @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\\)不改", + "内容ABC", + 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(""); + expect(changed).toContain("const name = '中文ABC';"); + expect(changed).toContain("最后 English 版本"); + }); + + 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("converts only matched quotation pairs and skips apostrophes", () => { + 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: '

使用PostgreSQL数据库 代码Python片段

代码ABC
', + }); + const before = editor.getHTML(); + expect(applyTiptapCjkTypography(editor, "spacing")).toBe(true); + expect(editor.getHTML()).toContain("使用 PostgreSQL 数据库"); + expect(editor.getHTML()).toContain("代码Python片段"); + expect(editor.getHTML()).toContain("代码ABC"); + expect(editor.commands.undo()).toBe(true); + expect(editor.getHTML()).toBe(before); + editor.destroy(); element.remove(); + }); +}); From 41602682b48cc469761b03178333e81db8a99c90 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:41:45 +0800 Subject: [PATCH 06/14] fix(typography): protect Unicode email local parts and isolate undo history --- frontend/src/lib/cjkTypography.ts | 4 ++-- frontend/src/lib/markdownCommands.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/cjkTypography.ts b/frontend/src/lib/cjkTypography.ts index f1ced7968..9195259f2 100644 --- a/frontend/src/lib/cjkTypography.ts +++ b/frontend/src/lib/cjkTypography.ts @@ -86,7 +86,7 @@ export function markdownProtectedRanges(text: string): TextRange[] { // URLs, emails, HTML tags and numeric tokens protected before punctuation. addMatches(/<\/?[A-Za-z][^>\n]*>/g); addMatches(/(?:https?:\/\/|www\.)[^\s<>()\[\]"']+/gi); - addMatches(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g); + addMatches(/[^\s<>()\[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g); addMatches(/(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?:\/[^\s]*)?/g); addMatches(/\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b/g); addMatches(/\.{3,}/g); @@ -212,7 +212,7 @@ export function transformCjkTypography( const protectedRanges = format === 'markdown' ? markdownProtectedRanges(text) : []; // In plain-text contexts avoid changing numeric, URLs and emails too. if (format === 'plain') { - const re = /(?:https?:\/\/|www\.)[^\s<>()\[\]"']+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b|\.{3,}/gi; + const re = /(?:https?:\/\/|www\.)[^\s<>()\[\]"']+|[^\s<>()\[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b|\.{3,}/gi; for (const m of text.matchAll(re)) protectedRanges.push({ from: m.index, to: m.index + m[0].length }); } let result = ''; diff --git a/frontend/src/lib/markdownCommands.ts b/frontend/src/lib/markdownCommands.ts index f02a3483a..44f338044 100644 --- a/frontend/src/lib/markdownCommands.ts +++ b/frontend/src/lib/markdownCommands.ts @@ -10,6 +10,7 @@ */ import { EditorView } from "@codemirror/view"; import { EditorSelection, Line } from "@codemirror/state"; +import { isolateHistory } from "@codemirror/commands"; import { transformCjkTypography, markdownProtectedRanges, type CjkTypographyAction } from "./cjkTypography"; // --------------------------------------------------------------------------- @@ -361,7 +362,7 @@ export function applyMarkdownCjkTypography(view: EditorView, action: CjkTypograp } } if (!changes.length) return false; - view.dispatch({ changes, userEvent: "input" }); + view.dispatch({ changes, annotations: isolateHistory.of("full"), userEvent: "input" }); focus(view); return true; } From 281f1fad9197125d5c927f5edea1c099c1d76b32 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:42:49 +0800 Subject: [PATCH 07/14] test(typography): run focused CI and use static translation keys --- .github/workflows/cjk-typography-ci.yml | 42 +++++++++++++++++++ .../components/CjkTypographyMenuActions.tsx | 4 +- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cjk-typography-ci.yml diff --git a/.github/workflows/cjk-typography-ci.yml b/.github/workflows/cjk-typography-ci.yml new file mode 100644 index 000000000..c6fc4fc9e --- /dev/null +++ b/.github/workflows/cjk-typography-ci.yml @@ -0,0 +1,42 @@ +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: Frontend TypeScript check + run: npx tsc -b diff --git a/frontend/src/components/CjkTypographyMenuActions.tsx b/frontend/src/components/CjkTypographyMenuActions.tsx index bb88d3f97..6591be458 100644 --- a/frontend/src/components/CjkTypographyMenuActions.tsx +++ b/frontend/src/components/CjkTypographyMenuActions.tsx @@ -30,7 +30,9 @@ export default function CjkTypographyMenuActions({ (mobile ? "active:bg-app-hover " : "hover:bg-app-hover ") + "transition-colors disabled:opacity-40"}> - {t("cjkTypography." + action)} + {action === "spacing" ? t("cjkTypography.spacing") + : action === "punctuation" ? t("cjkTypography.punctuation") + : t("cjkTypography.cornerQuotes")} ))} From 228bb876ca1b24ee83c6b49e0dafc17e716ebbf7 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:44:20 +0800 Subject: [PATCH 08/14] test(typography): avoid Tiptap auto trailing paragraph in undo fixture --- frontend/src/lib/__tests__/cjkTypography.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/__tests__/cjkTypography.test.ts b/frontend/src/lib/__tests__/cjkTypography.test.ts index f3e1e8cfe..2f937e515 100644 --- a/frontend/src/lib/__tests__/cjkTypography.test.ts +++ b/frontend/src/lib/__tests__/cjkTypography.test.ts @@ -106,7 +106,7 @@ describe("#822 manual Chinese typography", () => { const element = document.createElement("div"); document.body.append(element); const editor = new Editor({ element, extensions: [StarterKit], - content: '

使用PostgreSQL数据库 代码Python片段

代码ABC
', + content: '

使用PostgreSQL数据库 代码Python片段

代码ABC

结尾

', }); const before = editor.getHTML(); expect(applyTiptapCjkTypography(editor, "spacing")).toBe(true); From 408f6a55bf959e355a06729daa5c2f024c658ff7 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:47:07 +0800 Subject: [PATCH 09/14] fix(typography): protect Nowen links/frontmatter and speed up large-note scans --- .../src/lib/__tests__/cjkTypography.test.ts | 9 ++++++++ frontend/src/lib/cjkTypography.ts | 22 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/__tests__/cjkTypography.test.ts b/frontend/src/lib/__tests__/cjkTypography.test.ts index 2f937e515..d4e3de870 100644 --- a/frontend/src/lib/__tests__/cjkTypography.test.ts +++ b/frontend/src/lib/__tests__/cjkTypography.test.ts @@ -41,6 +41,15 @@ describe("#822 manual Chinese typography", () => { 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("你好,今天很好!欢迎(大家)。"); diff --git a/frontend/src/lib/cjkTypography.ts b/frontend/src/lib/cjkTypography.ts index 9195259f2..0920ec85f 100644 --- a/frontend/src/lib/cjkTypography.ts +++ b/frontend/src/lib/cjkTypography.ts @@ -83,6 +83,15 @@ export function markdownProtectedRanges(text: string): TextRange[] { } } + // Protect non-prose metadata and Nowen-style links: changing a link target + // would silently invalidate embedded notes/mind maps. + if (/^---\r?\n/.test(text)) { + const frontmatter = text.match(/^---\r?\n[\s\S]*?\n---[ \t]*(?:\r?\n|$)/); + if (frontmatter) ranges.push({ from: 0, to: frontmatter[0].length }); + } + addMatches(/!?\[\[[^\]\n]+\]\]/g); + addMatches(/\[\^[^\]\n]+\]/g); + // URLs, emails, HTML tags and numeric tokens protected before punctuation. addMatches(/<\/?[A-Za-z][^>\n]*>/g); addMatches(/(?:https?:\/\/|www\.)[^\s<>()\[\]"']+/gi); @@ -92,10 +101,21 @@ export function markdownProtectedRanges(text: string): TextRange[] { addMatches(/\.{3,}/g); addMatches(/^[ \t]{0,3}\[[^\]\n]+\]:[ \t]*[^\n]+/gm); + // Per-character parsing must not linearly scan thousands of protected + // ranges for every character in a large notebook. + const staticProtection = mergeRanges(ranges); + let protectedCursor = 0; + const isStaticProtected = (position: number) => { + while (protectedCursor < staticProtection.length && + staticProtection[protectedCursor].to <= position) protectedCursor++; + const range = staticProtection[protectedCursor]; + return !!range && position >= range.from && position < range.to; + }; + // Inline code, math and TeX bracket delimiters. Keep unmatched openers // untouched rather than stripping a delimiter or consuming following text. for (let i = 0; i < text.length; i++) { - if (alreadyProtected(i) || escapedAt(text, i)) continue; + if (isStaticProtected(i) || escapedAt(text, i)) continue; if (text[i] === TICK) { let size = 1; while (text[i + size] === TICK) size++; From 39ff8dc1067f7f2c77ba414698fdac2e75a42398 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 21:50:25 +0800 Subject: [PATCH 10/14] test(typography): protect math in rich text and nested straight quotes --- frontend/src/lib/__tests__/cjkTypography.test.ts | 9 +++++++++ frontend/src/lib/cjkTypography.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/__tests__/cjkTypography.test.ts b/frontend/src/lib/__tests__/cjkTypography.test.ts index d4e3de870..51d1d80a5 100644 --- a/frontend/src/lib/__tests__/cjkTypography.test.ts +++ b/frontend/src/lib/__tests__/cjkTypography.test.ts @@ -64,9 +64,18 @@ describe("#822 manual Chinese typography", () => { 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")) diff --git a/frontend/src/lib/cjkTypography.ts b/frontend/src/lib/cjkTypography.ts index 0920ec85f..a6a462148 100644 --- a/frontend/src/lib/cjkTypography.ts +++ b/frontend/src/lib/cjkTypography.ts @@ -232,7 +232,7 @@ export function transformCjkTypography( const protectedRanges = format === 'markdown' ? markdownProtectedRanges(text) : []; // In plain-text contexts avoid changing numeric, URLs and emails too. if (format === 'plain') { - const re = /(?:https?:\/\/|www\.)[^\s<>()\[\]"']+|[^\s<>()\[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b|\.{3,}/gi; + const re = /(?:https?:\/\/|www\.)[^\s<>()\[\]"']+|[^\s<>()\[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b|\.{3,}|\\\([^\n]*?\\\)|\\\[[^\n]*?\\\]|\$\$[^$]*\$\$|\$[^$\n]+\$/gi; for (const m of text.matchAll(re)) protectedRanges.push({ from: m.index, to: m.index + m[0].length }); } let result = ''; From 481331baf77b35bd0778bc23a71154bd1b8847ec Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 23:03:58 +0800 Subject: [PATCH 11/14] fix(cjk): clear five new eslint violations without changing matching semantics --- frontend/src/lib/cjkTypography.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/cjkTypography.ts b/frontend/src/lib/cjkTypography.ts index a6a462148..49cb78216 100644 --- a/frontend/src/lib/cjkTypography.ts +++ b/frontend/src/lib/cjkTypography.ts @@ -8,7 +8,6 @@ export type CjkTypographyFormat = 'plain' | 'markdown'; export interface TextRange { from: number; to: number } const HAN = /\p{Script=Han}/u; -const LATIN_OR_DIGIT = /[A-Za-z0-9]/; const TICK = String.fromCharCode(96); function mergeRanges(input: TextRange[]): TextRange[] { @@ -94,8 +93,8 @@ export function markdownProtectedRanges(text: string): TextRange[] { // URLs, emails, HTML tags and numeric tokens protected before punctuation. addMatches(/<\/?[A-Za-z][^>\n]*>/g); - addMatches(/(?:https?:\/\/|www\.)[^\s<>()\[\]"']+/gi); - addMatches(/[^\s<>()\[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g); + addMatches(/(?:https?:\/\/|www\.)[^\s<>()[\]"']+/gi); + addMatches(/[^\s<>()[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g); addMatches(/(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?:\/[^\s]*)?/g); addMatches(/\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b/g); addMatches(/\.{3,}/g); @@ -232,7 +231,7 @@ export function transformCjkTypography( const protectedRanges = format === 'markdown' ? markdownProtectedRanges(text) : []; // In plain-text contexts avoid changing numeric, URLs and emails too. if (format === 'plain') { - const re = /(?:https?:\/\/|www\.)[^\s<>()\[\]"']+|[^\s<>()\[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b|\.{3,}|\\\([^\n]*?\\\)|\\\[[^\n]*?\\\]|\$\$[^$]*\$\$|\$[^$\n]+\$/gi; + const re = /(?:https?:\/\/|www\.)[^\s<>()[\]"']+|[^\s<>()[\]"'@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b[vV]?\d+(?:[.,:]\d+)+(?:-[A-Za-z0-9]+)?\b|\.{3,}|\\\([^\n]*?\\\)|\\\[[^\n]*?\\\]|\$\$[^$]*\$\$|\$[^$\n]+\$/gi; for (const m of text.matchAll(re)) protectedRanges.push({ from: m.index, to: m.index + m[0].length }); } let result = ''; From c13139be35779371c27102d985b78d96944d7f71 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 23:05:18 +0800 Subject: [PATCH 12/14] test(attachments): assert unsigned URLs stay blocked until signed mapping --- .../__tests__/useAttachmentImageRenderSource.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/__tests__/useAttachmentImageRenderSource.test.tsx b/frontend/src/hooks/__tests__/useAttachmentImageRenderSource.test.tsx index 320e61d28..df15acf68 100644 --- a/frontend/src/hooks/__tests__/useAttachmentImageRenderSource.test.tsx +++ b/frontend/src/hooks/__tests__/useAttachmentImageRenderSource.test.tsx @@ -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 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( From be3d4cbb15dd06169c4a213c9aa8dd72105137d8 Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 23:05:21 +0800 Subject: [PATCH 13/14] test(markdown): await signed image preview and note scope updates --- .../src/lib/__tests__/markdownLivePreview.test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/__tests__/markdownLivePreview.test.tsx b/frontend/src/lib/__tests__/markdownLivePreview.test.tsx index 6e0df0b1f..cb4245246 100644 --- a/frontend/src/lib/__tests__/markdownLivePreview.test.tsx +++ b/frontend/src/lib/__tests__/markdownLivePreview.test.tsx @@ -67,14 +67,16 @@ describe("markdownLivePreviewExtension", () => { extensions: [markdown(), scope.of(markdownLivePreviewNoteId.of("note-a")), markdownLivePreviewExtension], }) }); try { - await flushPreview(); - expect(parent.querySelector("img")?.getAttribute("src")).toContain("sig=note-a"); + await vi.waitFor(() => { + // CodeMirror widgets mount their React subtree asynchronously. + expect(parent.querySelector("img")?.getAttribute("src")).toContain("sig=note-a"); + }, { timeout: 2500 }); view.dispatch({ effects: scope.reconfigure(markdownLivePreviewNoteId.of("note-b")) }); - await flushPreview(); resetAttachmentAccessStateForTests(); - await flushPreview(); - expect(fetch.mock.calls.some(([url]) => String(url).includes("noteId=note-b"))).toBe(true); - expect(parent.querySelector("img")?.getAttribute("src")).toContain("sig=note-b"); + await vi.waitFor(() => { + expect(fetch.mock.calls.some(([url]) => String(url).includes("noteId=note-b"))).toBe(true); + expect(parent.querySelector("img")?.getAttribute("src")).toContain("sig=note-b"); + }, { timeout: 2500 }); expect(view.state.doc.toString()).toBe(doc); } finally { view.destroy(); } }); From 57f8d14f4b18b3e0cc217a4fbf27944c8e1a10cc Mon Sep 17 00:00:00 2001 From: Drug Date: Sat, 10 Oct 2026 23:09:47 +0800 Subject: [PATCH 14/14] ci(cjk): enforce lint for new typography implementation modules --- .github/workflows/cjk-typography-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/cjk-typography-ci.yml b/.github/workflows/cjk-typography-ci.yml index c6fc4fc9e..e4c9fd67b 100644 --- a/.github/workflows/cjk-typography-ci.yml +++ b/.github/workflows/cjk-typography-ci.yml @@ -38,5 +38,7 @@ jobs: - 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