✨ 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 {highlightSelectionMatches} from '@codemirror/search';
|
||||||
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
|
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
|
||||||
import {lintKeymap} from '@codemirror/lint';
|
import {lintKeymap} from '@codemirror/lint';
|
||||||
import {searchVisibilityField, vscodeSearch, customSearchKeymap} from './vscodeSearch';
|
import {customSearchKeymap, searchVisibilityField, vscodeSearch} from './vscodeSearch';
|
||||||
|
|
||||||
import {hyperLink} from './hyperlink';
|
import {hyperLink} from './hyperlink';
|
||||||
import {color} from './colorSelector';
|
import {color} from './colorSelector';
|
||||||
import {createTextHighlighter} from './textHighlightExtension';
|
import {createTextHighlighter} from './textHighlightExtension';
|
||||||
import {minimap} from './minimap';
|
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[] => {
|
export const createBasicSetup = (): Extension[] => {
|
||||||
return [
|
return [
|
||||||
vscodeSearch,
|
|
||||||
searchVisibilityField,
|
|
||||||
|
|
||||||
hyperLink,
|
|
||||||
color,
|
|
||||||
...createTextHighlighter('hl'),
|
|
||||||
minimap({
|
|
||||||
displayText: 'characters',
|
|
||||||
showOverlay: 'always',
|
|
||||||
autohide: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 基础UI
|
// 基础UI
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
highlightActiveLineGutter(),
|
highlightActiveLineGutter(),
|
||||||
@@ -74,6 +65,30 @@ export const createBasicSetup = (): Extension[] => {
|
|||||||
// 自动完成
|
// 自动完成
|
||||||
autocompletion(),
|
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([
|
keymap.of([
|
||||||
...customSearchKeymap,
|
...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}
|
||||||
|
});
|
@@ -3,4 +3,6 @@ export * from './tabExtension';
|
|||||||
export * from './wheelZoomExtension';
|
export * from './wheelZoomExtension';
|
||||||
export * from './statsExtension';
|
export * from './statsExtension';
|
||||||
export * from './autoSaveExtension';
|
export * from './autoSaveExtension';
|
||||||
export * from './fontExtension';
|
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': {
|
'.cm-gutters': {
|
||||||
backgroundColor: config.lineNumberBackground,
|
backgroundColor: 'rgba(0,0,0, 0.1)',
|
||||||
color: config.foreground,
|
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-gutterElement': {color: config.lineNumber},
|
||||||
'.cm-lineNumbers .cm-activeLineGutter': {color: config.lineNumberActive},
|
'.cm-lineNumbers .cm-activeLineGutter': {color: config.lineNumberActive},
|
||||||
|
Reference in New Issue
Block a user