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

@@ -23,28 +23,19 @@ import {defaultKeymap, history, historyKeymap,} from '@codemirror/commands';
import {highlightSelectionMatches} from '@codemirror/search';
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
import {lintKeymap} from '@codemirror/lint';
import {searchVisibilityField, vscodeSearch, customSearchKeymap} from './vscodeSearch';
import {customSearchKeymap, searchVisibilityField, vscodeSearch} from './vscodeSearch';
import {hyperLink} from './hyperlink';
import {color} from './colorSelector';
import {createTextHighlighter} from './textHighlightExtension';
import {minimap} from './minimap';
import {createCodeBlockExtension} from './codeblock/index';
import {foldingOnIndent} from './foldExtension'
import rainbowBrackets from "./rainbowBrackets";
import {createCodeBlastExtension} from './codeblast';
// 基本编辑器设置
export const createBasicSetup = (): Extension[] => {
return [
vscodeSearch,
searchVisibilityField,
hyperLink,
color,
...createTextHighlighter('hl'),
minimap({
displayText: 'characters',
showOverlay: 'always',
autohide: false,
}),
// 基础UI
lineNumbers(),
highlightActiveLineGutter(),
@@ -74,6 +65,30 @@ export const createBasicSetup = (): Extension[] => {
// 自动完成
autocompletion(),
vscodeSearch,
searchVisibilityField,
foldingOnIndent,
rainbowBrackets(),
createCodeBlastExtension({
effect: 1,
shake: true,
maxParticles: 300,
shakeIntensity: 3
}),
hyperLink,
color,
...createTextHighlighter('hl'),
minimap({
displayText: 'characters',
showOverlay: 'always',
autohide: false,
}),
createCodeBlockExtension({
showBackground: true,
enableAutoDetection: true,
}),
// 键盘映射
keymap.of([
...customSearchKeymap,

View File

@@ -0,0 +1,368 @@
import { Extension } from '@codemirror/state';
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import './styles.css';
// 粒子接口定义
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
alpha: number;
size: number;
color: number[];
theta?: number;
drag?: number;
wander?: number;
}
// 配置接口
interface CodeBlastConfig {
effect?: 1 | 2; // effect 1: 随机粒子, effect 2: 追逐粒子
shake?: boolean; // 启用震动效果
maxParticles?: number; // 最大粒子数
particleRange?: { min: number; max: number }; // 粒子大小范围
shakeIntensity?: number; // 震动强度
gravity?: number; // 重力加速度 (仅 effect: 1)
alphaFadeout?: number; // 粒子透明度衰减
velocityRange?: { // 粒子速度范围
x: [number, number]; // x轴方向速度范围
y: [number, number]; // y轴方向速度范围
};
}
class CodeBlastEffect {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private particles: Particle[] = [];
private particlePointer = 0;
private isActive = false;
private lastTime = 0;
private shakeTime = 0;
private shakeTimeMax = 0;
private animationId: number | null = null;
// 配置参数
private config: Required<CodeBlastConfig> = {
effect: 2,
shake: true,
maxParticles: 500,
particleRange: { min: 5, max: 10 },
shakeIntensity: 5,
gravity: 0.08,
alphaFadeout: 0.96,
velocityRange: {
x: [-1, 1],
y: [-3.5, -1.5]
}
};
constructor(config?: CodeBlastConfig) {
if (config) {
this.config = { ...this.config, ...config };
}
this.particles = new Array(this.config.maxParticles);
}
private getRGBComponents(element: Element): number[] {
try {
const style = getComputedStyle(element);
const color = style.color;
if (color) {
const match = color.match(/(\d+),\s*(\d+),\s*(\d+)/);
if (match) {
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
}
}
} catch (e) {
console.warn('Failed to get RGB components:', e);
}
return [255, 255, 255]; // 默认白色
}
private random(min: number, max?: number): number {
if (max === undefined) {
max = min;
min = 0;
}
return min + Math.floor(Math.random() * (max - min + 1));
}
private createParticle(x: number, y: number, color: number[]): Particle {
const particle: Particle = {
x,
y: y + 10,
alpha: 1,
color,
vx: 0,
vy: 0,
size: 0
};
if (this.config.effect === 1) {
particle.size = this.random(2, 4);
particle.vx = this.config.velocityRange.x[0] +
Math.random() * (this.config.velocityRange.x[1] - this.config.velocityRange.x[0]);
particle.vy = this.config.velocityRange.y[0] +
Math.random() * (this.config.velocityRange.y[1] - this.config.velocityRange.y[0]);
} else if (this.config.effect === 2) {
particle.size = this.random(2, 8);
particle.drag = 0.92;
particle.vx = this.random(-3, 3);
particle.vy = this.random(-3, 3);
particle.wander = 0.15;
particle.theta = this.random(0, 360) * Math.PI / 180;
}
return particle;
}
private spawnParticles(view: EditorView): void {
if (!this.ctx) return;
try {
// 获取光标位置
const selection = view.state.selection.main;
const coords = view.coordsAtPos(selection.head);
if (!coords) return;
// 获取光标处的元素来确定颜色
const element = document.elementFromPoint(coords.left, coords.top);
const color = element ? this.getRGBComponents(element) : [255, 255, 255];
const numParticles = this.random(
this.config.particleRange.min,
this.config.particleRange.max
);
for (let i = 0; i < numParticles; i++) {
this.particles[this.particlePointer] = this.createParticle(
coords.left + 10,
coords.top,
color
);
this.particlePointer = (this.particlePointer + 1) % this.config.maxParticles;
}
} catch (error) {
// 如果在更新期间无法读取坐标,静默忽略
console.warn('Failed to spawn particles:', error);
}
}
private effect1(particle: Particle): void {
if (!this.ctx) return;
particle.vy += this.config.gravity;
particle.x += particle.vx;
particle.y += particle.vy;
particle.alpha *= this.config.alphaFadeout;
this.ctx.fillStyle = `rgba(${particle.color[0]}, ${particle.color[1]}, ${particle.color[2]}, ${particle.alpha})`;
this.ctx.fillRect(
Math.round(particle.x - 1),
Math.round(particle.y - 1),
particle.size,
particle.size
);
}
private effect2(particle: Particle): void {
if (!this.ctx || particle.theta === undefined || particle.drag === undefined) return;
particle.x += particle.vx;
particle.y += particle.vy;
particle.vx *= particle.drag;
particle.vy *= particle.drag;
particle.theta += this.random(-0.5, 0.5);
particle.vx += Math.sin(particle.theta) * 0.1;
particle.vy += Math.cos(particle.theta) * 0.1;
particle.size *= 0.96;
this.ctx.fillStyle = `rgba(${particle.color[0]}, ${particle.color[1]}, ${particle.color[2]}, ${particle.alpha})`;
this.ctx.beginPath();
this.ctx.arc(
Math.round(particle.x - 1),
Math.round(particle.y - 1),
particle.size,
0,
2 * Math.PI
);
this.ctx.fill();
}
private drawParticles(): void {
if (!this.ctx) return;
for (let i = 0; i < this.particles.length; i++) {
const particle = this.particles[i];
if (!particle || particle.alpha < 0.01 || particle.size <= 0.5) {
continue;
}
if (this.config.effect === 1) {
this.effect1(particle);
} else if (this.config.effect === 2) {
this.effect2(particle);
}
}
}
private shake(view: EditorView, duration: number): void {
if (!this.config.shake) return;
this.shakeTime = this.shakeTimeMax = duration;
const editorElement = view.dom;
const shakeAnimation = () => {
if (this.shakeTime <= 0) {
editorElement.style.transform = '';
return;
}
const magnitude = (this.shakeTime / this.shakeTimeMax) * this.config.shakeIntensity;
const shakeX = this.random(-magnitude, magnitude);
const shakeY = this.random(-magnitude, magnitude);
editorElement.style.transform = `translate(${shakeX}px, ${shakeY}px)`;
this.shakeTime -= 0.016; // ~60fps
requestAnimationFrame(shakeAnimation);
};
requestAnimationFrame(shakeAnimation);
}
private loop = (): void => {
if (!this.isActive || !this.ctx || !this.canvas) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.drawParticles();
this.animationId = requestAnimationFrame(this.loop);
};
public init(view: EditorView): void {
if (this.isActive) return;
this.isActive = true;
if (!this.canvas) {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
if (!this.ctx) {
console.error('Failed to get canvas context');
return;
}
this.canvas.id = 'code-blast-canvas';
this.canvas.style.position = 'absolute';
this.canvas.style.top = '0';
this.canvas.style.left = '0';
this.canvas.style.zIndex = '1';
this.canvas.style.pointerEvents = 'none';
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
document.body.appendChild(this.canvas);
this.loop();
}
}
public destroy(): void {
this.isActive = false;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.canvas) {
this.canvas.remove();
this.canvas = null;
this.ctx = null;
}
}
public onDocumentChange(view: EditorView): void {
if (this.config.shake) {
this.shake(view, 0.3);
}
// 使用 requestIdleCallback 或 setTimeout 延迟执行粒子生成
// 确保在 DOM 更新完成后再读取坐标
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
this.spawnParticles(view);
});
} else {
setTimeout(() => {
this.spawnParticles(view);
}, 16); // ~60fps
}
}
public updateCanvasSize(): void {
if (this.canvas) {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
}
}
// 节流函数
function throttle<T extends (...args: any[]) => void>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return function(this: any, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// 创建 CodeBlast 扩展
export function createCodeBlastExtension(config?: CodeBlastConfig): Extension {
let effect: CodeBlastEffect | null = null;
const plugin = ViewPlugin.fromClass(class {
private throttledOnChange: (view: EditorView) => void;
constructor(private view: EditorView) {
effect = new CodeBlastEffect(config);
effect.init(view);
this.throttledOnChange = throttle((view: EditorView) => {
effect?.onDocumentChange(view);
}, 100);
// 监听窗口大小变化
window.addEventListener('resize', this.handleResize);
}
update(update: ViewUpdate) {
if (update.docChanged) {
// 延迟执行,确保更新完成后再触发效果
setTimeout(() => {
this.throttledOnChange(this.view);
}, 0);
}
}
destroy() {
effect?.destroy();
effect = null;
window.removeEventListener('resize', this.handleResize);
}
private handleResize = () => {
effect?.updateCanvasSize();
};
});
return plugin;
}
// 默认导出
export const codeBlast = createCodeBlastExtension();

View File

@@ -0,0 +1,52 @@
#code-blast-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
pointer-events: none;
background: transparent;
}
/* 确保编辑器在震动时不会影响布局 */
.cm-editor {
transition: transform 0.1s ease-out;
}
/* 打字机效果的粒子样式 */
.code-blast-particle {
position: absolute;
pointer-events: none;
border-radius: 50%;
animation: particle-fade 2s ease-out forwards;
}
@keyframes particle-fade {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* 震动效果的缓动 */
.code-blast-shake {
animation: shake 0.3s ease-out;
}
@keyframes shake {
0%, 100% { transform: translate(0, 0); }
10% { transform: translate(-2px, -1px); }
20% { transform: translate(2px, 1px); }
30% { transform: translate(-1px, 2px); }
40% { transform: translate(1px, -2px); }
50% { transform: translate(-2px, 1px); }
60% { transform: translate(2px, -1px); }
70% { transform: translate(-1px, -2px); }
80% { transform: translate(1px, 2px); }
90% { transform: translate(-2px, -1px); }
}

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;
}

View File

@@ -0,0 +1,37 @@
import {foldService} from '@codemirror/language';
export const foldingOnIndent = foldService.of((state, from, to) => {
const line = state.doc.lineAt(from) // First line
const lines = state.doc.lines // Number of lines in the document
const indent = line.text.search(/\S|$/) // Indent level of the first line
let foldStart = from // Start of the fold
let foldEnd = to // End of the fold
// Check the next line if it is on a deeper indent level
// If it is, check the next line and so on
// If it is not, go on with the foldEnd
let nextLine = line
while (nextLine.number < lines) {
nextLine = state.doc.line(nextLine.number + 1) // Next line
const nextIndent = nextLine.text.search(/\S|$/) // Indent level of the next line
// If the next line is on a deeper indent level, add it to the fold
if (nextIndent > indent) {
foldEnd = nextLine.to // Set the fold end to the end of the next line
} else {
break // If the next line is not on a deeper indent level, stop
}
}
// If the fold is only one line, don't fold it
if (state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number) {
return null
}
// Set the fold start to the end of the first line
// With this, the fold will not include the first line
foldStart = line.to
// Return a fold that covers the entire indent level
return {from: foldStart, to: foldEnd}
});

View File

@@ -3,4 +3,6 @@ export * from './tabExtension';
export * from './wheelZoomExtension';
export * from './statsExtension';
export * from './autoSaveExtension';
export * from './fontExtension';
export * from './fontExtension';
export * from './codeblast';
export * from './codeblock';

View File

@@ -0,0 +1,87 @@
import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view';
import { Range } from '@codemirror/state';
// 生成彩虹颜色数组
function generateColors(): string[] {
return [
'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet',
];
}
class RainbowBracketsView {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.getBracketDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = this.getBracketDecorations(update.view);
}
}
private getBracketDecorations(view: EditorView): DecorationSet {
const { doc } = view.state;
const decorations: Range<Decoration>[] = [];
const stack: { type: string; from: number }[] = [];
const colors = generateColors();
// 遍历文档内容
for (let pos = 0; pos < doc.length; pos++) {
const char = doc.sliceString(pos, pos + 1);
// 遇到开括号
if (char === '(' || char === '[' || char === '{') {
stack.push({ type: char, from: pos });
}
// 遇到闭括号
else if (char === ')' || char === ']' || char === '}') {
const open = stack.pop();
const matchingBracket = this.getMatchingBracket(char);
if (open && open.type === matchingBracket) {
const color = colors[stack.length % colors.length];
const className = `cm-rainbow-bracket-${color}`;
// 为开括号和闭括号添加装饰
decorations.push(
Decoration.mark({ class: className }).range(open.from, open.from + 1),
Decoration.mark({ class: className }).range(pos, pos + 1)
);
}
}
}
return Decoration.set(decorations.sort((a, b) => a.from - b.from));
}
private getMatchingBracket(closingBracket: string): string | null {
switch (closingBracket) {
case ')': return '(';
case ']': return '[';
case '}': return '{';
default: return null;
}
}
}
const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
decorations: (v) => v.decorations,
});
export default function rainbowBrackets() {
return [
rainbowBracketsPlugin,
EditorView.baseTheme({
// 为每种颜色定义CSS样式
'.cm-rainbow-bracket-red': { color: 'red' },
'.cm-rainbow-bracket-orange': { color: 'orange' },
'.cm-rainbow-bracket-yellow': { color: 'yellow' },
'.cm-rainbow-bracket-green': { color: 'green' },
'.cm-rainbow-bracket-blue': { color: 'blue' },
'.cm-rainbow-bracket-indigo': { color: 'indigo' },
'.cm-rainbow-bracket-violet': { color: 'violet' },
}),
];
}

View File

@@ -73,11 +73,17 @@ export const draculaTheme = EditorView.theme({
},
'.cm-gutters': {
backgroundColor: config.lineNumberBackground,
backgroundColor: 'rgba(0,0,0, 0.1)',
color: config.foreground,
border: 'none'
border: 'none',
padding: '0 2px 0 4px',
userSelect: 'none',
},
'.cm-activeLineGutter': {
// backgroundColor: config.background
backgroundColor: "transparent",
color: 'rgba(255,255,255, 0.6)'
},
'.cm-activeLineGutter': {backgroundColor: config.background},
'.cm-lineNumbers .cm-gutterElement': {color: config.lineNumber},
'.cm-lineNumbers .cm-activeLineGutter': {color: config.lineNumberActive},