Add code blocks and rainbow bracket extensions

This commit is contained in:
2025-06-18 18:14:26 +08:00
parent 87fe9d48b1
commit cce9cf7e92
16 changed files with 2703 additions and 18 deletions

View File

@@ -0,0 +1,355 @@
/**
* Block 命令
*/
import { EditorSelection } from "@codemirror/state";
import { Command } from "@codemirror/view";
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state";
import { Block, EditorOptions } from "./types";
/**
* 获取块分隔符
*/
export function getBlockDelimiter(defaultToken: string, autoDetect: boolean): string {
return `\n∞∞∞${autoDetect ? defaultToken + '-a' : defaultToken}\n`;
}
/**
* 在光标处插入新块
*/
export const insertNewBlockAtCursor = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const currentBlock = getActiveNoteBlock(state);
let delimText: string;
if (currentBlock) {
delimText = `\n∞∞∞${currentBlock.language.name}${currentBlock.language.auto ? "-a" : ""}\n`;
} else {
delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
}
dispatch(state.replaceSelection(delimText), {
scrollIntoView: true,
userEvent: "input",
});
return true;
};
/**
* 在当前块之前添加新块
*/
export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 在当前块之后添加新块
*/
export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.content.to,
insert: delimText,
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 在第一个块之前添加新块
*/
export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getFirstNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(delimText.length),
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 在最后一个块之后添加新块
*/
export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getLastNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.content.to,
insert: delimText,
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 更改块语言
*/
export function changeLanguageTo(state: any, dispatch: any, block: Block, language: string, auto: boolean) {
if (state.readOnly) return false;
const delimRegex = /^\n∞∞∞[a-z]+?(-a)?\n/g;
if (state.doc.sliceString(block.delimiter.from, block.delimiter.to).match(delimRegex)) {
dispatch(state.update({
changes: {
from: block.delimiter.from,
to: block.delimiter.to,
insert: `\n∞∞∞${language}${auto ? '-a' : ''}\n`,
},
}));
} else {
throw new Error("Invalid delimiter: " + state.doc.sliceString(block.delimiter.from, block.delimiter.to));
}
}
/**
* 更改当前块语言
*/
export function changeCurrentBlockLanguage(state: any, dispatch: any, language: string | null, auto: boolean) {
const block = getActiveNoteBlock(state);
if (!block) return;
// 如果 language 为 null我们只想更改自动检测标志
if (language === null) {
language = block.language.name;
}
changeLanguageTo(state, dispatch, block, language, auto);
}
// 选择和移动辅助函数
function updateSel(sel: EditorSelection, by: (range: any) => any): EditorSelection {
return EditorSelection.create(sel.ranges.map(by), sel.mainIndex);
}
function setSel(state: any, selection: EditorSelection) {
return state.update({ selection, scrollIntoView: true, userEvent: "select" });
}
function extendSel(state: any, dispatch: any, how: (range: any) => any) {
let selection = updateSel(state.selection, range => {
let head = how(range);
return EditorSelection.range(range.anchor, head.head, head.goalColumn, head.bidiLevel || undefined);
});
if (selection.eq(state.selection)) return false;
dispatch(setSel(state, selection));
return true;
}
function moveSel(state: any, dispatch: any, how: (range: any) => any) {
let selection = updateSel(state.selection, how);
if (selection.eq(state.selection)) return false;
dispatch(setSel(state, selection));
return true;
}
function previousBlock(state: any, range: any) {
const blocks = state.field(blockState);
const block = getNoteBlockFromPos(state, range.head);
if (!block) return EditorSelection.cursor(0);
if (range.head === block.content.from) {
const index = blocks.indexOf(block);
const previousBlockIndex = index > 0 ? index - 1 : 0;
return EditorSelection.cursor(blocks[previousBlockIndex].content.from);
} else {
return EditorSelection.cursor(block.content.from);
}
}
function nextBlock(state: any, range: any) {
const blocks = state.field(blockState);
const block = getNoteBlockFromPos(state, range.head);
if (!block) return EditorSelection.cursor(state.doc.length);
if (range.head === block.content.to) {
const index = blocks.indexOf(block);
const nextBlockIndex = index < blocks.length - 1 ? index + 1 : index;
return EditorSelection.cursor(blocks[nextBlockIndex].content.to);
} else {
return EditorSelection.cursor(block.content.to);
}
}
/**
* 跳转到下一个块
*/
export function gotoNextBlock({ state, dispatch }: any) {
return moveSel(state, dispatch, (range: any) => nextBlock(state, range));
}
/**
* 选择到下一个块
*/
export function selectNextBlock({ state, dispatch }: any) {
return extendSel(state, dispatch, (range: any) => nextBlock(state, range));
}
/**
* 跳转到上一个块
*/
export function gotoPreviousBlock({ state, dispatch }: any) {
return moveSel(state, dispatch, (range: any) => previousBlock(state, range));
}
/**
* 选择到上一个块
*/
export function selectPreviousBlock({ state, dispatch }: any) {
return extendSel(state, dispatch, (range: any) => previousBlock(state, range));
}
/**
* 删除块
*/
export const deleteBlock = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const blocks = state.field(blockState);
if (blocks.length <= 1) return false; // 不能删除最后一个块
const blockIndex = blocks.indexOf(block);
let newCursorPos: number;
if (blockIndex === blocks.length - 1) {
// 如果是最后一个块,将光标移到前一个块的末尾
newCursorPos = blocks[blockIndex - 1].content.to;
} else {
// 否则移到下一个块的开始
newCursorPos = blocks[blockIndex + 1].content.from;
}
dispatch(state.update({
changes: {
from: block.range.from,
to: block.range.to,
insert: ""
},
selection: EditorSelection.cursor(newCursorPos)
}, {
scrollIntoView: true,
userEvent: "delete"
}));
return true;
};
/**
* 向上移动当前块
*/
export function moveCurrentBlockUp({ state, dispatch }: any) {
return moveCurrentBlock(state, dispatch, true);
}
/**
* 向下移动当前块
*/
export function moveCurrentBlockDown({ state, dispatch }: any) {
return moveCurrentBlock(state, dispatch, false);
}
function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const blocks = state.field(blockState);
const blockIndex = blocks.indexOf(block);
const targetIndex = up ? blockIndex - 1 : blockIndex + 1;
if (targetIndex < 0 || targetIndex >= blocks.length) return false;
const targetBlock = blocks[targetIndex];
// 获取两个块的完整内容
const currentBlockContent = state.doc.sliceString(block.range.from, block.range.to);
const targetBlockContent = state.doc.sliceString(targetBlock.range.from, targetBlock.range.to);
// 交换块的位置
const changes = up ? [
{
from: targetBlock.range.from,
to: block.range.to,
insert: currentBlockContent + targetBlockContent
}
] : [
{
from: block.range.from,
to: targetBlock.range.to,
insert: targetBlockContent + currentBlockContent
}
];
// 计算新的光标位置
const newCursorPos = up ?
targetBlock.range.from + (block.range.to - block.range.from) + (block.content.from - block.range.from) :
block.range.from + (targetBlock.range.to - targetBlock.range.from) + (block.content.from - block.range.from);
dispatch(state.update({
changes,
selection: EditorSelection.cursor(newCursorPos)
}, {
scrollIntoView: true,
userEvent: "move"
}));
return true;
}

