✨ Add code blocks and rainbow bracket extensions
This commit is contained in:
@@ -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,
|
||||
|
368
frontend/src/views/editor/extensions/codeblast/index.ts
Normal file
368
frontend/src/views/editor/extensions/codeblast/index.ts
Normal 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();
|
52
frontend/src/views/editor/extensions/codeblast/styles.css
Normal file
52
frontend/src/views/editor/extensions/codeblast/styles.css
Normal 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); }
|
||||
}
|
355
frontend/src/views/editor/extensions/codeblock/commands.ts
Normal file
355
frontend/src/views/editor/extensions/codeblock/commands.ts
Normal 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;
|
||||
}
|
234
frontend/src/views/editor/extensions/codeblock/copyPaste.ts
Normal file
234
frontend/src/views/editor/extensions/codeblock/copyPaste.ts
Normal 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
|
||||
}
|
||||
];
|
||||
}
|
216
frontend/src/views/editor/extensions/codeblock/decorations.ts
Normal file
216
frontend/src/views/editor/extensions/codeblock/decorations.ts
Normal 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;
|
||||
}
|
250
frontend/src/views/editor/extensions/codeblock/index.ts
Normal file
250
frontend/src/views/editor/extensions/codeblock/index.ts
Normal 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;
|
412
frontend/src/views/editor/extensions/codeblock/parser.ts
Normal file
412
frontend/src/views/editor/extensions/codeblock/parser.ts
Normal 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
|
||||
};
|
||||
}
|
224
frontend/src/views/editor/extensions/codeblock/selectAll.ts
Normal file
224
frontend/src/views/editor/extensions/codeblock/selectAll.ts
Normal 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,
|
||||
];
|
||||
}
|
59
frontend/src/views/editor/extensions/codeblock/state.ts
Normal file
59
frontend/src/views/editor/extensions/codeblock/state.ts
Normal 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
|
||||
);
|
||||
}
|
224
frontend/src/views/editor/extensions/codeblock/styles.css
Normal file
224
frontend/src/views/editor/extensions/codeblock/styles.css
Normal 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;
|
||||
}
|
144
frontend/src/views/editor/extensions/codeblock/types.ts
Normal file
144
frontend/src/views/editor/extensions/codeblock/types.ts
Normal 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;
|
||||
}
|
37
frontend/src/views/editor/extensions/foldExtension.ts
Normal file
37
frontend/src/views/editor/extensions/foldExtension.ts
Normal 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}
|
||||
});
|
@@ -4,3 +4,5 @@ export * from './wheelZoomExtension';
|
||||
export * from './statsExtension';
|
||||
export * from './autoSaveExtension';
|
||||
export * from './fontExtension';
|
||||
export * from './codeblast';
|
||||
export * from './codeblock';
|
87
frontend/src/views/editor/extensions/rainbowBrackets.ts
Normal file
87
frontend/src/views/editor/extensions/rainbowBrackets.ts
Normal 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' },
|
||||
}),
|
||||
];
|
||||
}
|
@@ -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},
|
||||
|
Reference in New Issue
Block a user