diff --git a/frontend/src/views/editor/extensions/basicSetup.ts b/frontend/src/views/editor/extensions/basicSetup.ts index aa8d92a..f79b8bd 100644 --- a/frontend/src/views/editor/extensions/basicSetup.ts +++ b/frontend/src/views/editor/extensions/basicSetup.ts @@ -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, diff --git a/frontend/src/views/editor/extensions/codeblast/index.ts b/frontend/src/views/editor/extensions/codeblast/index.ts new file mode 100644 index 0000000..d259b40 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblast/index.ts @@ -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 = { + 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 void>( + func: T, + limit: number +): (...args: Parameters) => void { + let inThrottle: boolean; + return function(this: any, ...args: Parameters) { + 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(); \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblast/styles.css b/frontend/src/views/editor/extensions/codeblast/styles.css new file mode 100644 index 0000000..f48973e --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblast/styles.css @@ -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); } +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/commands.ts b/frontend/src/views/editor/extensions/codeblock/commands.ts new file mode 100644 index 0000000..d6e2437 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/commands.ts @@ -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; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/copyPaste.ts b/frontend/src/views/editor/extensions/codeblock/copyPaste.ts new file mode 100644 index 0000000..855ae83 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/copyPaste.ts @@ -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 + } + ]; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/decorations.ts b/frontend/src/views/editor/extensions/codeblock/decorations.ts new file mode 100644 index 0000000..6d50a78 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/decorations.ts @@ -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(); + + 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; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/index.ts b/frontend/src/views/editor/extensions/codeblock/index.ts new file mode 100644 index 0000000..ba17130 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/index.ts @@ -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; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/parser.ts b/frontend/src/views/editor/extensions/codeblock/parser.ts new file mode 100644 index 0000000..ea3290d --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/parser.ts @@ -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 = { + javascript: [ + /\b(function|const|let|var|class|extends|import|export|async|await)\b/, + /\b(console\.log|document\.|window\.)\b/, + /=>\s*[{(]/ + ], + typescript: [ + /\b(interface|type|enum|namespace|implements|declare)\b/, + /:\s*(string|number|boolean|object|any)\b/, + /<[A-Z][a-zA-Z0-9<>,\s]*>/ + ], + python: [ + /\b(def|class|import|from|if __name__|print|len|range)\b/, + /^\s*#.*$/m, + /\b(True|False|None)\b/ + ], + java: [ + /\b(public|private|protected|static|final|class|interface)\b/, + /\b(System\.out\.println|String|int|void)\b/, + /import\s+[a-zA-Z0-9_.]+;/ + ], + html: [ + /<\/?[a-zA-Z][^>]*>/, + //i, + /<(div|span|p|h[1-6]|body|head|html)\b/ + ], + css: [ + /[.#][a-zA-Z][\w-]*\s*{/, + /\b(color|background|margin|padding|font-size):\s*[^;]+;/, + /@(media|keyframes|import)\b/ + ], + json: [ + /^\s*[{\[][\s\S]*[}\]]\s*$/, + /"[^"]*":\s*(".*"|[\d.]+|true|false|null)/, + /,\s*$/m + ], + sql: [ + /\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b/i, + /\b(JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP BY|ORDER BY)\b/i, + /;\s*$/m + ], + shell: [ + /^#!/, + /\b(echo|cd|ls|grep|awk|sed|cat|chmod)\b/, + /\$\{?\w+\}?/ + ], + markdown: [ + /^#+\s+/m, + /\*\*.*?\*\*/, + /\[.*?\]\(.*?\)/, + /^```/m + ] + }; + + /** + * 检测文本的编程语言 + */ + static detectLanguage(text: string): LanguageDetectionResult { + if (!text.trim()) { + return { language: 'text', confidence: 1.0 }; + } + + const scores: Record = {}; + + // 对每种语言计算匹配分数 + for (const [language, patterns] of Object.entries(this.LANGUAGE_PATTERNS)) { + let score = 0; + const textLower = text.toLowerCase(); + + for (const pattern of patterns) { + const matches = text.match(pattern); + if (matches) { + score += matches.length; + } + } + + // 根据文本长度标准化分数 + scores[language] = score / Math.max(text.length / 100, 1); + } + + // 找到最高分的语言 + const bestMatch = Object.entries(scores) + .sort(([, a], [, b]) => b - a)[0]; + + if (bestMatch && bestMatch[1] > 0) { + return { + language: bestMatch[0] as SupportedLanguage, + confidence: Math.min(bestMatch[1], 1.0) + }; + } + + return { language: 'text', confidence: 1.0 }; + } +} + +/** + * 从语法树解析代码块 + */ +export function getBlocksFromSyntaxTree(state: EditorState): CodeBlock[] | null { + 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 + }; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/selectAll.ts b/frontend/src/views/editor/extensions/codeblock/selectAll.ts new file mode 100644 index 0000000..1661ca5 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/selectAll.ts @@ -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({ + 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(); + +/** + * 空块选择装饰 + */ +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, + ]; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/state.ts b/frontend/src/views/editor/extensions/codeblock/state.ts new file mode 100644 index 0000000..ff71df0 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/state.ts @@ -0,0 +1,59 @@ +/** + * Block 状态管理 + */ + +import { StateField, EditorState } from '@codemirror/state'; +import { Block } from './types'; +import { getBlocks } from './parser'; + +/** + * 块状态字段,跟踪文档中的所有块 + */ +export const blockState = StateField.define({ + 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 + ); +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/styles.css b/frontend/src/views/editor/extensions/codeblock/styles.css new file mode 100644 index 0000000..59c654f --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/styles.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/types.ts b/frontend/src/views/editor/extensions/codeblock/types.ts new file mode 100644 index 0000000..fac35df --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/types.ts @@ -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; +} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/foldExtension.ts b/frontend/src/views/editor/extensions/foldExtension.ts new file mode 100644 index 0000000..1f0700e --- /dev/null +++ b/frontend/src/views/editor/extensions/foldExtension.ts @@ -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} +}); diff --git a/frontend/src/views/editor/extensions/index.ts b/frontend/src/views/editor/extensions/index.ts index 5386b43..fee710c 100644 --- a/frontend/src/views/editor/extensions/index.ts +++ b/frontend/src/views/editor/extensions/index.ts @@ -3,4 +3,6 @@ export * from './tabExtension'; export * from './wheelZoomExtension'; export * from './statsExtension'; export * from './autoSaveExtension'; -export * from './fontExtension'; \ No newline at end of file +export * from './fontExtension'; +export * from './codeblast'; +export * from './codeblock'; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/rainbowBrackets.ts b/frontend/src/views/editor/extensions/rainbowBrackets.ts new file mode 100644 index 0000000..0737bee --- /dev/null +++ b/frontend/src/views/editor/extensions/rainbowBrackets.ts @@ -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[] = []; + 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' }, + }), + ]; +} \ No newline at end of file diff --git a/frontend/src/views/editor/theme/default-dark.ts b/frontend/src/views/editor/theme/default-dark.ts index 82d4f7e..b7c35cf 100644 --- a/frontend/src/views/editor/theme/default-dark.ts +++ b/frontend/src/views/editor/theme/default-dark.ts @@ -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},