View File

@@ -0,0 +1,234 @@
/**
* 代码块复制粘贴扩展
* 防止复制分隔符标记,自动替换为换行符
*/
import { EditorState, EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { Command } from "@codemirror/view";
import { SUPPORTED_LANGUAGES } from "./types";
/**
* 构建块分隔符正则表达式
*/
const languageTokensMatcher = SUPPORTED_LANGUAGES.join("|");
const blockSeparatorRegex = new RegExp(`\\n∞∞∞(${languageTokensMatcher})(-a)?\\n`, "g");
/**
* 降级复制方法 - 使用传统的 document.execCommand
*/
function fallbackCopyToClipboard(text: string): boolean {
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const result = document.execCommand('copy');
document.body.removeChild(textArea);
return result;
} catch (err) {
console.error('The downgrade replication method also failed:', err);
return false;
}
}
/**
* 获取被复制的范围和内容
*/
function copiedRange(state: EditorState) {
let content: string[] = [];
let ranges: any[] = [];
for (let range of state.selection.ranges) {
if (!range.empty) {
content.push(state.sliceDoc(range.from, range.to));
ranges.push(range);
}
}
if (ranges.length === 0) {
// 如果所有范围都是空的,我们想要复制每个选择的整行(唯一的)
const copiedLines: number[] = [];
for (let range of state.selection.ranges) {
if (range.empty) {
const line = state.doc.lineAt(range.head);
const lineContent = state.sliceDoc(line.from, line.to);
if (!copiedLines.includes(line.from)) {
content.push(lineContent);
ranges.push(range);
copiedLines.push(line.from);
}
}
}
}
return {
text: content.join(state.lineBreak),
ranges
};
}
/**
* 设置浏览器复制和剪切事件处理器,将块分隔符替换为换行符
*/
export const codeBlockCopyCut = EditorView.domEventHandlers({
copy(event, view) {
let { text, ranges } = copiedRange(view.state);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
const data = event.clipboardData;
if (data) {
event.preventDefault();
data.clearData();
data.setData("text/plain", text);
}
},
cut(event, view) {
let { text, ranges } = copiedRange(view.state);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
const data = event.clipboardData;
if (data) {
event.preventDefault();
data.clearData();
data.setData("text/plain", text);
}
if (!view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
});
}
}
});
/**
* 复制和剪切的通用函数
*/
const copyCut = (view: EditorView, cut: boolean): boolean => {
let { text, ranges } = copiedRange(view.state);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
// 使用现代剪贴板 API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(err => {
fallbackCopyToClipboard(text);
});
} else {
// 降级到传统方法
fallbackCopyToClipboard(text);
}
if (cut && !view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
});
}
return true;
};
/**
* 粘贴函数
*/
function doPaste(view: EditorView, input: string) {
const { state } = view;
const text = state.toText(input);
const byLine = text.lines === state.selection.ranges.length;
let changes: any;
if (byLine) {
let i = 1;
changes = state.changeByRange(range => {
const line = text.line(i++);
return {
changes: { from: range.from, to: range.to, insert: line.text },
range: EditorSelection.cursor(range.from + line.length)
};
});
} else {
changes = state.replaceSelection(text);
}
view.dispatch(changes, {
userEvent: "input.paste",
scrollIntoView: true
});
}
/**
* 复制命令
*/
export const copyCommand: Command = (view) => {
return copyCut(view, false);
};
/**
* 剪切命令
*/
export const cutCommand: Command = (view) => {
return copyCut(view, true);
};
/**
* 粘贴命令
*/
export const pasteCommand: Command = (view) => {
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText()
.then(text => {
doPaste(view, text);
})
.catch(err => {
console.error('Failed to read from clipboard:', err);
});
} else {
console.warn('The clipboard API is not available, please use your browser\'s native paste feature');
}
return true;
};
/**
* 获取复制粘贴扩展
*/
export function getCopyPasteExtensions() {
return [
codeBlockCopyCut,
];
}
/**
* 获取复制粘贴键盘映射
*/
export function getCopyPasteKeymap() {
return [
{
key: 'Mod-c',
run: copyCommand,
preventDefault: true
},
{
key: 'Mod-x',
run: cutCommand,
preventDefault: true
},
{
key: 'Mod-v',
run: pasteCommand,
preventDefault: true
}
];
}

