From a08c0d8448999ed054563f415d5a7578da8cd291 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Mon, 17 Nov 2025 22:11:16 +0800 Subject: [PATCH] :art: Modify code block logic --- .../editor/extensions/codeblock/annotation.ts | 53 +++++++++++ .../editor/extensions/codeblock/commands.ts | 93 +++++++++++-------- .../editor/extensions/codeblock/copyPaste.ts | 9 +- .../extensions/codeblock/cursorProtection.ts | 4 +- .../extensions/codeblock/decorations.ts | 17 ++-- .../editor/extensions/codeblock/deleteLine.ts | 7 +- .../editor/extensions/codeblock/formatCode.ts | 5 +- .../editor/extensions/codeblock/mathBlock.ts | 14 ++- .../editor/extensions/codeblock/moveLines.ts | 5 +- .../editor/extensions/codeblock/selectAll.ts | 9 +- .../extensions/codeblock/transposeChars.ts | 5 +- 11 files changed, 156 insertions(+), 65 deletions(-) create mode 100644 frontend/src/views/editor/extensions/codeblock/annotation.ts diff --git a/frontend/src/views/editor/extensions/codeblock/annotation.ts b/frontend/src/views/editor/extensions/codeblock/annotation.ts new file mode 100644 index 0000000..7d937fa --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/annotation.ts @@ -0,0 +1,53 @@ +import { Annotation, Transaction } from "@codemirror/state"; + +/** + * 统一的 CodeBlock 注解,用于标记内部触发的事务。 + */ +export const codeBlockEvent = Annotation.define(); + +export const LANGUAGE_CHANGE = "codeblock-language-change"; +export const ADD_NEW_BLOCK = "codeblock-add-new-block"; +export const MOVE_BLOCK = "codeblock-move-block"; +export const DELETE_BLOCK = "codeblock-delete-block"; +export const CURRENCIES_LOADED = "codeblock-currencies-loaded"; + +/** + * 统一管理的 userEvent 常量,方便复用与检索。 + */ +export const USER_EVENTS = { + INPUT: "input", + DELETE: "delete", + MOVE: "move", + SELECT: "select", + DELETE_LINE: "delete.line", + DELETE_CUT: "delete.cut", + INPUT_PASTE: "input.paste", + MOVE_LINE: "move.line", + MOVE_CHARACTER: "move.character", + SELECT_BLOCK_BOUNDARY: "select.block-boundary", +} as const; + +/** + * 判断事务列表中是否包含指定注解。 + */ +export function transactionsHasAnnotation( + transactions: readonly Transaction[], + annotation: string +) { + return transactions.some( + tr => tr.annotation(codeBlockEvent) === annotation + ); +} + +/** + * 判断事务列表中是否包含任一注解。 + */ +export function transactionsHasAnnotationsAny( + transactions: readonly Transaction[], + annotations: readonly string[] +) { + return transactions.some(tr => { + const value = tr.annotation(codeBlockEvent); + return value ? annotations.includes(value) : false; + }); +} diff --git a/frontend/src/views/editor/extensions/codeblock/commands.ts b/frontend/src/views/editor/extensions/codeblock/commands.ts index 0bcdf89..2bbf0b4 100644 --- a/frontend/src/views/editor/extensions/codeblock/commands.ts +++ b/frontend/src/views/editor/extensions/codeblock/commands.ts @@ -2,11 +2,12 @@ * Block 命令 */ -import { EditorSelection } from "@codemirror/state"; +import { EditorSelection, Transaction } from "@codemirror/state"; import { Command } from "@codemirror/view"; import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state"; import { Block, EditorOptions, DELIMITER_REGEX } from "./types"; import { formatBlockContent } from "./formatCode"; +import { codeBlockEvent, LANGUAGE_CHANGE, ADD_NEW_BLOCK, MOVE_BLOCK, DELETE_BLOCK, CURRENCIES_LOADED, USER_EVENTS } from "./annotation"; /** * 获取块分隔符 @@ -32,7 +33,7 @@ export const insertNewBlockAtCursor = (options: EditorOptions): Command => ({ st dispatch(state.replaceSelection(delimText), { scrollIntoView: true, - userEvent: "input", + userEvent: USER_EVENTS.INPUT, }); return true; @@ -49,15 +50,16 @@ export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({ const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect); - dispatch(state.update({ + dispatch(state.update({ changes: { from: block.delimiter.from, insert: delimText, }, selection: EditorSelection.cursor(block.delimiter.from + delimText.length), + annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)], }, { scrollIntoView: true, - userEvent: "input", + userEvent: USER_EVENTS.INPUT, })); return true; @@ -74,15 +76,16 @@ export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ s const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect); - dispatch(state.update({ + dispatch(state.update({ changes: { from: block.content.to, insert: delimText, }, - selection: EditorSelection.cursor(block.content.to + delimText.length) + selection: EditorSelection.cursor(block.content.to + delimText.length), + annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)], }, { scrollIntoView: true, - userEvent: "input", + userEvent: USER_EVENTS.INPUT, })); return true; @@ -99,15 +102,16 @@ export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ st const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect); - dispatch(state.update({ + dispatch(state.update({ changes: { from: block.delimiter.from, insert: delimText, }, selection: EditorSelection.cursor(delimText.length), + annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)], }, { scrollIntoView: true, - userEvent: "input", + userEvent: USER_EVENTS.INPUT, })); return true; @@ -124,15 +128,16 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect); - dispatch(state.update({ + dispatch(state.update({ changes: { from: block.content.to, insert: delimText, }, - selection: EditorSelection.cursor(block.content.to + delimText.length) + selection: EditorSelection.cursor(block.content.to + delimText.length), + annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)], }, { scrollIntoView: true, - userEvent: "input", + userEvent: USER_EVENTS.INPUT, })); return true; @@ -143,26 +148,19 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat */ export function changeLanguageTo(state: any, dispatch: any, block: Block, language: string, auto: boolean) { if (state.readOnly) return false; - - const currentDelimiter = state.doc.sliceString(block.delimiter.from, block.delimiter.to); - - // 重置正则表达式的 lastIndex - DELIMITER_REGEX.lastIndex = 0; - if (currentDelimiter.match(DELIMITER_REGEX)) { - const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`; - - dispatch({ - changes: { - from: block.delimiter.from, - to: block.delimiter.to, - insert: newDelimiter, - }, - }); - - return true; - } else { - return false; - } + + const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`; + + dispatch({ + changes: { + from: block.delimiter.from, + to: block.delimiter.to, + insert: newDelimiter, + }, + annotations: [codeBlockEvent.of(LANGUAGE_CHANGE)], + }); + + return true; } /** @@ -189,7 +187,7 @@ function updateSel(sel: EditorSelection, by: (range: any) => any): EditorSelecti } function setSel(state: any, selection: EditorSelection) { - return state.update({ selection, scrollIntoView: true, userEvent: "select" }); + return state.update({ selection, scrollIntoView: true, userEvent: USER_EVENTS.SELECT }); } function extendSel(state: any, dispatch: any, how: (range: any) => any) { @@ -303,10 +301,11 @@ export const deleteBlock = (_options: EditorOptions): Command => ({ state, dispa to: block.range.to, insert: "" }, - selection: EditorSelection.cursor(newCursorPos) + selection: EditorSelection.cursor(newCursorPos), + annotations: [codeBlockEvent.of(DELETE_BLOCK)] }, { scrollIntoView: true, - userEvent: "delete" + userEvent: USER_EVENTS.DELETE })); return true; @@ -366,10 +365,11 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) { dispatch(state.update({ changes, - selection: EditorSelection.cursor(newCursorPos) + selection: EditorSelection.cursor(newCursorPos), + annotations: [codeBlockEvent.of(MOVE_BLOCK)] }, { scrollIntoView: true, - userEvent: "move" + userEvent: USER_EVENTS.MOVE })); return true; @@ -380,4 +380,21 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) { */ export const formatCurrentBlock: Command = (view) => { return formatBlockContent(view); -}; \ No newline at end of file +}; + +/** + * 触发一次货币数据刷新,让数学块重新计算 + */ +export function triggerCurrenciesLoaded({ state, dispatch }: { state: any; dispatch: any }) { + if (!dispatch || state.readOnly) { + return false; + } + dispatch(state.update({ + changes: { from: 0, to: 0, insert: "" }, + annotations: [ + codeBlockEvent.of(CURRENCIES_LOADED), + Transaction.addToHistory.of(false) + ], + })); + return true; +} diff --git a/frontend/src/views/editor/extensions/codeblock/copyPaste.ts b/frontend/src/views/editor/extensions/codeblock/copyPaste.ts index 3f7473e..c5088b3 100644 --- a/frontend/src/views/editor/extensions/codeblock/copyPaste.ts +++ b/frontend/src/views/editor/extensions/codeblock/copyPaste.ts @@ -7,6 +7,7 @@ import { EditorState, EditorSelection } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { Command } from "@codemirror/view"; import { LANGUAGES } from "./lang-parser/languages"; +import { USER_EVENTS } from "./annotation"; /** * 构建块分隔符正则表达式 @@ -89,7 +90,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({ view.dispatch({ changes: ranges, scrollIntoView: true, - userEvent: "delete.cut" + userEvent: USER_EVENTS.DELETE_CUT }); } } @@ -111,7 +112,7 @@ const copyCut = (view: EditorView, cut: boolean): boolean => { view.dispatch({ changes: ranges, scrollIntoView: true, - userEvent: "delete.cut" + userEvent: USER_EVENTS.DELETE_CUT }); } @@ -142,7 +143,7 @@ function doPaste(view: EditorView, input: string) { } view.dispatch(changes, { - userEvent: "input.paste", + userEvent: USER_EVENTS.INPUT_PASTE, scrollIntoView: true }); } @@ -186,4 +187,4 @@ export function getCopyPasteExtensions() { return [ codeBlockCopyCut, ]; -} \ No newline at end of file +} diff --git a/frontend/src/views/editor/extensions/codeblock/cursorProtection.ts b/frontend/src/views/editor/extensions/codeblock/cursorProtection.ts index 14181f3..3c22cb5 100644 --- a/frontend/src/views/editor/extensions/codeblock/cursorProtection.ts +++ b/frontend/src/views/editor/extensions/codeblock/cursorProtection.ts @@ -7,6 +7,7 @@ import { EditorView } from '@codemirror/view'; import { EditorSelection } from '@codemirror/state'; import { blockState } from './state'; import { Block } from './types'; +import { USER_EVENTS } from './annotation'; /** * 二分查找:找到包含指定位置的块 @@ -136,7 +137,7 @@ export function createCursorProtection() { view.dispatch({ selection: EditorSelection.cursor(adjustedPos), scrollIntoView: true, - userEvent: 'select' + userEvent: USER_EVENTS.SELECT }); // 阻止默认行为 @@ -148,4 +149,3 @@ export function createCursorProtection() { } }); } - diff --git a/frontend/src/views/editor/extensions/codeblock/decorations.ts b/frontend/src/views/editor/extensions/codeblock/decorations.ts index 0e9dfff..9d84d28 100644 --- a/frontend/src/views/editor/extensions/codeblock/decorations.ts +++ b/frontend/src/views/editor/extensions/codeblock/decorations.ts @@ -5,6 +5,7 @@ import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view"; import { StateField, RangeSetBuilder, EditorState } from "@codemirror/state"; import { blockState } from "./state"; +import { codeBlockEvent } from "./annotation"; /** * 块开始装饰组件 @@ -180,10 +181,11 @@ const blockLayer = layer({ */ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => { const protect: number[] = []; + const internalEvent = tr.annotation(codeBlockEvent); // 获取块状态并获取第一个块的分隔符大小 const blocks = tr.startState.field(blockState); - if (blocks && blocks.length > 0) { + if (!internalEvent && blocks && blocks.length > 0) { const firstBlock = blocks[0]; const firstBlockDelimiterSize = firstBlock.delimiter.to; @@ -195,22 +197,25 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) // 如果是搜索替换操作,保护所有块分隔符 if (tr.annotations.some((a: any) => a.value === "input.replace" || a.value === "input.replace.all")) { - blocks.forEach((block: any) => { + blocks?.forEach((block: any) => { if (block.delimiter) { protect.push(block.delimiter.from, block.delimiter.to); } }); } - // 返回保护范围数组,如果没有需要保护的范围则返回 false - return protect.length > 0 ? protect : false; -}); + // 返回保护范围数组;若无需保护则返回 true 放行事务 + return protect.length > 0 ? protect : true; +}) /** * 防止选择在第一个块之前 * 使用 transactionFilter 来确保选择不会在第一个块之前 */ const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => { + if (tr.annotation(codeBlockEvent)) { + return tr; + } // 获取块状态并获取第一个块的分隔符大小 const blocks = tr.startState.field(blockState); if (!blocks || blocks.length === 0) { @@ -261,4 +266,4 @@ export function getBlockDecorationExtensions(options: { } return extensions; -} \ No newline at end of file +} diff --git a/frontend/src/views/editor/extensions/codeblock/deleteLine.ts b/frontend/src/views/editor/extensions/codeblock/deleteLine.ts index 0d80921..45bf059 100644 --- a/frontend/src/views/editor/extensions/codeblock/deleteLine.ts +++ b/frontend/src/views/editor/extensions/codeblock/deleteLine.ts @@ -6,6 +6,7 @@ import { EditorSelection, SelectionRange } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { getNoteBlockFromPos } from "./state"; +import { USER_EVENTS } from "./annotation"; interface LineBlock { from: number; @@ -87,7 +88,7 @@ export const deleteLine = (view: EditorView): boolean => { changes, selection, scrollIntoView: true, - userEvent: "delete.line" + userEvent: USER_EVENTS.DELETE_LINE }); return true; @@ -127,8 +128,8 @@ export const deleteLineCommand = ({ state, dispatch }: { state: any; dispatch: a changes, selection, scrollIntoView: true, - userEvent: "delete.line" + userEvent: USER_EVENTS.DELETE_LINE })); return true; -}; \ No newline at end of file +}; diff --git a/frontend/src/views/editor/extensions/codeblock/formatCode.ts b/frontend/src/views/editor/extensions/codeblock/formatCode.ts index ad31b8f..58487b4 100644 --- a/frontend/src/views/editor/extensions/codeblock/formatCode.ts +++ b/frontend/src/views/editor/extensions/codeblock/formatCode.ts @@ -4,6 +4,7 @@ import * as prettier from "prettier/standalone"; import { getActiveNoteBlock } from "./state"; import { getLanguage } from "./lang-parser/languages"; import { SupportedLanguage } from "./types"; +import { USER_EVENTS } from "./annotation"; export const formatBlockContent = (view) => { if (!view || view.state.readOnly) @@ -87,7 +88,7 @@ export const formatBlockContent = (view) => { }, selection: EditorSelection.cursor(currentBlockFrom + Math.min(formattedContent.cursorOffset, formattedContent.formatted.length)), scrollIntoView: true, - userEvent: "input" + userEvent: USER_EVENTS.INPUT }); return true; @@ -100,4 +101,4 @@ export const formatBlockContent = (view) => { // 执行异步格式化 performFormat(); return true; // 立即返回 true,表示命令已开始执行 -}; \ No newline at end of file +}; diff --git a/frontend/src/views/editor/extensions/codeblock/mathBlock.ts b/frontend/src/views/editor/extensions/codeblock/mathBlock.ts index d87ceb6..ee6440a 100644 --- a/frontend/src/views/editor/extensions/codeblock/mathBlock.ts +++ b/frontend/src/views/editor/extensions/codeblock/mathBlock.ts @@ -6,6 +6,7 @@ import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; import { RangeSetBuilder } from "@codemirror/state"; import { getNoteBlockFromPos } from "./state"; +import { transactionsHasAnnotation, CURRENCIES_LOADED } from "./annotation"; // 声明全局math对象 declare global { interface Window { @@ -75,6 +76,11 @@ function mathDeco(view: any): any { // get math.js parser and cache it for this block let { parser, prev } = mathParsers.get(block) || {}; if (!parser) { + // 如果当前可见行不是该 math 块的第一行,为了正确累计 prev,需要从块头开始重新扫描 + if (line.from > block.content.from) { + pos = block.content.from; + continue; + } if (typeof window.math !== 'undefined') { parser = window.math.parser(); mathParsers.set(block, { parser, prev }); @@ -148,8 +154,12 @@ export const mathBlock = ViewPlugin.fromClass(class { } update(update: any) { - // If the document changed, the viewport changed, update the decorations - if (update.docChanged || update.viewportChanged) { + // 需要在文档/视口变化或收到 CURRENCIES_LOADED 注解时重新渲染 + if ( + update.docChanged || + update.viewportChanged || + transactionsHasAnnotation(update.transactions, CURRENCIES_LOADED) + ) { this.decorations = mathDeco(update.view); } } diff --git a/frontend/src/views/editor/extensions/codeblock/moveLines.ts b/frontend/src/views/editor/extensions/codeblock/moveLines.ts index 0cf5161..6090e10 100644 --- a/frontend/src/views/editor/extensions/codeblock/moveLines.ts +++ b/frontend/src/views/editor/extensions/codeblock/moveLines.ts @@ -6,6 +6,7 @@ import { EditorSelection, SelectionRange } from "@codemirror/state"; import { blockState } from "./state"; import { LANGUAGES } from "./lang-parser/languages"; +import { USER_EVENTS } from "./annotation"; interface LineBlock { from: number; @@ -131,7 +132,7 @@ function moveLine(state: any, dispatch: any, forward: boolean): boolean { changes, scrollIntoView: true, selection: EditorSelection.create(ranges, state.selection.mainIndex), - userEvent: "move.line" + userEvent: USER_EVENTS.MOVE_LINE })); return true; @@ -157,4 +158,4 @@ export const moveLineUp = ({ state, dispatch }: { state: any; dispatch: any }): */ export const moveLineDown = ({ state, dispatch }: { state: any; dispatch: any }): boolean => { return moveLine(state, dispatch, true); -}; \ No newline at end of file +}; diff --git a/frontend/src/views/editor/extensions/codeblock/selectAll.ts b/frontend/src/views/editor/extensions/codeblock/selectAll.ts index 0a413c3..b913db9 100644 --- a/frontend/src/views/editor/extensions/codeblock/selectAll.ts +++ b/frontend/src/views/editor/extensions/codeblock/selectAll.ts @@ -7,6 +7,7 @@ import { StateField, StateEffect, RangeSetBuilder, EditorSelection, EditorState, import { selectAll as defaultSelectAll } from "@codemirror/commands"; import { Command } from "@codemirror/view"; import { getActiveNoteBlock, blockState } from "./state"; +import { USER_EVENTS } from "./annotation"; /** * 当用户按下 Ctrl+A 时,我们希望首先选择整个块。但如果整个块已经被选中, @@ -115,7 +116,7 @@ export const selectAll: Command = ({ state, dispatch }) => { // 选择当前块的所有内容 dispatch(state.update({ selection: { anchor: block.content.from, head: block.content.to }, - userEvent: "select" + userEvent: USER_EVENTS.SELECT })); return true; @@ -127,7 +128,7 @@ export const selectAll: Command = ({ state, dispatch }) => { */ export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) => { // 只处理选择变化的事务,并且忽略我们自己生成的事务 - if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === "select.block-boundary") { + if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === USER_EVENTS.SELECT_BLOCK_BOUNDARY) { return tr; } @@ -181,7 +182,7 @@ export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) => return { ...tr, selection: EditorSelection.create(correctedRanges, tr.selection.mainIndex), - annotations: tr.annotations.concat(Transaction.userEvent.of("select.block-boundary")) + annotations: tr.annotations.concat(Transaction.userEvent.of(USER_EVENTS.SELECT_BLOCK_BOUNDARY)) }; } } catch (error) { @@ -219,4 +220,4 @@ export function getBlockSelectExtensions() { return [ emptyBlockSelected, ]; -} \ No newline at end of file +} diff --git a/frontend/src/views/editor/extensions/codeblock/transposeChars.ts b/frontend/src/views/editor/extensions/codeblock/transposeChars.ts index 4a69c55..ffa0f35 100644 --- a/frontend/src/views/editor/extensions/codeblock/transposeChars.ts +++ b/frontend/src/views/editor/extensions/codeblock/transposeChars.ts @@ -5,6 +5,7 @@ import { EditorSelection, findClusterBreak } from "@codemirror/state"; import { getNoteBlockFromPos } from "./state"; +import { USER_EVENTS } from "./annotation"; /** * 交换光标前后的字符 @@ -46,8 +47,8 @@ export const transposeChars = ({ state, dispatch }: { state: any; dispatch: any dispatch(state.update(changes, { scrollIntoView: true, - userEvent: "move.character" + userEvent: USER_EVENTS.MOVE_CHARACTER })); return true; -}; \ No newline at end of file +};