diff --git a/frontend/src/views/editor/extensions/codeblock/decorations.ts b/frontend/src/views/editor/extensions/codeblock/decorations.ts index 6d50a78..b3679f3 100644 --- a/frontend/src/views/editor/extensions/codeblock/decorations.ts +++ b/frontend/src/views/editor/extensions/codeblock/decorations.ts @@ -3,7 +3,7 @@ */ import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view"; -import { StateField, RangeSetBuilder } from "@codemirror/state"; +import { StateField, RangeSetBuilder, EditorState } from "@codemirror/state"; import { blockState } from "./state"; /** @@ -179,16 +179,65 @@ const blockLayer = layer({ /** * 防止第一个块被删除 + * 使用 changeFilter 来保护第一个块分隔符不被删除 */ -const preventFirstBlockFromBeingDeleted = EditorView.updateListener.of((update: any) => { - // 暂时简化实现,后续可以完善 +const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => { + const protect: number[] = []; + + // 获取块状态 + const blocks = tr.startState.field(blockState); + if (blocks && blocks.length > 0) { + const firstBlock = blocks[0]; + // 保护第一个块的分隔符区域 + if (firstBlock && firstBlock.delimiter) { + protect.push(firstBlock.delimiter.from, firstBlock.delimiter.to); + } + } + + // 如果是搜索替换操作,保护所有块分隔符 + if (tr.annotations.some((a: any) => a.value === "input.replace" || a.value === "input.replace.all")) { + blocks.forEach((block: any) => { + if (block.delimiter) { + protect.push(block.delimiter.from, block.delimiter.to); + } + }); + } + + // 返回保护范围数组,如果没有需要保护的范围则返回 false + return protect.length > 0 ? protect : false; }); /** * 防止选择在第一个块之前 + * 使用 transactionFilter 来确保选择不会在第一个块之前 */ -const preventSelectionBeforeFirstBlock = EditorView.updateListener.of((update: any) => { - // 暂时简化实现,后续可以完善 +const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => { + // 获取块状态 + const blocks = tr.startState.field(blockState); + if (!blocks || blocks.length === 0) { + return tr; + } + + const firstBlock = blocks[0]; + if (!firstBlock || !firstBlock.delimiter) { + return tr; + } + + const firstBlockDelimiterSize = firstBlock.delimiter.to; + + // 检查选择范围,如果在第一个块之前,则调整到第一个块的内容开始位置 + if (tr.selection) { + tr.selection.ranges.forEach((range: any) => { + if (range && range.from < firstBlockDelimiterSize) { + range.from = firstBlockDelimiterSize; + } + if (range && range.to < firstBlockDelimiterSize) { + range.to = firstBlockDelimiterSize; + } + }); + } + + return tr; }); /** diff --git a/frontend/src/views/editor/extensions/codeblock/deleteLine.ts b/frontend/src/views/editor/extensions/codeblock/deleteLine.ts new file mode 100644 index 0000000..9330a0d --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/deleteLine.ts @@ -0,0 +1,134 @@ +/** + * 删除行功能 + * 处理代码块边界 + */ + +import { EditorSelection, SelectionRange } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { getNoteBlockFromPos } from "./state"; + +interface LineBlock { + from: number; + to: number; + ranges: SelectionRange[]; +} + +/** + * 更新选择范围 + */ +function updateSel(sel: EditorSelection, by: (range: SelectionRange) => SelectionRange): EditorSelection { + return EditorSelection.create(sel.ranges.map(by), sel.mainIndex); +} + +/** + * 获取选中的行块 + */ +function selectedLineBlocks(state: any): LineBlock[] { + let blocks: LineBlock[] = []; + let upto = -1; + + for (let range of state.selection.ranges) { + let startLine = state.doc.lineAt(range.from); + let endLine = state.doc.lineAt(range.to); + + if (!range.empty && range.to == endLine.from) { + endLine = state.doc.lineAt(range.to - 1); + } + + if (upto >= startLine.number) { + let prev = blocks[blocks.length - 1]; + prev.to = endLine.to; + prev.ranges.push(range); + } else { + blocks.push({ + from: startLine.from, + to: endLine.to, + ranges: [range] + }); + } + upto = endLine.number + 1; + } + + return blocks; +} + +/** + * 删除行命令 + */ +export const deleteLine = (view: EditorView): boolean => { + if (view.state.readOnly) { + return false; + } + + const { state } = view; + const selectedLines = selectedLineBlocks(state); + + const changes = state.changes(selectedLines.map(({ from, to }) => { + const block = getNoteBlockFromPos(state, from); + + // 如果不是删除整个代码块,需要调整删除范围 + if (block && (from !== block.content.from || to !== block.content.to)) { + if (from > 0) { + from--; + } else if (to < state.doc.length) { + to++; + } + } + + return { from, to }; + })); + + const selection = updateSel( + state.selection, + range => view.moveVertically(range, true) + ).map(changes); + + view.dispatch({ + changes, + selection, + scrollIntoView: true, + userEvent: "delete.line" + }); + + return true; +}; + +/** + * 删除行命令函数,用于键盘映射 + */ +export const deleteLineCommand = ({ state, dispatch }: { state: any; dispatch: any }) => { + if (state.readOnly) { + return false; + } + + const selectedLines = selectedLineBlocks(state); + + const changes = state.changes(selectedLines.map(({ from, to }: LineBlock) => { + const block = getNoteBlockFromPos(state, from); + + // 如果不是删除整个代码块,需要调整删除范围 + if (block && (from !== block.content.from || to !== block.content.to)) { + if (from > 0) { + from--; + } else if (to < state.doc.length) { + to++; + } + } + + return { from, to }; + })); + + const selection = updateSel( + state.selection, + range => EditorSelection.cursor(range.from) + ).map(changes); + + dispatch(state.update({ + changes, + selection, + scrollIntoView: true, + userEvent: "delete.line" + })); + + return true; +}; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/index.ts b/frontend/src/views/editor/extensions/codeblock/index.ts index ba17130..4286973 100644 --- a/frontend/src/views/editor/extensions/codeblock/index.ts +++ b/frontend/src/views/editor/extensions/codeblock/index.ts @@ -1,9 +1,19 @@ /** * CodeBlock 扩展主入口 + * + * 配置说明: + * - showBackground: 控制是否显示代码块的背景色区分 + * - enableAutoDetection: 控制是否启用内容的语言自动检测功能 + * - defaultLanguage: 新建代码块时使用的默认语言(也是自动检测的回退语言) + * - defaultAutoDetect: 新建代码块时是否默认添加-a标记启用自动检测 + * + * 注意:defaultLanguage 和 defaultAutoDetect 是配合使用的: + * - 如果 defaultAutoDetect=true,新建块会是 ∞∞∞javascript-a(会根据内容自动检测语言) + * - 如果 defaultAutoDetect=false,新建块会是 ∞∞∞javascript(固定使用指定语言) */ import {Extension} from '@codemirror/state'; -import {EditorView, keymap} from '@codemirror/view'; +import {keymap} from '@codemirror/view'; // 导入核心模块 import {blockState} from './state'; @@ -11,6 +21,11 @@ import {getBlockDecorationExtensions} from './decorations'; import * as commands from './commands'; import {selectAll, getBlockSelectExtensions} from './selectAll'; import {getCopyPasteExtensions, getCopyPasteKeymap} from './copyPaste'; +import {deleteLineCommand} from './deleteLine'; +import {moveLineUp, moveLineDown} from './moveLines'; +import {transposeChars} from './transposeChars'; +import {getCodeBlockLanguageExtension} from './lang-parser'; +import {createLanguageDetection} from './language-detection'; import {EditorOptions, SupportedLanguage} from './types'; import {lineNumbers} from '@codemirror/view'; import './styles.css' @@ -19,16 +34,17 @@ import './styles.css' * 代码块扩展配置选项 */ export interface CodeBlockOptions { - // 视觉选项 + /** 是否显示块背景色 */ showBackground?: boolean; - // 功能选项 + /** 是否启用语言自动检测功能 */ enableAutoDetection?: boolean; + + /** 新建块时的默认语言 */ defaultLanguage?: SupportedLanguage; - - // 编辑器选项 - defaultBlockToken?: string; - defaultBlockAutoDetect?: boolean; + + /** 新建块时是否默认启用自动检测(添加-a标记) */ + defaultAutoDetect?: boolean; } /** @@ -75,8 +91,6 @@ const blockLineNumbers = lineNumbers({ } }); - - /** * 创建代码块扩展 */ @@ -85,13 +99,13 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens showBackground = true, enableAutoDetection = true, defaultLanguage = 'text', - defaultBlockToken = 'text', - defaultBlockAutoDetect = false, + defaultAutoDetect = true, } = options; + // 将简化的配置转换为内部使用的EditorOptions const editorOptions: EditorOptions = { - defaultBlockToken, - defaultBlockAutoDetect, + defaultBlockToken: defaultLanguage, + defaultBlockAutoDetect: defaultAutoDetect, }; const extensions: Extension[] = [ @@ -101,6 +115,16 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens // 块内行号 blockLineNumbers, + // 语言解析支持 + ...getCodeBlockLanguageExtension(), + + // 语言自动检测(如果启用) + ...(enableAutoDetection ? [createLanguageDetection({ + defaultLanguage: defaultLanguage, + confidenceThreshold: 0.3, + minContentLength: 8, + })] : []), + // 视觉装饰系统 ...getBlockDecorationExtensions({ showBackground @@ -112,19 +136,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens // 复制粘贴功能 ...getCopyPasteExtensions(), - // 主题样式 - EditorView.theme({ - '&': { - fontSize: '14px' - }, - '.cm-content': { - fontFamily: 'Monaco, Menlo, "Ubuntu Mono", consolas, monospace' - }, - '.cm-focused': { - outline: 'none' - } - }), - // 键盘映射 keymap.of([ // 复制粘贴命令 @@ -191,6 +202,28 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens run: commands.moveCurrentBlockDown, preventDefault: true }, + + // 行编辑命令 + { + key: 'Mod-Shift-k', // 删除行 + run: deleteLineCommand, + preventDefault: true + }, + { + key: 'Alt-ArrowUp', // 向上移动行 + run: moveLineUp, + preventDefault: true + }, + { + key: 'Alt-ArrowDown', // 向下移动行 + run: moveLineDown, + preventDefault: true + }, + { + key: 'Ctrl-t', // 字符转置 + run: transposeChars, + preventDefault: true + }, ]) ]; @@ -241,6 +274,44 @@ export { getCopyPasteKeymap } from './copyPaste'; +// 删除行功能 +export { + deleteLine, + deleteLineCommand +} from './deleteLine'; + +// 移动行功能 +export { + moveLineUp, + moveLineDown +} from './moveLines'; + +// 字符转置功能 +export { + transposeChars +} from './transposeChars'; + +// 语言解析器 +export { + getCodeBlockLanguageExtension, + getLanguage, + getLanguageTokens, + languageMapping, + LanguageInfo, + LANGUAGES as PARSER_LANGUAGES +} from './lang-parser'; + +// 语言检测 +export { + createLanguageDetection, + detectLanguage, + detectLanguages, + detectLanguageHeuristic, + getSupportedDetectionLanguages, + levenshteinDistance, + type LanguageDetectionResult +} from './language-detection'; + // 行号相关 export { getBlockLineFromPos, blockLineNumbers }; diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/build-parser.js b/frontend/src/views/editor/extensions/codeblock/lang-parser/build-parser.js new file mode 100644 index 0000000..6828daf --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/build-parser.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +/** + * 解析器构建脚本 + * 使用 lezer-generator 从语法文件生成解析器 + * 使用:node build-parser.js + */ + +import { execSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +console.log('🚀 start building parser...'); + +try { + // 检查语法文件是否存在 + const grammarFile = path.join(__dirname, 'codeblock.grammar'); + if (!fs.existsSync(grammarFile)) { + throw new Error('grammarFile codeblock.grammar not found'); + } + + console.log('📄 grammar file:', grammarFile); + + // 运行 lezer-generator + console.log('⚙️ building parser...'); + execSync('npx lezer-generator codeblock.grammar -o parser.js', { + cwd: __dirname, + stdio: 'inherit' + }); + + // 检查生成的文件 + const parserFile = path.join(__dirname, 'parser.js'); + const termsFile = path.join(__dirname, 'parser.terms.js'); + + if (fs.existsSync(parserFile) && fs.existsSync(termsFile)) { + console.log('✅ parser file successfully generated!'); + console.log('📦 parser files:'); + console.log(' - parser.js'); + console.log(' - parser.terms.js'); + } else { + throw new Error('failed to generate parser'); + } + + console.log('🎉 build success!'); + +} catch (error) { + console.error('❌ build failed:', error.message); + process.exit(1); +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock-lang.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock-lang.ts new file mode 100644 index 0000000..bbdb552 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock-lang.ts @@ -0,0 +1,65 @@ +/** + * 代码块语言支持 + * 提供多语言代码块支持 + */ + +import { parser } from "./parser.js"; +import { configureNesting } from "./nested-parser"; + +import { + LRLanguage, + LanguageSupport, + foldNodeProp, +} from "@codemirror/language"; +import { styleTags, tags as t } from "@lezer/highlight"; + +import { json } from "@codemirror/lang-json"; + +/** + * 折叠节点函数 + */ +function foldNode(node: any) { + return { from: node.from, to: node.to - 1 }; +} + +/** + * 代码块语言定义 + */ +export const CodeBlockLanguage = LRLanguage.define({ + parser: parser.configure({ + props: [ + styleTags({ + BlockDelimiter: t.tagName, + }), + + foldNodeProp.add({ + BlockContent(node: any) { + return { from: node.from, to: node.to - 1 }; + }, + }), + ], + wrap: configureNesting(), + }), + languageData: { + commentTokens: { line: ";" } + } +}); + +/** + * 创建代码块语言支持 + */ +export function codeBlockLang() { + let wrap = configureNesting(); + let lang = CodeBlockLanguage.configure({ dialect: "", wrap: wrap }); + + return [ + new LanguageSupport(lang, [json().support]), + ]; +} + +/** + * 获取代码块语言扩展 + */ +export function getCodeBlockLanguageExtension() { + return codeBlockLang(); +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock.grammar b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock.grammar new file mode 100644 index 0000000..24d1210 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock.grammar @@ -0,0 +1,23 @@ +@external tokens blockContent from "./external-tokens.js" { + BlockContent +} + +@top Document { Block* } + +Block { + BlockDelimiter BlockContent +} + +BlockDelimiter { + "\n∞∞∞" BlockLanguage Auto? "\n" +} + +BlockLanguage { + "text" | "math" | "json" | "python" | "html" | "sql" | "markdown" | + "java" | "php" | "css" | "xml" | "cpp" | "rust" | "ruby" | "shell" | + "yaml" | "go" | "javascript" | "typescript" +} + +@tokens { + Auto { "-a" } +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/external-tokens.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/external-tokens.ts new file mode 100644 index 0000000..6139216 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/external-tokens.ts @@ -0,0 +1,51 @@ +/** + * 外部标记器 + * 用于识别代码块内容的边界 + */ + +import { ExternalTokenizer } from "@lezer/lr"; +import { BlockContent } from "./parser.terms.js"; +import { LANGUAGES } from "./languages"; + +const EOF = -1; + +const FIRST_TOKEN_CHAR = "\n".charCodeAt(0); +const SECOND_TOKEN_CHAR = "∞".charCodeAt(0); + +// 创建语言标记匹配器 +const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|"); +const tokenRegEx = new RegExp(`^\\n∞∞∞(${languageTokensMatcher})(-a)?\\n`, "g"); + +/** + * 代码块内容标记器 + * 识别 ∞∞∞ 分隔符之间的内容 + */ +export const blockContent = new ExternalTokenizer((input) => { + let current = input.peek(0); + let next = input.peek(1); + + if (current === EOF) { + return; + } + + while (true) { + // 除非前两个字符是换行符和"∞"字符,否则我们没有代码块内容标记 + // 所以我们不需要检查标记的其余部分 + if (current === FIRST_TOKEN_CHAR && next === SECOND_TOKEN_CHAR) { + let potentialLang = ""; + for (let i = 0; i < 18; i++) { + potentialLang += String.fromCharCode(input.peek(i)); + } + if (potentialLang.match(tokenRegEx)) { + input.acceptToken(BlockContent); + return; + } + } + if (next === EOF) { + input.acceptToken(BlockContent, 1); + return; + } + current = input.advance(1); + next = input.peek(1); + } +}); \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/index.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/index.ts new file mode 100644 index 0000000..40a9ba9 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/index.ts @@ -0,0 +1,38 @@ +/** + * 代码块语言解析器入口 + * 导出所有语言解析相关的功能 + */ + +// 主要语言支持 +export { + CodeBlockLanguage, + codeBlockLang, + getCodeBlockLanguageExtension +} from './codeblock-lang'; + +// 语言映射和信息 +export { + LanguageInfo, + LANGUAGES, + languageMapping, + getLanguage, + getLanguageTokens +} from './languages'; + +// 嵌套解析器 +export { + configureNesting +} from './nested-parser'; + +// 解析器术语 +export * from './parser.terms.js'; + +// 外部标记器 +export { + blockContent +} from './external-tokens'; + +// 解析器 +export { + parser +} from './parser.js'; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts new file mode 100644 index 0000000..ae6d080 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts @@ -0,0 +1,82 @@ +/** + * 语言映射和解析器配置 + */ + +import { jsonLanguage } from "@codemirror/lang-json"; +import { pythonLanguage } from "@codemirror/lang-python"; +import { javascriptLanguage, typescriptLanguage } from "@codemirror/lang-javascript"; +import { htmlLanguage } from "@codemirror/lang-html"; +import { StandardSQL } from "@codemirror/lang-sql"; +import { markdownLanguage } from "@codemirror/lang-markdown"; +import { javaLanguage } from "@codemirror/lang-java"; +import { phpLanguage } from "@codemirror/lang-php"; +import { cssLanguage } from "@codemirror/lang-css"; +import { cppLanguage } from "@codemirror/lang-cpp"; +import { xmlLanguage } from "@codemirror/lang-xml"; +import { rustLanguage } from "@codemirror/lang-rust"; + +import { StreamLanguage } from "@codemirror/language"; +import { ruby } from "@codemirror/legacy-modes/mode/ruby"; +import { shell } from "@codemirror/legacy-modes/mode/shell"; +import { go } from "@codemirror/legacy-modes/mode/go"; +import { yamlLanguage } from "@codemirror/lang-yaml"; + +import { SupportedLanguage } from '../types'; + +/** + * 语言信息类 + */ +export class LanguageInfo { + constructor( + public token: SupportedLanguage, + public name: string, + public parser: any, + public guesslang?: string | null + ) {} +} + +/** + * 支持的语言列表 + */ +export const LANGUAGES: LanguageInfo[] = [ + new LanguageInfo("text", "Plain Text", null), + new LanguageInfo("json", "JSON", jsonLanguage.parser, "json"), + new LanguageInfo("python", "Python", pythonLanguage.parser, "py"), + new LanguageInfo("javascript", "JavaScript", javascriptLanguage.parser, "js"), + new LanguageInfo("typescript", "TypeScript", typescriptLanguage.parser, "ts"), + new LanguageInfo("html", "HTML", htmlLanguage.parser, "html"), + new LanguageInfo("css", "CSS", cssLanguage.parser, "css"), + new LanguageInfo("sql", "SQL", StandardSQL.language.parser, "sql"), + new LanguageInfo("markdown", "Markdown", markdownLanguage.parser, "md"), + new LanguageInfo("java", "Java", javaLanguage.parser, "java"), + new LanguageInfo("php", "PHP", phpLanguage.configure({top:"Program"}).parser, "php"), + new LanguageInfo("xml", "XML", xmlLanguage.parser, "xml"), + new LanguageInfo("cpp", "C++", cppLanguage.parser, "cpp"), + new LanguageInfo("c", "C", cppLanguage.parser, "c"), + new LanguageInfo("rust", "Rust", rustLanguage.parser, "rs"), + new LanguageInfo("ruby", "Ruby", StreamLanguage.define(ruby).parser, "rb"), + new LanguageInfo("shell", "Shell", StreamLanguage.define(shell).parser, "sh"), + new LanguageInfo("yaml", "YAML", yamlLanguage.parser, "yaml"), + new LanguageInfo("go", "Go", StreamLanguage.define(go).parser, "go"), +]; + +/** + * 语言映射表 + */ +export const languageMapping = Object.fromEntries( + LANGUAGES.map(l => [l.token, l.parser]) +); + +/** + * 根据 token 获取语言信息 + */ +export function getLanguage(token: SupportedLanguage): LanguageInfo | undefined { + return LANGUAGES.find(lang => lang.token === token); +} + +/** + * 获取所有语言的 token 列表 + */ +export function getLanguageTokens(): SupportedLanguage[] { + return LANGUAGES.map(lang => lang.token); +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/nested-parser.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/nested-parser.ts new file mode 100644 index 0000000..b057a86 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/nested-parser.ts @@ -0,0 +1,44 @@ +/** + * 嵌套解析器配置 + * 为不同语言的代码块提供语法高亮支持 + */ + +import { parseMixed } from "@lezer/common"; +import { BlockContent, BlockLanguage } from "./parser.terms.js"; +import { languageMapping } from "./languages"; + +/** + * 配置嵌套解析器 + * 根据代码块的语言标记选择相应的解析器 + */ +export function configureNesting() { + return parseMixed((node, input) => { + let id = node.type.id; + + if (id === BlockContent) { + // 获取父节点中的语言标记 + let blockLang = node.node.parent?.firstChild?.getChildren(BlockLanguage)[0]; + let langName = blockLang ? input.read(blockLang.from, blockLang.to) : null; + + // 如果 BlockContent 为空,不返回解析器 + // 这可以避免 StreamLanguage 解析器在大缓冲区时出错 + if (node.node.from === node.node.to) { + return null; + } + + // 处理自动检测标记 + if (langName && langName.endsWith('-a')) { + langName = langName.slice(0, -2); // 移除 '-a' 后缀 + } + + // 查找对应的语言解析器 + if (langName && langName in languageMapping && languageMapping[langName] !== null) { + return { + parser: languageMapping[langName], + }; + } + } + + return null; + }); +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.js b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.js new file mode 100644 index 0000000..7cd8dac --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.js @@ -0,0 +1,17 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import {LRParser} from "@lezer/lr" +import {blockContent} from "./external-tokens.js" +export const parser = LRParser.deserialize({ + version: 14, + states: "!jQQOQOOOVOQO'#C`O!dOPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO!iOSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO!qOSO1G.fOOOP7+$Q7+$Q", + stateData: "!v~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTO~OPVO~OUYOmXO~OmZO~O", + goto: "jWPPPX]aPdTROSTQOSRUPQSORWS", + nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto", + maxTerm: 29, + skippedNodes: [0], + repeatNodeCount: 1, + tokenData: ",k~R]YZz}!O!e#V#W!p#Z#[#a#[#]#l#^#_$T#a#b%x#d#e'X#f#g([#g#h)R#h#i*O#l#m+q#m#n,SR!PPmQ%&x%&y!SP!VP%&x%&y!YP!]P%&x%&y!`P!eOXP~!hP#T#U!k~!pOU~~!sQ#d#e!y#g#h#U~!|P#d#e#P~#UOe~~#XP#g#h#[~#aOc~~#dP#c#d#g~#lOj~~#oP#h#i#r~#uP#a#b#x~#{P#`#a$O~$TO^~~$WQ#T#U$^#g#h%g~$aP#j#k$d~$gP#T#U$j~$oPa~#g#h$r~$uP#V#W$x~${P#f#g%O~%RP#]#^%U~%XP#d#e%[~%_P#h#i%b~%gOk~~%jP#c#d%m~%pP#b#c%s~%xO[~~%{P#T#U&O~&RQ#f#g&X#h#i&|~&[P#_#`&_~&bP#W#X&e~&hP#c#d&k~&nP#k#l&q~&tP#b#c&w~&|O`~~'PP#[#]'S~'XOZ~~'[Q#[#]'b#m#n'm~'eP#d#e'h~'mOb~~'pP#h#i's~'vP#[#]'y~'|P#c#d(P~(SP#b#c(V~([O]~~(_P#i#j(b~(eQ#U#V(k#g#h(v~(nP#m#n(q~(vOg~~(yP#h#i(|~)ROf~~)UQ#[#])[#e#f)s~)_P#X#Y)b~)eP#`#a)h~)kP#`#a)n~)sOh~~)vP#`#a)y~*OO_~~*RQ#X#Y*X#m#n*j~*[P#l#m*_~*bP#h#i*e~*jOY~~*mP#d#e*p~*sP#X#Y*v~*yP#g#h*|~+PP#V#W+S~+VP#f#g+Y~+]P#]#^+`~+cP#d#e+f~+iP#h#i+l~+qOl~~+tP#a#b+w~+zP#`#a+}~,SOd~~,VP#T#U,Y~,]P#a#b,`~,cP#`#a,f~,kOi~", + tokenizers: [blockContent, 0, 1], + topRules: {"Document":[0,2]}, + tokenPrec: 0 +}) diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.terms.js b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.terms.js new file mode 100644 index 0000000..d5514f6 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.terms.js @@ -0,0 +1,8 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const + BlockContent = 1, + Document = 2, + Block = 3, + BlockDelimiter = 4, + BlockLanguage = 5, + Auto = 6 diff --git a/frontend/src/views/editor/extensions/codeblock/language-detection/autodetect.ts b/frontend/src/views/editor/extensions/codeblock/language-detection/autodetect.ts new file mode 100644 index 0000000..96f6a11 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/language-detection/autodetect.ts @@ -0,0 +1,259 @@ +/** + * 自动语言检测 + * 基于内容变化自动检测和更新代码块语言 + */ + +import { EditorState, Annotation } from '@codemirror/state'; +import { EditorView, ViewPlugin } from '@codemirror/view'; +import { blockState, getActiveNoteBlock } from '../state'; +import { levenshteinDistance } from './levenshtein'; +import { detectLanguageHeuristic, LanguageDetectionResult } from './heuristics'; +import { LANGUAGES } from '../lang-parser/languages'; +import { SupportedLanguage, Block } from '../types'; + +/** + * 语言检测配置 + */ +interface LanguageDetectionConfig { + /** 最小内容长度阈值 */ + minContentLength: number; + /** 变化阈值比例 */ + changeThreshold: number; + /** 检测置信度阈值 */ + confidenceThreshold: number; + /** 空闲检测延迟 (ms) */ + idleDelay: number; + /** 默认语言 */ + defaultLanguage: SupportedLanguage; +} + +/** + * 默认配置 + */ +const DEFAULT_CONFIG: LanguageDetectionConfig = { + minContentLength: 8, + changeThreshold: 0.1, + confidenceThreshold: 0.3, + idleDelay: 1000, + defaultLanguage: 'text', +}; + +/** + * 语言标记映射 + * 将检测结果映射到我们的语言标记 + */ +const DETECTION_TO_TOKEN = Object.fromEntries( + LANGUAGES.map(l => [l.token, l.token]) +); + +/** + * 兼容性函数 + */ +function requestIdleCallbackCompat(cb: () => void): number { + if (typeof window !== 'undefined' && window.requestIdleCallback) { + return window.requestIdleCallback(cb); + } else { + return setTimeout(cb, 0) as any; + } +} + +function cancelIdleCallbackCompat(id: number): void { + if (typeof window !== 'undefined' && window.cancelIdleCallback) { + window.cancelIdleCallback(id); + } else { + clearTimeout(id); + } +} + +/** + * 语言更改注解 + */ +const languageChangeAnnotation = Annotation.define(); + +/** + * 语言更改命令 + * 简化版本,直接更新块的语言标记 + */ +function changeLanguageTo( + state: EditorState, + dispatch: (tr: any) => void, + block: Block, + newLanguage: SupportedLanguage, + isAuto: boolean +): void { + // 构建新的分隔符文本 + const autoSuffix = isAuto ? '-a' : ''; + const newDelimiter = `\n∞∞∞${newLanguage}${autoSuffix}\n`; + + // 创建事务来替换分隔符 + const transaction = state.update({ + changes: { + from: block.delimiter.from, + to: block.delimiter.to, + insert: newDelimiter, + }, + annotations: [ + languageChangeAnnotation.of(true) + ] + }); + + dispatch(transaction); +} + + + +/** + * 创建语言检测插件 + */ +export function createLanguageDetection( + config: Partial = {} +): ViewPlugin { + const finalConfig = { ...DEFAULT_CONFIG, ...config }; + const previousBlockContent: Record = {}; + let idleCallbackId: number | null = null; + + return ViewPlugin.fromClass( + class LanguageDetectionPlugin { + constructor(public view: EditorView) {} + + update(update: any) { + if (update.docChanged) { + // 取消之前的检测 + if (idleCallbackId !== null) { + cancelIdleCallbackCompat(idleCallbackId); + idleCallbackId = null; + } + + // 安排新的检测 + idleCallbackId = requestIdleCallbackCompat(() => { + idleCallbackId = null; + this.performLanguageDetection(update); + }); + } + } + + private performLanguageDetection(update: any) { + const range = update.state.selection.asSingle().ranges[0]; + const blocks = update.state.field(blockState); + + let block: Block | null = null; + let blockIndex: number | null = null; + + // 找到当前块 + for (let i = 0; i < blocks.length; i++) { + if (blocks[i].content.from <= range.from && blocks[i].content.to >= range.from) { + block = blocks[i]; + blockIndex = i; + break; + } + } + + if (block === null || blockIndex === null) { + return; + } + + + // 如果不是自动检测模式,清除缓存并返回 + if (!block.language.auto) { + delete previousBlockContent[blockIndex]; + return; + } + + const content = update.state.doc.sliceString(block.content.from, block.content.to); + + // 如果内容为空,重置为默认语言 + if (content === "") { + if (block.language.name !== finalConfig.defaultLanguage) { + changeLanguageTo( + update.state, + this.view.dispatch, + block, + finalConfig.defaultLanguage, + true + ); + } + delete previousBlockContent[blockIndex]; + return; + } + + // 内容太短,跳过检测 + if (content.length <= finalConfig.minContentLength) { + return; + } + + // 检查内容是否有显著变化 + const threshold = content.length * finalConfig.changeThreshold; + const previousContent = previousBlockContent[blockIndex]; + + if (!previousContent) { + // 执行语言检测 + this.detectAndUpdateLanguage(content, block, blockIndex, update.state); + previousBlockContent[blockIndex] = content; + } else { + const distance = levenshteinDistance(previousContent, content); + + if (distance >= threshold) { + // 执行语言检测 + this.detectAndUpdateLanguage(content, block, blockIndex, update.state); + previousBlockContent[blockIndex] = content; + } + } + } + + private detectAndUpdateLanguage( + content: string, + block: any, + blockIndex: number, + state: EditorState + ) { + + // 使用启发式检测 + const detectionResult = detectLanguageHeuristic(content); + + // 检查置信度和语言变化 + if (detectionResult.confidence >= finalConfig.confidenceThreshold && + detectionResult.language !== block.language.name && + DETECTION_TO_TOKEN[detectionResult.language]) { + + + // 验证内容未显著变化 + const currentContent = state.doc.sliceString(block.content.from, block.content.to); + const threshold = currentContent.length * finalConfig.changeThreshold; + const contentDistance = levenshteinDistance(content, currentContent); + + + if (contentDistance <= threshold) { + // 内容未显著变化,安全更新语言 + changeLanguageTo( + state, + this.view.dispatch, + block, + detectionResult.language, + true + ); + } + } + } + + destroy() { + if (idleCallbackId !== null) { + cancelIdleCallbackCompat(idleCallbackId); + } + } + } + ); +} + +/** + * 手动检测语言 + */ +export function detectLanguage(content: string): LanguageDetectionResult { + return detectLanguageHeuristic(content); +} + +/** + * 批量检测多个内容块的语言 + */ +export function detectLanguages(contents: string[]): LanguageDetectionResult[] { + return contents.map(content => detectLanguageHeuristic(content)); +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/language-detection/heuristics.ts b/frontend/src/views/editor/extensions/codeblock/language-detection/heuristics.ts new file mode 100644 index 0000000..8813dc0 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/language-detection/heuristics.ts @@ -0,0 +1,269 @@ +/** + * 基于启发式规则的语言检测 + * 用于快速识别常见的编程语言模式 + */ + +import { SupportedLanguage } from '../types'; + +/** + * 语言检测结果 + */ +export interface LanguageDetectionResult { + language: SupportedLanguage; + confidence: number; +} + +/** + * 语言模式定义 + */ +interface LanguagePattern { + patterns: RegExp[]; + weight: number; +} + +/** + * 语言检测规则映射 + */ +const LANGUAGE_PATTERNS: Record = { + javascript: { + patterns: [ + /\b(function|const|let|var|class|extends|import|export|async|await)\b/g, + /\b(console\.log|document\.|window\.)\b/g, + /=>\s*[{(]/g, + /\b(require|module\.exports)\b/g, + ], + weight: 1.0, + }, + typescript: { + patterns: [ + /\b(interface|type|enum|namespace|implements|declare)\b/g, + /:\s*(string|number|boolean|object|any)\b/g, + /<[A-Z][a-zA-Z0-9<>,\s]*>/g, + /\b(public|private|protected|readonly)\b/g, + ], + weight: 1.2, + }, + python: { + patterns: [ + /\b(def|class|import|from|if __name__|print|len|range)\b/g, + /^\s*#.*$/gm, + /\b(True|False|None)\b/g, + /:\s*$/gm, + ], + weight: 1.0, + }, + java: { + patterns: [ + /\b(public|private|protected|static|final|class|interface)\b/g, + /\b(System\.out\.println|String|int|void)\b/g, + /import\s+[a-zA-Z0-9_.]+;/g, + /\b(extends|implements)\b/g, + ], + weight: 1.0, + }, + html: { + patterns: [ + /<\/?[a-zA-Z][^>]*>/g, + //gi, + /<(div|span|p|h[1-6]|body|head|html)\b/g, + /\s(class|id|src|href)=/g, + ], + weight: 1.5, + }, + css: { + patterns: [ + /[.#][a-zA-Z][\w-]*\s*{/g, + /\b(color|background|margin|padding|font-size):\s*[^;]+;/g, + /@(media|keyframes|import)\b/g, + /\{[^}]*\}/g, + ], + weight: 1.3, + }, + json: { + patterns: [ + /^\s*[{\[][\s\S]*[}\]]\s*$/, + /"[^"]*":\s*(".*"|[\d.]+|true|false|null)/g, + /,\s*$/gm, + ], + weight: 2.0, + }, + sql: { + patterns: [ + /\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b/gi, + /\b(JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP BY|ORDER BY)\b/gi, + /;\s*$/gm, + /\b(TABLE|DATABASE|INDEX)\b/gi, + ], + weight: 1.4, + }, + shell: { + patterns: [ + /^#!/g, + /\b(echo|cd|ls|grep|awk|sed|cat|chmod)\b/g, + /\$\{?\w+\}?/g, + /\|\s*\w+/g, + ], + weight: 1.2, + }, + markdown: { + patterns: [ + /^#+\s+/gm, + /\*\*.*?\*\*/g, + /\[.*?\]\(.*?\)/g, + /^```/gm, + ], + weight: 1.1, + }, + php: { + patterns: [ + /<\?php/g, + /\$\w+/g, + /\b(function|class|extends|implements)\b/g, + /echo\s+/g, + ], + weight: 1.3, + }, + cpp: { + patterns: [ + /#include\s*<.*>/g, + /\b(int|char|float|double|void|class|struct)\b/g, + /std::/g, + /cout\s*<<|cin\s*>>/g, + ], + weight: 1.1, + }, + rust: { + patterns: [ + /\bfn\s+\w+/g, + /\b(let|mut|struct|enum|impl|trait)\b/g, + /println!\(/g, + /::\w+/g, + ], + weight: 1.2, + }, + go: { + patterns: [ + /\bfunc\s+\w+/g, + /\b(var|const|type|package|import)\b/g, + /fmt\.\w+/g, + /:=\s*/g, + ], + weight: 1.1, + }, + ruby: { + patterns: [ + /\b(def|class|module|end)\b/g, + /\b(puts|print|require)\b/g, + /@\w+/g, + /\|\w+\|/g, + ], + weight: 1.0, + }, + yaml: { + patterns: [ + /^\s*\w+:\s*.*$/gm, // key: value 模式 + /^\s*-\s+\w+/gm, // 列表项 + /^---\s*$/gm, // 文档分隔符 + /^\s*\w+:\s*\|/gm, // 多行字符串 + /^\s*\w+:\s*>/gm, // 折叠字符串 + /^\s*#.*$/gm, // 注释 + /:\s*\[.*\]/g, // 内联数组 + /:\s*\{.*\}/g, // 内联对象 + ], + weight: 1.5, + }, + xml: { + patterns: [ + /<\?xml/g, + /<\/\w+>/g, + /<\w+[^>]*\/>/g, + /\s\w+="[^"]*"/g, + ], + weight: 1.3, + }, +}; + +/** + * JSON 特殊检测 + * 使用更严格的规则检测 JSON + */ +function detectJSON(content: string): LanguageDetectionResult | null { + const trimmed = content.trim(); + + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + JSON.parse(trimmed); + return { + language: 'json', + confidence: 1.0, + }; + } catch (e) { + // JSON 解析失败,继续其他检测 + } + } + + return null; +} + +/** + * 计算文本与语言模式的匹配分数 + */ +function calculateScore(content: string, pattern: LanguagePattern): number { + let score = 0; + const contentLength = Math.max(content.length, 1); + + for (const regex of pattern.patterns) { + const matches = content.match(regex); + if (matches) { + score += matches.length; + } + } + + // 根据内容长度和权重标准化分数 + return (score * pattern.weight) / (contentLength / 100); +} + +/** + * 基于启发式规则检测语言 + */ +export function detectLanguageHeuristic(content: string): LanguageDetectionResult { + if (!content.trim()) { + return { language: 'text', confidence: 1.0 }; + } + + // 首先尝试 JSON 特殊检测 + const jsonResult = detectJSON(content); + if (jsonResult) { + return jsonResult; + } + + const scores: Record = {}; + + // 计算每种语言的匹配分数 + for (const [language, pattern] of Object.entries(LANGUAGE_PATTERNS)) { + scores[language] = calculateScore(content, pattern); + } + + // 找到最高分的语言 + const sortedScores = Object.entries(scores) + .sort(([, a], [, b]) => b - a) + .filter(([, score]) => score > 0); + + if (sortedScores.length > 0) { + const [bestLanguage, bestScore] = sortedScores[0]; + return { + language: bestLanguage as SupportedLanguage, + confidence: Math.min(bestScore, 1.0), + }; + } + + return { language: 'text', confidence: 1.0 }; +} + +/** + * 获取所有支持的检测语言 + */ +export function getSupportedDetectionLanguages(): SupportedLanguage[] { + return Object.keys(LANGUAGE_PATTERNS) as SupportedLanguage[]; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/language-detection/index.ts b/frontend/src/views/editor/extensions/codeblock/language-detection/index.ts new file mode 100644 index 0000000..1bf4e95 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/language-detection/index.ts @@ -0,0 +1,26 @@ +/** + * 语言检测模块入口 + * 导出所有语言检测相关的功能 + */ + +// 主要检测功能 +export { + createLanguageDetection, + detectLanguage, + detectLanguages +} from './autodetect'; + +// 启发式检测 +export { + detectLanguageHeuristic, + getSupportedDetectionLanguages, + type LanguageDetectionResult +} from './heuristics'; + +// 工具函数 +export { + levenshteinDistance +} from './levenshtein'; + +// 重新导出类型 +export type { SupportedLanguage } from '../types'; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/language-detection/levenshtein.ts b/frontend/src/views/editor/extensions/codeblock/language-detection/levenshtein.ts new file mode 100644 index 0000000..fc00b33 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/language-detection/levenshtein.ts @@ -0,0 +1,113 @@ +/** + * Levenshtein 距离算法 + * 用于计算两个字符串之间的编辑距离 + */ + +/** + * 内部最小值计算函数 + */ +function _min(d0: number, d1: number, d2: number, bx: number, ay: number): number { + return d0 < d1 || d2 < d1 + ? d0 > d2 + ? d2 + 1 + : d0 + 1 + : bx === ay + ? d1 + : d1 + 1; +} + +/** + * 计算两个字符串之间的 Levenshtein 距离 + * @param a 第一个字符串 + * @param b 第二个字符串 + * @returns 编辑距离 + */ +export function levenshteinDistance(a: string, b: string): number { + if (a === b) { + return 0; + } + + if (a.length > b.length) { + const tmp = a; + a = b; + b = tmp; + } + + let la = a.length; + let lb = b.length; + + while (la > 0 && (a.charCodeAt(la - 1) === b.charCodeAt(lb - 1))) { + la--; + lb--; + } + + let offset = 0; + + while (offset < la && (a.charCodeAt(offset) === b.charCodeAt(offset))) { + offset++; + } + + la -= offset; + lb -= offset; + + if (la === 0 || lb < 3) { + return lb; + } + + let x = 0; + let y: number; + let d0: number; + let d1: number; + let d2: number; + let d3: number; + let dd = 0; + let dy: number; + let ay: number; + let bx0: number; + let bx1: number; + let bx2: number; + let bx3: number; + + const vector: number[] = []; + + for (y = 0; y < la; y++) { + vector.push(y + 1); + vector.push(a.charCodeAt(offset + y)); + } + + const len = vector.length - 1; + + for (; x < lb - 3;) { + bx0 = b.charCodeAt(offset + (d0 = x)); + bx1 = b.charCodeAt(offset + (d1 = x + 1)); + bx2 = b.charCodeAt(offset + (d2 = x + 2)); + bx3 = b.charCodeAt(offset + (d3 = x + 3)); + x += 4; + dd = x; + for (y = 0; y < len; y += 2) { + dy = vector[y]; + ay = vector[y + 1]; + d0 = _min(dy, d0, d1, bx0, ay); + d1 = _min(d0, d1, d2, bx1, ay); + d2 = _min(d1, d2, d3, bx2, ay); + dd = _min(d2, d3, dd, bx3, ay); + vector[y] = dd; + d3 = d2; + d2 = d1; + d1 = d0; + d0 = dy; + } + } + + for (; x < lb;) { + bx0 = b.charCodeAt(offset + (d0 = x)); + dd = ++x; + for (y = 0; y < len; y += 2) { + dy = vector[y]; + vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]); + d0 = dy; + } + } + + return dd; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/moveLines.ts b/frontend/src/views/editor/extensions/codeblock/moveLines.ts new file mode 100644 index 0000000..59faafa --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/moveLines.ts @@ -0,0 +1,160 @@ +/** + * 移动行功能 + * 处理代码块分隔符 + */ + +import { EditorSelection, SelectionRange } from "@codemirror/state"; +import { blockState } from "./state"; +import { SUPPORTED_LANGUAGES } from "./types"; + +interface LineBlock { + from: number; + to: number; + ranges: SelectionRange[]; +} + +// 创建语言标记的正则表达式 +const languageTokensMatcher = SUPPORTED_LANGUAGES.join("|"); +const tokenRegEx = new RegExp(`^∞∞∞(${languageTokensMatcher})(-a)?$`, "g"); + +/** + * 获取选中的行块 + */ +function selectedLineBlocks(state: any): LineBlock[] { + let blocks: LineBlock[] = []; + let upto = -1; + + for (let range of state.selection.ranges) { + let startLine = state.doc.lineAt(range.from); + let endLine = state.doc.lineAt(range.to); + + if (!range.empty && range.to == endLine.from) { + endLine = state.doc.lineAt(range.to - 1); + } + + if (upto >= startLine.number) { + let prev = blocks[blocks.length - 1]; + prev.to = endLine.to; + prev.ranges.push(range); + } else { + blocks.push({ + from: startLine.from, + to: endLine.to, + ranges: [range] + }); + } + upto = endLine.number + 1; + } + + return blocks; +} + +/** + * 移动行的核心逻辑 + */ +function moveLine(state: any, dispatch: any, forward: boolean): boolean { + if (state.readOnly) { + return false; + } + + let changes: any[] = []; + let ranges: SelectionRange[] = []; + + for (let block of selectedLineBlocks(state)) { + if (forward ? block.to == state.doc.length : block.from == 0) { + continue; + } + + let nextLine = state.doc.lineAt(forward ? block.to + 1 : block.from - 1); + let previousLine; + + if (!forward ? block.to == state.doc.length : block.from == 0) { + previousLine = null; + } else { + previousLine = state.doc.lineAt(forward ? block.from - 1 : block.to + 1); + } + + // 如果整个选择是一个被分隔符包围的块,我们需要在分隔符之间添加额外的换行符 + // 以避免创建两个只有单个换行符的分隔符,这会导致语法解析器无法解析 + let nextLineIsSeparator = nextLine.text.match(tokenRegEx); + let blockSurroundedBySeparators = previousLine !== null && + previousLine.text.match(tokenRegEx) && nextLineIsSeparator; + + let size = nextLine.length + 1; + + if (forward) { + if (blockSurroundedBySeparators) { + size += 1; + changes.push( + { from: block.to, to: nextLine.to }, + { from: block.from, insert: state.lineBreak + nextLine.text + state.lineBreak } + ); + } else { + changes.push( + { from: block.to, to: nextLine.to }, + { from: block.from, insert: nextLine.text + state.lineBreak } + ); + } + + for (let r of block.ranges) { + ranges.push(EditorSelection.range( + Math.min(state.doc.length, r.anchor + size), + Math.min(state.doc.length, r.head + size) + )); + } + } else { + if (blockSurroundedBySeparators || (previousLine === null && nextLineIsSeparator)) { + changes.push( + { from: nextLine.from, to: block.from }, + { from: block.to, insert: state.lineBreak + nextLine.text + state.lineBreak } + ); + for (let r of block.ranges) { + ranges.push(EditorSelection.range(r.anchor - size, r.head - size)); + } + } else { + changes.push( + { from: nextLine.from, to: block.from }, + { from: block.to, insert: state.lineBreak + nextLine.text } + ); + for (let r of block.ranges) { + ranges.push(EditorSelection.range(r.anchor - size, r.head - size)); + } + } + } + } + + if (!changes.length) { + return false; + } + + dispatch(state.update({ + changes, + scrollIntoView: true, + selection: EditorSelection.create(ranges, state.selection.mainIndex), + userEvent: "move.line" + })); + + return true; +} + +/** + * 向上移动行 + */ +export const moveLineUp = ({ state, dispatch }: { state: any; dispatch: any }): boolean => { + // 防止移动行到第一个块分隔符之前 + if (state.selection.ranges.some((range: SelectionRange) => { + let startLine = state.doc.lineAt(range.from); + return startLine.from <= state.field(blockState)[0].content.from; + })) { + return true; + } + + return moveLine(state, dispatch, false); +}; + +/** + * 向下移动行 + */ +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/parser.ts b/frontend/src/views/editor/extensions/codeblock/parser.ts index ea3290d..5c3a1f0 100644 --- a/frontend/src/views/editor/extensions/codeblock/parser.ts +++ b/frontend/src/views/editor/extensions/codeblock/parser.ts @@ -4,6 +4,7 @@ import { EditorState } from '@codemirror/state'; import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language'; +import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage, Document } from './lang-parser/parser.terms.js'; import { CodeBlock, SupportedLanguage, @@ -17,121 +18,81 @@ import { Block } from './types'; -/** - * 语言检测工具 - */ -class LanguageDetector { - // 语言关键字映射 - private static readonly LANGUAGE_PATTERNS: Record = { - javascript: [ - /\b(function|const|let|var|class|extends|import|export|async|await)\b/, - /\b(console\.log|document\.|window\.)\b/, - /=>\s*[{(]/ - ], - typescript: [ - /\b(interface|type|enum|namespace|implements|declare)\b/, - /:\s*(string|number|boolean|object|any)\b/, - /<[A-Z][a-zA-Z0-9<>,\s]*>/ - ], - python: [ - /\b(def|class|import|from|if __name__|print|len|range)\b/, - /^\s*#.*$/m, - /\b(True|False|None)\b/ - ], - java: [ - /\b(public|private|protected|static|final|class|interface)\b/, - /\b(System\.out\.println|String|int|void)\b/, - /import\s+[a-zA-Z0-9_.]+;/ - ], - html: [ - /<\/?[a-zA-Z][^>]*>/, - //i, - /<(div|span|p|h[1-6]|body|head|html)\b/ - ], - css: [ - /[.#][a-zA-Z][\w-]*\s*{/, - /\b(color|background|margin|padding|font-size):\s*[^;]+;/, - /@(media|keyframes|import)\b/ - ], - json: [ - /^\s*[{\[][\s\S]*[}\]]\s*$/, - /"[^"]*":\s*(".*"|[\d.]+|true|false|null)/, - /,\s*$/m - ], - sql: [ - /\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b/i, - /\b(JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP BY|ORDER BY)\b/i, - /;\s*$/m - ], - shell: [ - /^#!/, - /\b(echo|cd|ls|grep|awk|sed|cat|chmod)\b/, - /\$\{?\w+\}?/ - ], - markdown: [ - /^#+\s+/m, - /\*\*.*?\*\*/, - /\[.*?\]\(.*?\)/, - /^```/m - ] - }; - - /** - * 检测文本的编程语言 - */ - static detectLanguage(text: string): LanguageDetectionResult { - if (!text.trim()) { - return { language: 'text', confidence: 1.0 }; - } - - const scores: Record = {}; - - // 对每种语言计算匹配分数 - for (const [language, patterns] of Object.entries(this.LANGUAGE_PATTERNS)) { - let score = 0; - const textLower = text.toLowerCase(); - - for (const pattern of patterns) { - const matches = text.match(pattern); - if (matches) { - score += matches.length; - } - } - - // 根据文本长度标准化分数 - scores[language] = score / Math.max(text.length / 100, 1); - } - - // 找到最高分的语言 - const bestMatch = Object.entries(scores) - .sort(([, a], [, b]) => b - a)[0]; - - if (bestMatch && bestMatch[1] > 0) { - return { - language: bestMatch[0] as SupportedLanguage, - confidence: Math.min(bestMatch[1], 1.0) - }; - } - - return { language: 'text', confidence: 1.0 }; - } -} - /** * 从语法树解析代码块 */ -export function getBlocksFromSyntaxTree(state: EditorState): CodeBlock[] | null { +export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null { if (!syntaxTreeAvailable(state)) { return null; } const tree = syntaxTree(state); - const blocks: CodeBlock[] = []; + const blocks: Block[] = []; const doc = state.doc; - // TODO: 如果使用自定义 Lezer 语法,在这里实现语法树解析 - // 目前先返回 null,使用字符串解析作为后备 - return null; + // 遍历语法树中的所有块 + tree.iterate({ + enter(node) { + if (node.type.id === BlockNode) { + // 查找块的分隔符和内容 + let delimiter: { from: number; to: number } | null = null; + let content: { from: number; to: number } | null = null; + let language = 'text'; + let auto = false; + + // 遍历块的子节点 + const blockNode = node.node; + blockNode.firstChild?.cursor().iterate(child => { + if (child.type.id === BlockDelimiter) { + delimiter = { from: child.from, to: child.to }; + + // 解析整个分隔符文本来获取语言和自动检测标记 + const delimiterText = doc.sliceString(child.from, child.to); + console.log('🔍 [解析器] 分隔符文本:', delimiterText); + + // 使用正则表达式解析分隔符 + const match = delimiterText.match(/∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/); + if (match) { + language = match[1] || 'text'; + auto = match[2] === '-a'; + console.log(`🔍 [解析器] 解析结果: 语言=${language}, 自动=${auto}`); + } else { + // 回退到逐个解析子节点 + child.node.firstChild?.cursor().iterate(langChild => { + if (langChild.type.id === BlockLanguage) { + const langText = doc.sliceString(langChild.from, langChild.to); + language = langText || 'text'; + } + // 检查是否有自动检测标记 + if (doc.sliceString(langChild.from, langChild.to) === '-a') { + auto = true; + } + }); + } + } else if (child.type.id === BlockContent) { + content = { from: child.from, to: child.to }; + } + }); + + if (delimiter && content) { + blocks.push({ + language: { + name: language as SupportedLanguage, + auto: auto, + }, + content: content, + delimiter: delimiter, + range: { + from: node.from, + to: node.to, + }, + }); + } + } + } + }); + + return blocks.length > 0 ? blocks : null; } // 跟踪第一个分隔符的大小 @@ -308,6 +269,13 @@ export function getBlocksFromString(state: EditorState): Block[] { * 获取文档中的所有块 */ export function getBlocks(state: EditorState): Block[] { + // 优先使用语法树解析 + const syntaxTreeBlocks = getBlocksFromSyntaxTree(state); + if (syntaxTreeBlocks) { + return syntaxTreeBlocks; + } + + // 如果语法树不可用,回退到字符串解析 return getBlocksFromString(state); } diff --git a/frontend/src/views/editor/extensions/codeblock/transposeChars.ts b/frontend/src/views/editor/extensions/codeblock/transposeChars.ts new file mode 100644 index 0000000..ee63dca --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/transposeChars.ts @@ -0,0 +1,53 @@ +/** + * 字符转置功能 + * 交换光标前后的字符 + */ + +import { EditorSelection, findClusterBreak } from "@codemirror/state"; +import { getNoteBlockFromPos } from "./state"; + +/** + * 交换光标前后的字符 + */ +export const transposeChars = ({ state, dispatch }: { state: any; dispatch: any }): boolean => { + if (state.readOnly) { + return false; + } + + let changes = state.changeByRange((range: any) => { + // 防止在代码块的开始或结束位置进行字符转置,因为这会破坏块语法 + const block = getNoteBlockFromPos(state, range.from); + if (block && (range.from === block.content.from || range.from === block.content.to)) { + return { range }; + } + + if (!range.empty || range.from == 0 || range.from == state.doc.length) { + return { range }; + } + + let pos = range.from; + let line = state.doc.lineAt(pos); + let from = pos == line.from ? pos - 1 : findClusterBreak(line.text, pos - line.from, false) + line.from; + let to = pos == line.to ? pos + 1 : findClusterBreak(line.text, pos - line.from, true) + line.from; + + return { + changes: { + from, + to, + insert: state.doc.slice(pos, to).append(state.doc.slice(from, pos)) + }, + range: EditorSelection.cursor(to) + }; + }); + + if (changes.changes.empty) { + return false; + } + + dispatch(state.update(changes, { + scrollIntoView: true, + userEvent: "move.character" + })); + + return true; +}; \ No newline at end of file