View File

@@ -0,0 +1,216 @@
/**
* Block 装饰系统
*/
import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view";
import { StateField, RangeSetBuilder } from "@codemirror/state";
import { blockState } from "./state";
/**
* 块开始装饰组件
*/
class NoteBlockStart extends WidgetType {
constructor(private isFirst: boolean) {
super();
}
eq(other: NoteBlockStart) {
return this.isFirst === other.isFirst;
}
toDOM() {
let wrap = document.createElement("div");
wrap.className = "code-block-start" + (this.isFirst ? " first" : "");
return wrap;
}
ignoreEvent() {
return false;
}
}
/**
* 块分隔符装饰器
*/
const noteBlockWidget = () => {
const decorate = (state: any) => {
const builder = new RangeSetBuilder<Decoration>();
state.field(blockState).forEach((block: any) => {
let delimiter = block.delimiter;
let deco = Decoration.replace({
widget: new NoteBlockStart(delimiter.from === 0),
inclusive: true,
block: true,
side: 0,
});
builder.add(
delimiter.from === 0 ? delimiter.from : delimiter.from + 1,
delimiter.to - 1,
deco
);
});
return builder.finish();
};
const noteBlockStartField = StateField.define({
create(state: any) {
return decorate(state);
},
update(widgets: any, transaction: any) {
// 如果装饰为空,可能意味着我们没有获得解析的语法树,那么我们希望在所有更新时更新装饰(而不仅仅是文档更改)
if (transaction.docChanged || widgets.isEmpty) {
return decorate(transaction.state);
}
return widgets;
},
provide(field: any) {
return EditorView.decorations.from(field);
}
});
return noteBlockStartField;
};
/**
* 原子范围,防止在分隔符内编辑
*/
function atomicRanges(view: EditorView) {
let builder = new RangeSetBuilder();
view.state.field(blockState).forEach((block: any) => {
builder.add(
block.delimiter.from,
block.delimiter.to,
Decoration.mark({ atomic: true }),
);
});
return builder.finish();
}
const atomicNoteBlock = ViewPlugin.fromClass(
class {
atomicRanges: any;
constructor(view: EditorView) {
this.atomicRanges = atomicRanges(view);
}
update(update: any) {
if (update.docChanged) {
this.atomicRanges = atomicRanges(update.view);
}
}
},
{
provide: plugin => EditorView.atomicRanges.of(view => {
return view.plugin(plugin)?.atomicRanges || [];
})
}
);
/**
* 块背景层 - 修复高度计算问题
*/
const blockLayer = layer({
above: false,
markers(view: EditorView) {
const markers: RectangleMarker[] = [];
let idx = 0;
function rangesOverlaps(range1: any, range2: any) {
return range1.from <= range2.to && range2.from <= range1.to;
}
const blocks = view.state.field(blockState);
blocks.forEach((block: any) => {
// 确保块是可见的
if (!view.visibleRanges.some(range => rangesOverlaps(block.content, range))) {
idx++;
return;
}
// view.coordsAtPos 如果编辑器不可见则返回 null
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
idx++;
return;
}
// 只对最后一个块进行特殊处理
if (idx === blocks.length - 1) {
// 计算需要为最后一个块添加多少额外高度,但要更保守
const editorHeight = view.dom.clientHeight;
const contentBottom = toCoordsBottom - view.documentTop + view.documentPadding.top;
// 只有当内容不足以填满视口时,才添加额外高度
if (contentBottom < editorHeight) {
let extraHeight = editorHeight - contentBottom - view.defaultLineHeight - 16; // 保留合理的底部边距
extraHeight = Math.max(0, extraHeight); // 确保不为负数
toCoordsBottom += extraHeight;
}
}
markers.push(new RectangleMarker(
idx++ % 2 == 0 ? "block-even" : "block-odd",
0,
// 参考 Heynote 的精确计算方式
fromCoordsTop - (view.documentTop - view.documentPadding.top) - 1 - 6,
null, // 宽度在 CSS 中设置为 100%
(toCoordsBottom - fromCoordsTop) + 15,
));
});
return markers;
},
update(update: any, dom: any) {
return update.docChanged || update.viewportChanged;
},
class: "code-blocks-layer"
});
/**
* 防止第一个块被删除
*/
const preventFirstBlockFromBeingDeleted = EditorView.updateListener.of((update: any) => {
// 暂时简化实现,后续可以完善
});
/**
* 防止选择在第一个块之前
*/
const preventSelectionBeforeFirstBlock = EditorView.updateListener.of((update: any) => {
// 暂时简化实现,后续可以完善
});
/**
* 获取块装饰扩展 - 简化选项
*/
export function getBlockDecorationExtensions(options: {
showBackground?: boolean;
} = {}) {
const {
showBackground = true,
} = options;
const extensions: any[] = [
noteBlockWidget(),
atomicNoteBlock,
preventFirstBlockFromBeingDeleted,
preventSelectionBeforeFirstBlock,
];
if (showBackground) {
extensions.push(blockLayer);
}
return extensions;
}

View File

@@ -0,0 +1,250 @@
/**
* CodeBlock 扩展主入口
*/
import {Extension} from '@codemirror/state';
import {EditorView, keymap} from '@codemirror/view';
// 导入核心模块
import {blockState} from './state';
import {getBlockDecorationExtensions} from './decorations';
import * as commands from './commands';
import {selectAll, getBlockSelectExtensions} from './selectAll';
import {getCopyPasteExtensions, getCopyPasteKeymap} from './copyPaste';
import {EditorOptions, SupportedLanguage} from './types';
import {lineNumbers} from '@codemirror/view';
import './styles.css'
/**
* 代码块扩展配置选项
*/
export interface CodeBlockOptions {
// 视觉选项
showBackground?: boolean;
// 功能选项
enableAutoDetection?: boolean;
defaultLanguage?: SupportedLanguage;
// 编辑器选项
defaultBlockToken?: string;
defaultBlockAutoDetect?: boolean;
}
/**
* 默认编辑器选项
*/
const defaultEditorOptions: EditorOptions = {
defaultBlockToken: 'text',
defaultBlockAutoDetect: false,
};
/**
* 获取块内行号信息
*/
function getBlockLineFromPos(state: any, pos: number) {
const line = state.doc.lineAt(pos);
const blocks = state.field(blockState);
const block = blocks.find((block: any) =>
block.content.from <= line.from && block.content.to >= line.from
);
if (block) {
const firstBlockLine = state.doc.lineAt(block.content.from).number;
return {
line: line.number - firstBlockLine + 1,
col: pos - line.from + 1,
length: line.length,
};
}
return null;
}
/**
* 创建块内行号扩展
*/
const blockLineNumbers = lineNumbers({
formatNumber(lineNo, state) {
if (state.doc.lines >= lineNo) {
const lineInfo = getBlockLineFromPos(state, state.doc.line(lineNo).from);
if (lineInfo !== null) {
return lineInfo.line.toString();
}
}
return "";
}
});
/**
* 创建代码块扩展
*/
export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension {
const {
showBackground = true,
enableAutoDetection = true,
defaultLanguage = 'text',
defaultBlockToken = 'text',
defaultBlockAutoDetect = false,
} = options;
const editorOptions: EditorOptions = {
defaultBlockToken,
defaultBlockAutoDetect,
};
const extensions: Extension[] = [
// 核心状态管理
blockState,
// 块内行号
blockLineNumbers,
// 视觉装饰系统
...getBlockDecorationExtensions({
showBackground
}),
// 块选择功能
...getBlockSelectExtensions(),
// 复制粘贴功能
...getCopyPasteExtensions(),
// 主题样式
EditorView.theme({
'&': {
fontSize: '14px'
},
'.cm-content': {
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", consolas, monospace'
},
'.cm-focused': {
outline: 'none'
}
}),
// 键盘映射
keymap.of([
// 复制粘贴命令
...getCopyPasteKeymap(),
// 块隔离选择命令
{
key: 'Mod-a',
run: selectAll,
preventDefault: true
},
// 块创建命令
{
key: 'Mod-Enter',
run: commands.addNewBlockAfterCurrent(editorOptions),
preventDefault: true
},
{
key: 'Mod-Shift-Enter',
run: commands.addNewBlockAfterLast(editorOptions),
preventDefault: true
},
{
key: 'Alt-Enter',
run: commands.addNewBlockBeforeCurrent(editorOptions),
preventDefault: true
},
// 块导航命令
{
key: 'Mod-ArrowUp',
run: commands.gotoPreviousBlock,
preventDefault: true
},
{
key: 'Mod-ArrowDown',
run: commands.gotoNextBlock,
preventDefault: true
},
{
key: 'Mod-Shift-ArrowUp',
run: commands.selectPreviousBlock,
preventDefault: true
},
{
key: 'Mod-Shift-ArrowDown',
run: commands.selectNextBlock,
preventDefault: true
},
// 块编辑命令
{
key: 'Mod-Shift-d',
run: commands.deleteBlock(editorOptions),
preventDefault: true
},
{
key: 'Alt-Mod-ArrowUp',
run: commands.moveCurrentBlockUp,
preventDefault: true
},
{
key: 'Alt-Mod-ArrowDown',
run: commands.moveCurrentBlockDown,
preventDefault: true
},
])
];
return extensions;
}
// 导出核心功能
export {
// 类型定义
type Block,
type SupportedLanguage,
type CreateBlockOptions,
SUPPORTED_LANGUAGES
} from './types';
// 状态管理
export {
blockState,
getActiveNoteBlock,
getFirstNoteBlock,
getLastNoteBlock,
getNoteBlockFromPos
} from './state';
// 解析器
export {
getBlocks,
getBlocksFromString,
firstBlockDelimiterSize
} from './parser';
// 命令
export * from './commands';
// 选择功能
export {
selectAll,
getBlockSelectExtensions
} from './selectAll';
// 复制粘贴功能
export {
copyCommand,
cutCommand,
pasteCommand,
getCopyPasteExtensions,
getCopyPasteKeymap
} from './copyPaste';
// 行号相关
export { getBlockLineFromPos, blockLineNumbers };
/**
* 默认导出
*/
export default createCodeBlockExtension;

View File

@@ -0,0 +1,412 @@
/**
* Block 解析器
*/
import { EditorState } from '@codemirror/state';
import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
import {
CodeBlock,
SupportedLanguage,
SUPPORTED_LANGUAGES,
DELIMITER_REGEX,
DELIMITER_PREFIX,
DELIMITER_SUFFIX,
AUTO_DETECT_SUFFIX,
ParseOptions,
LanguageDetectionResult,
Block
} from './types';
/**
* 语言检测工具
*/
class LanguageDetector {
// 语言关键字映射
private static readonly LANGUAGE_PATTERNS: Record<string, RegExp[]> = {
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][^>]*>/,
/<!DOCTYPE\s+html>/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<string, number> = {};
// 对每种语言计算匹配分数
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 {
if (!syntaxTreeAvailable(state)) {
return null;
}
const tree = syntaxTree(state);
const blocks: CodeBlock[] = [];
const doc = state.doc;
// TODO: 如果使用自定义 Lezer 语法,在这里实现语法树解析
// 目前先返回 null使用字符串解析作为后备
return null;
}
// 跟踪第一个分隔符的大小
export let firstBlockDelimiterSize: number | undefined;
/**
* 从文档字符串内容解析块,使用 String.indexOf()
*/
export function getBlocksFromString(state: EditorState): Block[] {
const blocks: Block[] = [];
const doc = state.doc;
if (doc.length === 0) {
// 如果文档为空,创建一个默认的文本块
return [{
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: 0,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: 0,
},
}];
}
const content = doc.sliceString(0, doc.length);
const delim = "\n∞∞∞";
let pos = 0;
// 检查文档是否以分隔符开始
if (!content.startsWith("∞∞∞")) {
// 如果文档不以分隔符开始,查找第一个分隔符
const firstDelimPos = content.indexOf(delim);
if (firstDelimPos === -1) {
// 如果没有找到分隔符,整个文档作为一个文本块
return [{
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: doc.length,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: doc.length,
},
}];
}
// 创建第一个块(分隔符之前的内容)
blocks.push({
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: firstDelimPos,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: firstDelimPos,
},
});
pos = firstDelimPos;
firstBlockDelimiterSize = 0;
}
while (pos < doc.length) {
const blockStart = content.indexOf(delim, pos);
if (blockStart !== pos) {
// 如果在当前位置没有找到分隔符,可能是文档结尾
break;
}
const langStart = blockStart + delim.length;
const delimiterEnd = content.indexOf("\n", langStart);
if (delimiterEnd < 0) {
console.error("Error parsing blocks. Delimiter didn't end with newline");
break;
}
const langFull = content.substring(langStart, delimiterEnd);
let auto = false;
let lang = langFull;
if (langFull.endsWith("-a")) {
auto = true;
lang = langFull.substring(0, langFull.length - 2);
}
const contentFrom = delimiterEnd + 1;
let blockEnd = content.indexOf(delim, contentFrom);
if (blockEnd < 0) {
blockEnd = doc.length;
}
const block: Block = {
language: {
name: lang || 'text',
auto: auto,
},
content: {
from: contentFrom,
to: blockEnd,
},
delimiter: {
from: blockStart,
to: delimiterEnd + 1,
},
range: {
from: blockStart,
to: blockEnd,
},
};
blocks.push(block);
pos = blockEnd;
// 设置第一个块分隔符的大小(只有当这是第一个有分隔符的块时)
if (blocks.length === 1 && firstBlockDelimiterSize === undefined) {
firstBlockDelimiterSize = block.delimiter.to;
}
}
// 如果没有找到任何块,创建一个默认块
if (blocks.length === 0) {
blocks.push({
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: doc.length,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: doc.length,
},
});
firstBlockDelimiterSize = 0;
}
return blocks;
}
/**
* 获取文档中的所有块
*/
export function getBlocks(state: EditorState): Block[] {
return getBlocksFromString(state);
}
/**
* 获取当前光标所在的块
*/
export function getActiveBlock(state: EditorState): Block | undefined {
const range = state.selection.asSingle().ranges[0];
const blocks = getBlocks(state);
return blocks.find(block =>
block.range.from <= range.head && block.range.to >= range.head
);
}
/**
* 获取第一个块
*/
export function getFirstBlock(state: EditorState): Block | undefined {
const blocks = getBlocks(state);
return blocks[0];
}
/**
* 获取最后一个块
*/
export function getLastBlock(state: EditorState): Block | undefined {
const blocks = getBlocks(state);
return blocks[blocks.length - 1];
}
/**
* 根据位置获取块
*/
export function getBlockFromPos(state: EditorState, pos: number): Block | undefined {
const blocks = getBlocks(state);
return blocks.find(block =>
block.range.from <= pos && block.range.to >= pos
);
}
/**
* 获取块的行信息
*/
export function getBlockLineFromPos(state: EditorState, pos: number) {
const line = state.doc.lineAt(pos);
const block = getBlockFromPos(state, pos);
if (block) {
const firstBlockLine = state.doc.lineAt(block.content.from).number;
return {
line: line.number - firstBlockLine + 1,
col: pos - line.from,
length: line.length,
};
}
return {
line: line.number,
col: pos - line.from,
length: line.length,
};
}
/**
* 创建新的分隔符文本
*/
export function createDelimiter(language: SupportedLanguage, autoDetect = false): string {
const suffix = autoDetect ? AUTO_DETECT_SUFFIX : '';
return `${DELIMITER_PREFIX}${language}${suffix}${DELIMITER_SUFFIX}`;
}
/**
* 验证分隔符格式
*/
export function isValidDelimiter(text: string): boolean {
DELIMITER_REGEX.lastIndex = 0;
return DELIMITER_REGEX.test(text);
}
/**
* 解析分隔符信息
*/
export function parseDelimiter(delimiterText: string): { language: SupportedLanguage; auto: boolean } | null {
DELIMITER_REGEX.lastIndex = 0;
const match = DELIMITER_REGEX.exec(delimiterText);
if (!match) {
return null;
}
const languageName = match[1];
const isAuto = match[2] === AUTO_DETECT_SUFFIX;
const validLanguage = SUPPORTED_LANGUAGES.includes(languageName as SupportedLanguage)
? languageName as SupportedLanguage
: 'text';
return {
language: validLanguage,
auto: isAuto
};
}

View File

@@ -0,0 +1,224 @@
/**
* 块隔离选择功能
*/
import { ViewPlugin, Decoration } from "@codemirror/view";
import { StateField, StateEffect, RangeSetBuilder, EditorSelection, EditorState, Transaction } from "@codemirror/state";
import { selectAll as defaultSelectAll } from "@codemirror/commands";
import { Command } from "@codemirror/view";
import { getActiveNoteBlock, blockState } from "./state";
/**
* 当用户按下 Ctrl+A 时,我们希望首先选择整个块。但如果整个块已经被选中,
* 我们希望改为选择整个文档。这对于空块不起作用,因为整个块已经被选中(因为它是空的)。
* 因此我们使用 StateField 来跟踪空块是否被选中,并添加手动行装饰来视觉上指示空块被选中。
*/
/**
* 空块选择状态字段
*/
export const emptyBlockSelected = StateField.define<number | null>({
create: () => {
return null;
},
update(value, tr) {
if (tr.selection) {
// 如果选择改变,重置状态
return null;
} else {
for (let e of tr.effects) {
if (e.is(setEmptyBlockSelected)) {
// 切换状态为 true
return e.value;
}
}
}
return value;
},
provide() {
return ViewPlugin.fromClass(class {
decorations: any;
constructor(view: any) {
this.decorations = emptyBlockSelectedDecorations(view);
}
update(update: any) {
this.decorations = emptyBlockSelectedDecorations(update.view);
}
}, {
decorations: v => v.decorations
});
}
});
/**
* 可以分派的效果来设置空块选择状态
*/
const setEmptyBlockSelected = StateEffect.define<number>();
/**
* 空块选择装饰
*/
const decoration = Decoration.line({
attributes: { class: "code-block-empty-selected" }
});
function emptyBlockSelectedDecorations(view: any) {
const selectionPos = view.state.field(emptyBlockSelected);
const builder = new RangeSetBuilder();
if (selectionPos !== null) {
const line = view.state.doc.lineAt(selectionPos);
builder.add(line.from, line.from, decoration);
}
return builder.finish();
}
/**
* 块隔离的选择全部功能
*/
export const selectAll: Command = ({ state, dispatch }) => {
const range = state.selection.asSingle().ranges[0];
const block = getActiveNoteBlock(state);
// 如果没有找到块,使用默认的全选
if (!block) {
return defaultSelectAll({ state, dispatch });
}
// 单独处理空块
if (block.content.from === block.content.to) {
// 检查是否已经按过 Ctrl+A
if (state.field(emptyBlockSelected)) {
// 如果活动块已经标记为选中,我们想要选择整个缓冲区
return defaultSelectAll({ state, dispatch });
} else if (range.empty) {
// 如果空块没有被选中,标记为选中
// 我们检查 range.empty 的原因是如果文档末尾有一个空块
// 用户按两次 Ctrl+A 使整个缓冲区被选中,活动块仍然是空的
// 但我们不想标记它为选中
dispatch({
effects: setEmptyBlockSelected.of(block.content.from)
});
return true;
}
return true;
}
// 检查是否已经选中了块的所有文本,在这种情况下我们想要选择整个文档的所有文本
if (range.from === block.content.from && range.to === block.content.to) {
return defaultSelectAll({ state, dispatch });
}
// 选择当前块的所有内容
dispatch(state.update({
selection: { anchor: block.content.from, head: block.content.to },
userEvent: "select"
}));
return true;
};
/**
* 块感知的选择扩展功能
* 使用事务过滤器来确保选择不会跨越块边界
*/
export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) => {
// 只处理选择变化的事务,并且忽略我们自己生成的事务
if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === "select.block-boundary") {
return tr;
}
const state = tr.startState;
try {
const blocks = state.field(blockState);
if (blocks.length === 0) {
return tr;
}
// 检查是否需要修正选择
let needsCorrection = false;
const correctedRanges = tr.selection.ranges.map((range: any) => {
// 为选择范围的开始和结束位置找到对应的块
const fromBlock = getBlockAtPos(state, range.from);
const toBlock = getBlockAtPos(state, range.to);
// 如果选择开始或结束在分隔符内,跳过边界检查
if (isInDelimiter(state, range.from) || isInDelimiter(state, range.to)) {
return range;
}
// 如果选择跨越了多个块,需要限制选择
if (fromBlock && toBlock && fromBlock !== toBlock) {
// 选择跨越了多个块,限制到起始块
needsCorrection = true;
return EditorSelection.range(
Math.max(range.from, fromBlock.content.from),
Math.min(range.to, fromBlock.content.to)
);
}
// 如果选择在一个块内,确保不超出块边界
if (fromBlock) {
let newFrom = Math.max(range.from, fromBlock.content.from);
let newTo = Math.min(range.to, fromBlock.content.to);
if (newFrom !== range.from || newTo !== range.to) {
needsCorrection = true;
return EditorSelection.range(newFrom, newTo);
}
}
return range;
});
if (needsCorrection) {
// 返回修正后的事务
return {
...tr,
selection: EditorSelection.create(correctedRanges, tr.selection.mainIndex),
annotations: tr.annotations.concat(Transaction.userEvent.of("select.block-boundary"))
};
}
} catch (error) {
// 如果出现错误,返回原始事务
console.warn("Block boundary check failed:", error);
}
return tr;
});
/**
* 辅助函数:根据位置获取块
*/
function getBlockAtPos(state: any, pos: number) {
const blocks = state.field(blockState);
return blocks.find((block: any) =>
block.content.from <= pos && block.content.to >= pos
);
}
/**
* 辅助函数:检查位置是否在块分隔符内
*/
function isInDelimiter(state: any, pos: number) {
const blocks = state.field(blockState);
return blocks.some((block: any) =>
block.delimiter.from <= pos && block.delimiter.to >= pos
);
}
/**
* 获取块选择扩展
*/
export function getBlockSelectExtensions() {
return [
emptyBlockSelected,
// 禁用块边界检查以避免递归更新问题
// blockAwareSelection,
];
}

View File

@@ -0,0 +1,59 @@
/**
* Block 状态管理
*/
import { StateField, EditorState } from '@codemirror/state';
import { Block } from './types';
import { getBlocks } from './parser';
/**
* 块状态字段,跟踪文档中的所有块
*/
export const blockState = StateField.define<Block[]>({
create(state: EditorState): Block[] {
return getBlocks(state);
},
update(blocks: Block[], transaction): Block[] {
// 如果块为空,可能意味着我们没有获得解析的语法树,那么我们希望在所有更新时更新块(而不仅仅是文档更改)
if (transaction.docChanged || blocks.length === 0) {
return getBlocks(transaction.state);
}
return blocks;
},
});
/**
* 获取当前活动的块
*/
export function getActiveNoteBlock(state: EditorState): Block | undefined {
// 找到光标所在的块
const range = state.selection.asSingle().ranges[0];
return state.field(blockState).find(block =>
block.range.from <= range.head && block.range.to >= range.head
);
}
/**
* 获取第一个块
*/
export function getFirstNoteBlock(state: EditorState): Block | undefined {
return state.field(blockState)[0];
}
/**
* 获取最后一个块
*/
export function getLastNoteBlock(state: EditorState): Block | undefined {
const blocks = state.field(blockState);
return blocks[blocks.length - 1];
}
/**
* 根据位置获取块
*/
export function getNoteBlockFromPos(state: EditorState, pos: number): Block | undefined {
return state.field(blockState).find(block =>
block.range.from <= pos && block.range.to >= pos
);
}

View File

@@ -0,0 +1,224 @@
/* 块层样式 */
.code-blocks-layer {
width: 100%;
z-index: -1;
}
.code-blocks-layer .block-even,
.code-blocks-layer .block-odd {
width: 100%;
box-sizing: content-box;
left: 0;
margin-left: 0;
}
.code-blocks-layer .block-even:first-child {
border-top: none;
}
/* 块开始装饰 */
.code-block-start {
height: 12px;
position: relative;
z-index: 0;
}
.code-block-start.first {
height: 0px;
}
/* 默认块样式*/
.code-blocks-layer .block-even {
background: #252B37 !important;
border-top: 1px solid #1e222a;
}
.code-blocks-layer .block-odd {
background: #213644 !important;
border-top: 1px solid #1e222a;
}
/* 浅色主题块样式 */
:root[data-theme="light"] .code-blocks-layer .block-even {
background: #ffffff !important;
border-top: 1px solid #dfdfdf;
}
:root[data-theme="light"] .code-blocks-layer .block-odd {
background: #f4f8f4 !important;
border-top: 1px solid #dfdfdf;
}
/* 确保深色主题样式 */
:root[data-theme="dark"] .code-blocks-layer .block-even {
background: #252B37 !important;
border-top: 1px solid #1e222a;
}
:root[data-theme="dark"] .code-blocks-layer .block-odd {
background: #213644 !important;
border-top: 1px solid #1e222a;
}
/* 空块选择样式 */
.code-block-empty-selected {
background-color: #0865a9aa !important;
border-radius: 3px;
}
:root[data-theme="light"] .code-block-empty-selected {
background-color: #77baff8c !important;
}
/* 选择样式 */
.cm-activeLine.code-empty-block-selected {
background-color: #0865a9aa;
}
:root[data-theme="light"] .cm-activeLine.code-empty-block-selected {
background-color: #77baff8c;
}
/* 光标样式 */
.cm-cursor, .cm-dropCursor {
border-left-width: 2px;
padding-top: 4px;
margin-top: -2px;
}
/* 内容区域样式 */
.cm-content {
padding-top: 4px;
}
/* 装订线样式 */
.cm-gutters {
padding: 0 2px 0 4px;
user-select: none;
background-color: transparent;
color: rgba(255, 255, 255, 0.15);
border: none;
}
.cm-activeLineGutter {
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
}
/* 浅色主题装订线 */
:root[data-theme="light"] .cm-gutters {
background-color: transparent;
color: rgba(0, 0, 0, 0.25);
border: none;
border-right: 1px solid rgba(0, 0, 0, 0.05);
}
:root[data-theme="light"] .cm-activeLineGutter {
background-color: transparent;
color: rgba(0, 0, 0, 0.6);
}
/* 活动行样式 */
.cm-activeLine {
background-color: rgba(255, 255, 255, 0.04);
}
:root[data-theme="light"] .cm-activeLine {
background-color: rgba(0, 0, 0, 0.04);
}
/* 选择背景 */
.cm-selectionBackground {
background-color: #225377aa;
}
.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground {
background-color: #0865a9aa;
}
:root[data-theme="light"] .cm-selectionBackground {
background: #b2c2ca85;
}
:root[data-theme="light"] .cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground {
background: #77baff8c;
}
/* 编辑器焦点样式 */
.cm-editor.cm-focused {
outline: none;
}
/* 折叠装订线样式 */
.cm-foldGutter {
margin-left: 0px;
}
.cm-foldGutter .cm-gutterElement {
opacity: 0;
transition: opacity 400ms;
}
.cm-gutters:hover .cm-gutterElement {
opacity: 1;
}
/* 匹配括号样式 */
.cm-focused .cm-matchingBracket,
.cm-focused .cm-nonmatchingBracket {
outline: 0.5px solid #8fbcbb;
}
.cm-focused .cm-matchingBracket {
background-color: rgba(255, 255, 255, 0.1);
color: inherit;
}
.cm-focused .cm-nonmatchingBracket {
outline: 0.5px solid #bc8f8f;
}
/* 搜索匹配样式 */
.cm-searchMatch {
background-color: transparent;
outline: 1px solid #8fbcbb;
}
.cm-searchMatch.cm-searchMatch-selected {
background-color: #d8dee9;
color: #2e3440;
}
/* 选择匹配样式 */
.cm-selectionMatch {
background-color: #50606D;
}
/* 折叠占位符样式 */
.cm-foldPlaceholder {
background-color: transparent;
border: none;
color: #ddd;
}
/* 工具提示样式 */
.cm-tooltip {
border: none;
background-color: #3b4252;
}
.cm-tooltip .cm-tooltip-arrow:before {
border-top-color: transparent;
border-bottom-color: transparent;
}
.cm-tooltip .cm-tooltip-arrow:after {
border-top-color: #3b4252;
border-bottom-color: #3b4252;
}
/* 自动完成工具提示 */
.cm-tooltip-autocomplete > ul > li[aria-selected] {
background-color: rgba(255, 255, 255, 0.04);
color: #4c566a;
}

View File

@@ -0,0 +1,144 @@
/**
* Block 结构
*/
export interface Block {
language: {
name: string;
auto: boolean;
};
content: {
from: number;
to: number;
};
delimiter: {
from: number;
to: number;
};
range: {
from: number;
to: number;
};
}
/**
* 支持的语言类型
*/
export type SupportedLanguage =
| 'text'
| 'javascript'
| 'typescript'
| 'python'
| 'html'
| 'css'
| 'json'
| 'markdown'
| 'shell'
| 'sql'
| 'yaml'
| 'xml'
| 'php'
| 'java'
| 'cpp'
| 'c'
| 'go'
| 'rust'
| 'ruby';
/**
* 支持的语言列表
*/
export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
'text',
'javascript',
'typescript',
'python',
'html',
'css',
'json',
'markdown',
'shell',
'sql',
'yaml',
'xml',
'php',
'java',
'cpp',
'c',
'go',
'rust',
'ruby'
];
/**
* 创建块的选项
*/
export interface CreateBlockOptions {
language?: SupportedLanguage;
auto?: boolean;
content?: string;
}
/**
* 编辑器配置选项
*/
export interface EditorOptions {
defaultBlockToken: string;
defaultBlockAutoDetect: boolean;
}
// 语言信息接口
export interface LanguageInfo {
name: SupportedLanguage;
auto: boolean; // 是否自动检测语言
}
// 位置范围接口
export interface Range {
from: number;
to: number;
}
// 代码块核心接口
export interface CodeBlock {
language: LanguageInfo;
content: Range; // 内容区域
delimiter: Range; // 分隔符区域
range: Range; // 整个块区域(包括分隔符和内容)
}
// 代码块解析选项
export interface ParseOptions {
fallbackLanguage?: SupportedLanguage;
enableAutoDetection?: boolean;
}
// 分隔符格式常量
export const DELIMITER_REGEX = /^\n∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/gm;
export const DELIMITER_PREFIX = '\n∞∞∞';
export const DELIMITER_SUFFIX = '\n';
export const AUTO_DETECT_SUFFIX = '-a';
// 代码块操作类型
export type BlockOperation =
| 'insert-after'
| 'insert-before'
| 'delete'
| 'move-up'
| 'move-down'
| 'change-language';
// 代码块状态更新事件
export interface BlockStateUpdate {
blocks: CodeBlock[];
activeBlockIndex: number;
operation?: BlockOperation;
}
// 块导航方向
export type NavigationDirection = 'next' | 'previous' | 'first' | 'last';
// 语言检测结果
export interface LanguageDetectionResult {
language: SupportedLanguage;
confidence: number;
}