Compare commits
13 Commits
ac086db1ed
...
a9c81c878e
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c81c878e | |||
| 3660d13d7d | |||
| 281f53c049 | |||
| 71ca541f78 | |||
| 91f4f4afac | |||
| fc5639d7bd | |||
|
|
6668c11846 | ||
| 17f3351cea | |||
| dd3dd4ddb2 | |||
| 60d1494d45 | |||
| 1ef5350b3f | |||
| 3521e5787b | |||
| 8d9bcdad7e |
@@ -415,25 +415,48 @@ export enum ExtensionID {
|
||||
* 颜色选择器
|
||||
*/
|
||||
ExtensionColorSelector = "colorSelector",
|
||||
ExtensionFold = "fold",
|
||||
ExtensionTextHighlight = "textHighlight",
|
||||
|
||||
/**
|
||||
* 选择框
|
||||
* 代码折叠
|
||||
*/
|
||||
ExtensionCheckbox = "checkbox",
|
||||
ExtensionFold = "fold",
|
||||
|
||||
/**
|
||||
* 划词翻译
|
||||
*/
|
||||
ExtensionTranslator = "translator",
|
||||
|
||||
/**
|
||||
* Markdown渲染
|
||||
*/
|
||||
ExtensionMarkdown = "markdown",
|
||||
|
||||
/**
|
||||
* 显示空白字符
|
||||
*/
|
||||
ExtensionHighlightWhitespace = "highlightWhitespace",
|
||||
|
||||
/**
|
||||
* 高亮行尾空白
|
||||
*/
|
||||
ExtensionHighlightTrailingWhitespace = "highlightTrailingWhitespace",
|
||||
|
||||
/**
|
||||
* UI增强扩展
|
||||
* 小地图
|
||||
*/
|
||||
ExtensionMinimap = "minimap",
|
||||
|
||||
/**
|
||||
* 行号显示
|
||||
*/
|
||||
ExtensionLineNumbers = "lineNumbers",
|
||||
|
||||
/**
|
||||
* 上下文菜单
|
||||
*/
|
||||
ExtensionContextMenu = "contextMenu",
|
||||
|
||||
/**
|
||||
* 工具扩展
|
||||
* 搜索功能
|
||||
@@ -810,31 +833,6 @@ export enum KeyBindingCommand {
|
||||
*/
|
||||
HideSearchCommand = "hideSearch",
|
||||
|
||||
/**
|
||||
* 搜索切换大小写
|
||||
*/
|
||||
SearchToggleCaseCommand = "searchToggleCase",
|
||||
|
||||
/**
|
||||
* 搜索切换整词
|
||||
*/
|
||||
SearchToggleWordCommand = "searchToggleWord",
|
||||
|
||||
/**
|
||||
* 搜索切换正则
|
||||
*/
|
||||
SearchToggleRegexCommand = "searchToggleRegex",
|
||||
|
||||
/**
|
||||
* 显示替换
|
||||
*/
|
||||
SearchShowReplaceCommand = "searchShowReplace",
|
||||
|
||||
/**
|
||||
* 替换全部
|
||||
*/
|
||||
SearchReplaceAllCommand = "searchReplaceAll",
|
||||
|
||||
/**
|
||||
* 代码块扩展相关
|
||||
* 块内选择全部
|
||||
@@ -1073,12 +1071,6 @@ export enum KeyBindingCommand {
|
||||
* 重做选择
|
||||
*/
|
||||
HistoryRedoSelectionCommand = "historyRedoSelection",
|
||||
|
||||
/**
|
||||
* 文本高亮扩展相关
|
||||
* 切换文本高亮
|
||||
*/
|
||||
TextHighlightToggleCommand = "textHighlightToggle",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
3048
frontend/package-lock.json
generated
3048
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -53,33 +53,30 @@
|
||||
"@codemirror/view": "^6.38.8",
|
||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.3",
|
||||
"@mdit/plugin-katex": "^0.23.2",
|
||||
"@mdit/plugin-tasklist": "^0.22.2",
|
||||
"@lezer/lr": "^1.4.4",
|
||||
"@prettier/plugin-xml": "^3.4.2",
|
||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||
"@toml-tools/lexer": "^1.0.0",
|
||||
"@toml-tools/parser": "^1.0.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/katex": "^0.16.7",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"colors-named": "^1.0.2",
|
||||
"colors-named-hex": "^1.0.2",
|
||||
"groovy-beautify": "^0.0.17",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"java-parser": "^3.0.1",
|
||||
"katex": "^0.16.25",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.1",
|
||||
"npm": "^11.6.3",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.7.2",
|
||||
"sass": "^1.94.2",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.2.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-pick-colors": "^1.8.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
@@ -91,18 +88,18 @@
|
||||
"@wailsio/runtime": "latest",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.0",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"happy-dom": "^20.0.11",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vitepress": "^2.0.0-alpha.12",
|
||||
"vitest": "^4.0.13",
|
||||
"vitest": "^4.0.14",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.4"
|
||||
"vue-tsc": "^3.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
|
||||
@@ -49,6 +49,46 @@
|
||||
--voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--voidraft-loading-done-color: #66ff66;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 暗色主题 */
|
||||
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(28% 0.02 255);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||
|
||||
/* Markdown 表格样式 - 暗色主题 */
|
||||
--cm-table-bg: rgba(35, 40, 52, 0.5);
|
||||
--cm-table-header-bg: rgba(46, 51, 69, 0.7);
|
||||
--cm-table-border: rgba(75, 85, 99, 0.35);
|
||||
--cm-table-row-hover: rgba(55, 62, 78, 0.5);
|
||||
|
||||
/* Search Panel - Dark Theme */
|
||||
--search-panel-bg: #252526;
|
||||
--search-panel-text: #cccccc;
|
||||
--search-panel-border: #454545;
|
||||
--search-input-bg: #3c3c3c;
|
||||
--search-input-text: #cccccc;
|
||||
--search-input-border: #3c3c3c;
|
||||
--search-focus-border: #0078d4;
|
||||
--search-btn-hover: rgba(255, 255, 255, 0.1);
|
||||
--search-btn-active-bg: rgba(0, 120, 212, 0.4);
|
||||
--search-btn-active-text: #ffffff;
|
||||
--search-error-border: #f14c4c;
|
||||
--search-error-bg: #5a1d1d;
|
||||
|
||||
/* Search Match Highlight - Dark Theme (VSCode style) */
|
||||
--search-match-bg: rgba(250, 220, 81, 0.85);
|
||||
--search-match-selected-bg: rgba(81, 175, 255, 0.5);
|
||||
--search-match-selected-border: #74b0f4;
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
@@ -96,6 +136,45 @@
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: #f3f3f3;
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* Markdown 表格样式 - 亮色主题 */
|
||||
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||
--cm-table-header-bg: oklch(94% 0.01 255);
|
||||
--cm-table-border: oklch(88% 0.008 255);
|
||||
--cm-table-row-hover: oklch(95% 0.008 255);
|
||||
|
||||
/* Search Panel - Light Theme */
|
||||
--search-panel-bg: #f3f3f3;
|
||||
--search-panel-text: #616161;
|
||||
--search-panel-border: #c8c8c8;
|
||||
--search-input-bg: #ffffff;
|
||||
--search-input-text: #616161;
|
||||
--search-input-border: #cecece;
|
||||
--search-focus-border: #0078d4;
|
||||
--search-btn-hover: rgba(0, 0, 0, 0.1);
|
||||
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
|
||||
--search-btn-active-text: #0078d4;
|
||||
--search-error-border: #e51400;
|
||||
--search-error-bg: #fdeceb;
|
||||
|
||||
/* Search Match Highlight - Light Theme (VSCode style) */
|
||||
--search-match-bg: rgba(250, 220, 81, 0.85);
|
||||
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
|
||||
--search-match-selected-border: #268fff;
|
||||
}
|
||||
|
||||
/* 跟随系统的浅色偏好 */
|
||||
@@ -144,5 +223,44 @@
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* Markdown 表格样式 - 亮色主题 */
|
||||
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||
--cm-table-header-bg: oklch(94% 0.01 255);
|
||||
--cm-table-border: oklch(88% 0.008 255);
|
||||
--cm-table-row-hover: oklch(95% 0.008 255);
|
||||
|
||||
/* Search Panel - Light Theme (auto) */
|
||||
--search-panel-bg: #f3f3f3;
|
||||
--search-panel-text: #616161;
|
||||
--search-panel-border: #c8c8c8;
|
||||
--search-input-bg: #ffffff;
|
||||
--search-input-text: #616161;
|
||||
--search-input-border: #cecece;
|
||||
--search-focus-border: #0078d4;
|
||||
--search-btn-hover: rgba(0, 0, 0, 0.1);
|
||||
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
|
||||
--search-btn-active-text: #0078d4;
|
||||
--search-error-border: #e51400;
|
||||
--search-error-bg: #fdeceb;
|
||||
|
||||
/* Search Match Highlight - Light Theme auto (VSCode style) */
|
||||
--search-match-bg: rgba(250, 220, 81, 0.85);
|
||||
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
|
||||
--search-match-selected-border: #268fff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,19 @@
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
AuthMethod,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig,
|
||||
UpdateSourceType,
|
||||
GitBackupConfig,
|
||||
AuthMethod
|
||||
UpdateSourceType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {FONT_OPTIONS} from './fonts';
|
||||
|
||||
// 配置键映射和限制的类型定义
|
||||
export type GeneralConfigKeyMap = {
|
||||
readonly [K in keyof GeneralConfig]: string;
|
||||
};
|
||||
|
||||
export type EditingConfigKeyMap = {
|
||||
readonly [K in keyof EditingConfig]: string;
|
||||
};
|
||||
|
||||
export type AppearanceConfigKeyMap = {
|
||||
readonly [K in keyof AppearanceConfig]: string;
|
||||
};
|
||||
|
||||
export type UpdatesConfigKeyMap = {
|
||||
readonly [K in keyof UpdatesConfig]: string;
|
||||
};
|
||||
|
||||
export type BackupConfigKeyMap = {
|
||||
readonly [K in keyof GitBackupConfig]: string;
|
||||
};
|
||||
|
||||
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
|
||||
export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup';
|
||||
|
||||
// 配置键映射
|
||||
export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
// 统一配置键映射(平级展开)
|
||||
export const CONFIG_KEY_MAP = {
|
||||
// general
|
||||
alwaysOnTop: 'general.alwaysOnTop',
|
||||
dataPath: 'general.dataPath',
|
||||
enableSystemTray: 'general.enableSystemTray',
|
||||
@@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
enableWindowSnap: 'general.enableWindowSnap',
|
||||
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
||||
enableTabs: 'general.enableTabs',
|
||||
} as const;
|
||||
|
||||
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
// editing
|
||||
fontSize: 'editing.fontSize',
|
||||
fontFamily: 'editing.fontFamily',
|
||||
fontWeight: 'editing.fontWeight',
|
||||
@@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
enableTabIndent: 'editing.enableTabIndent',
|
||||
tabSize: 'editing.tabSize',
|
||||
tabType: 'editing.tabType',
|
||||
autoSaveDelay: 'editing.autoSaveDelay'
|
||||
} as const;
|
||||
|
||||
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
|
||||
autoSaveDelay: 'editing.autoSaveDelay',
|
||||
// appearance
|
||||
language: 'appearance.language',
|
||||
systemTheme: 'appearance.systemTheme',
|
||||
currentTheme: 'appearance.currentTheme'
|
||||
} as const;
|
||||
|
||||
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
currentTheme: 'appearance.currentTheme',
|
||||
// updates
|
||||
version: 'updates.version',
|
||||
autoUpdate: 'updates.autoUpdate',
|
||||
primarySource: 'updates.primarySource',
|
||||
@@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
||||
updateTimeout: 'updates.updateTimeout',
|
||||
github: 'updates.github',
|
||||
gitea: 'updates.gitea'
|
||||
} as const;
|
||||
|
||||
export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
gitea: 'updates.gitea',
|
||||
// backup
|
||||
enabled: 'backup.enabled',
|
||||
repo_url: 'backup.repo_url',
|
||||
auth_method: 'backup.auth_method',
|
||||
@@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
auto_backup: 'backup.auto_backup',
|
||||
} as const;
|
||||
|
||||
export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
|
||||
|
||||
// 配置限制
|
||||
export const CONFIG_LIMITS = {
|
||||
fontSize: {min: 12, max: 28, default: 13},
|
||||
|
||||
1945
frontend/src/common/constant/emojies.ts
Normal file
1945
frontend/src/common/constant/emojies.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
// Enclose abbreviations in <abbr> tags
|
||||
//
|
||||
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 环境接口,包含缩写定义
|
||||
*/
|
||||
interface AbbrEnv {
|
||||
abbreviations?: { [key: string]: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-abbr 插件
|
||||
* 用于支持缩写语法
|
||||
*/
|
||||
export default function abbr_plugin(md: MarkdownIt): void {
|
||||
const escapeRE = md.utils.escapeRE;
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
|
||||
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
|
||||
// you can check character classes here:
|
||||
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
|
||||
const OTHER_CHARS = ' \r\n$+<=>^`|~';
|
||||
|
||||
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
|
||||
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
|
||||
|
||||
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
let labelEnd: number;
|
||||
let pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
if (pos + 2 >= max) { return false; }
|
||||
|
||||
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
|
||||
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
|
||||
|
||||
const labelStart = pos;
|
||||
|
||||
for (; pos < max; pos++) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
if (ch === 0x5B /* [ */) {
|
||||
return false;
|
||||
} else if (ch === 0x5D /* ] */) {
|
||||
labelEnd = pos;
|
||||
break;
|
||||
} else if (ch === 0x5C /* \ */) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (silent) { return true; }
|
||||
|
||||
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
|
||||
const title = state.src.slice(labelEnd! + 2, max).trim();
|
||||
if (label.length === 0) { return false; }
|
||||
if (title.length === 0) { return false; }
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { env.abbreviations = {}; }
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
if (typeof env.abbreviations[':' + label] === 'undefined') {
|
||||
env.abbreviations[':' + label] = title;
|
||||
}
|
||||
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function abbr_replace(state: StateCore): void {
|
||||
const blockTokens = state.tokens;
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { return; }
|
||||
|
||||
const regSimple = new RegExp('(?:' +
|
||||
Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') +
|
||||
')');
|
||||
|
||||
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
|
||||
'(' + Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') + ')' +
|
||||
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
|
||||
|
||||
const reg = new RegExp(regText, 'g');
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
const currentToken = tokens[i];
|
||||
if (currentToken.type !== 'text') { continue; }
|
||||
|
||||
let pos = 0;
|
||||
const text = currentToken.content;
|
||||
reg.lastIndex = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
// fast regexp run to determine whether there are any abbreviated words
|
||||
// in the current token
|
||||
if (!regSimple.test(text)) { continue; }
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = reg.exec(text))) {
|
||||
if (m.index > 0 || m[1].length > 0) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos, m.index + m[1].length);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token_o = new state.Token('abbr_open', 'abbr', 1);
|
||||
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
|
||||
nodes.push(token_o);
|
||||
|
||||
const token_t = new state.Token('text', '', 0);
|
||||
token_t.content = m[2];
|
||||
nodes.push(token_t);
|
||||
|
||||
const token_c = new state.Token('abbr_close', 'abbr', -1);
|
||||
nodes.push(token_c);
|
||||
|
||||
reg.lastIndex -= m[3].length;
|
||||
pos = reg.lastIndex;
|
||||
}
|
||||
|
||||
if (!nodes.length) { continue; }
|
||||
|
||||
if (pos < text.length) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
|
||||
|
||||
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
// Process definition lists
|
||||
//
|
||||
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* markdown-it-deflist 插件
|
||||
* 用于支持定义列表语法
|
||||
*/
|
||||
export default function deflist_plugin(md: MarkdownIt): void {
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
// Search `[:~][\n ]`, returns next pos after marker on success
|
||||
// or -1 on fail.
|
||||
function skipMarker(state: StateBlock, line: number): number {
|
||||
let start = state.bMarks[line] + state.tShift[line];
|
||||
const max = state.eMarks[line];
|
||||
|
||||
if (start >= max) { return -1; }
|
||||
|
||||
// Check bullet
|
||||
const marker = state.src.charCodeAt(start++);
|
||||
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
|
||||
|
||||
const pos = state.skipSpaces(start);
|
||||
|
||||
// require space after ":"
|
||||
if (start === pos) { return -1; }
|
||||
|
||||
// no empty definitions, e.g. " : "
|
||||
if (pos >= max) { return -1; }
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
function markTightParagraphs(state: StateBlock, idx: number): void {
|
||||
const level = state.level + 2;
|
||||
|
||||
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
|
||||
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
|
||||
state.tokens[i + 2].hidden = true;
|
||||
state.tokens[i].hidden = true;
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
if (silent) {
|
||||
// quirk: validation mode validates a dd block only, not a whole deflist
|
||||
if (state.ddIndent < 0) { return false; }
|
||||
return skipMarker(state, startLine) >= 0;
|
||||
}
|
||||
|
||||
let nextLine = startLine + 1;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
|
||||
if (state.isEmpty(nextLine)) {
|
||||
nextLine++;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
}
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { return false; }
|
||||
let contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { return false; }
|
||||
|
||||
// Start list
|
||||
const listTokIdx = state.tokens.length;
|
||||
let tight = true;
|
||||
|
||||
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
|
||||
const listLines: [number, number] = [startLine, 0];
|
||||
token_dl_o.map = listLines;
|
||||
|
||||
//
|
||||
// Iterate list items
|
||||
//
|
||||
|
||||
let dtLine = startLine;
|
||||
let ddLine = nextLine;
|
||||
|
||||
// One definition list can contain multiple DTs,
|
||||
// and one DT can be followed by multiple DDs.
|
||||
//
|
||||
// Thus, there is two loops here, and label is
|
||||
// needed to break out of the second one
|
||||
//
|
||||
/* eslint no-labels:0,block-scoped-var:0 */
|
||||
OUTER:
|
||||
for (;;) {
|
||||
let prevEmptyEnd = false;
|
||||
|
||||
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
|
||||
token_dt_o.map = [dtLine, dtLine];
|
||||
|
||||
const token_i: Token = state.push('inline', '', 0);
|
||||
token_i.map = [dtLine, dtLine];
|
||||
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
|
||||
token_i.children = [];
|
||||
|
||||
state.push('dt_close', 'dt', -1);
|
||||
|
||||
for (;;) {
|
||||
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
|
||||
const itemLines: [number, number] = [nextLine, 0];
|
||||
token_dd_o.map = itemLines;
|
||||
|
||||
let pos = contentStart;
|
||||
const max = state.eMarks[ddLine];
|
||||
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
contentStart = pos;
|
||||
|
||||
const oldTight = state.tight;
|
||||
const oldDDIndent = state.ddIndent;
|
||||
const oldIndent = state.blkIndent;
|
||||
const oldTShift = state.tShift[ddLine];
|
||||
const oldSCount = state.sCount[ddLine];
|
||||
const oldParentType = state.parentType;
|
||||
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
|
||||
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
|
||||
state.sCount[ddLine] = offset;
|
||||
state.tight = true;
|
||||
state.parentType = 'deflist' as any;
|
||||
|
||||
state.md.block.tokenize(state, ddLine, endLine);
|
||||
|
||||
// If any of list item is tight, mark list as tight
|
||||
if (!state.tight || prevEmptyEnd) {
|
||||
tight = false;
|
||||
}
|
||||
// Item become loose if finish with empty line,
|
||||
// but we should filter last element, because it means list finish
|
||||
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
|
||||
|
||||
state.tShift[ddLine] = oldTShift;
|
||||
state.sCount[ddLine] = oldSCount;
|
||||
state.tight = oldTight;
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent = oldIndent;
|
||||
state.ddIndent = oldDDIndent;
|
||||
|
||||
state.push('dd_close', 'dd', -1);
|
||||
|
||||
itemLines[1] = nextLine = state.line;
|
||||
|
||||
if (nextLine >= endLine) { break OUTER; }
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
|
||||
contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
ddLine = nextLine;
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DD tag and repeat checking
|
||||
}
|
||||
|
||||
if (nextLine >= endLine) { break; }
|
||||
dtLine = nextLine;
|
||||
|
||||
if (state.isEmpty(dtLine)) { break; }
|
||||
if (state.sCount[dtLine] < state.blkIndent) { break; }
|
||||
|
||||
ddLine = dtLine + 1;
|
||||
if (ddLine >= endLine) { break; }
|
||||
if (state.isEmpty(ddLine)) { ddLine++; }
|
||||
if (ddLine >= endLine) { break; }
|
||||
|
||||
if (state.sCount[ddLine] < state.blkIndent) { break; }
|
||||
contentStart = skipMarker(state, ddLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DT and DD tags and repeat checking
|
||||
}
|
||||
|
||||
// Finilize list
|
||||
state.push('dl_close', 'dl', -1);
|
||||
|
||||
listLines[1] = nextLine;
|
||||
|
||||
state.line = nextLine;
|
||||
|
||||
// mark paragraphs tight if needed
|
||||
if (tight) {
|
||||
markTightParagraphs(state, listTokIdx);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as bare } from './lib/bare';
|
||||
export { default as light } from './lib/light';
|
||||
export { default as full } from './lib/full';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emoji_html from './render';
|
||||
import emoji_replace from './replace';
|
||||
import normalize_opts, { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Bare emoji 插件(不包含预定义的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: {},
|
||||
shortcuts: {},
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
|
||||
|
||||
md.renderer.rules.emoji = emoji_html;
|
||||
|
||||
md.core.ruler.after(
|
||||
'linkify',
|
||||
'emoji',
|
||||
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +0,0 @@
|
||||
// Generated, don't edit
|
||||
import { EmojiDefs } from '../normalize_opts';
|
||||
|
||||
const emojies: EmojiDefs = {
|
||||
"grinning": "😀",
|
||||
"smiley": "😃",
|
||||
"smile": "😄",
|
||||
"grin": "😁",
|
||||
"laughing": "😆",
|
||||
"satisfied": "😆",
|
||||
"sweat_smile": "😅",
|
||||
"joy": "😂",
|
||||
"wink": "😉",
|
||||
"blush": "😊",
|
||||
"innocent": "😇",
|
||||
"heart_eyes": "😍",
|
||||
"kissing_heart": "😘",
|
||||
"kissing": "😗",
|
||||
"kissing_closed_eyes": "😚",
|
||||
"kissing_smiling_eyes": "😙",
|
||||
"yum": "😋",
|
||||
"stuck_out_tongue": "😛",
|
||||
"stuck_out_tongue_winking_eye": "😜",
|
||||
"stuck_out_tongue_closed_eyes": "😝",
|
||||
"neutral_face": "😐",
|
||||
"expressionless": "😑",
|
||||
"no_mouth": "😶",
|
||||
"smirk": "😏",
|
||||
"unamused": "😒",
|
||||
"relieved": "😌",
|
||||
"pensive": "😔",
|
||||
"sleepy": "😪",
|
||||
"sleeping": "😴",
|
||||
"mask": "😷",
|
||||
"dizzy_face": "😵",
|
||||
"sunglasses": "😎",
|
||||
"confused": "😕",
|
||||
"worried": "😟",
|
||||
"open_mouth": "😮",
|
||||
"hushed": "😯",
|
||||
"astonished": "😲",
|
||||
"flushed": "😳",
|
||||
"frowning": "😦",
|
||||
"anguished": "😧",
|
||||
"fearful": "😨",
|
||||
"cold_sweat": "😰",
|
||||
"disappointed_relieved": "😥",
|
||||
"cry": "😢",
|
||||
"sob": "😭",
|
||||
"scream": "😱",
|
||||
"confounded": "😖",
|
||||
"persevere": "😣",
|
||||
"disappointed": "😞",
|
||||
"sweat": "😓",
|
||||
"weary": "😩",
|
||||
"tired_face": "😫",
|
||||
"rage": "😡",
|
||||
"pout": "😡",
|
||||
"angry": "😠",
|
||||
"smiling_imp": "😈",
|
||||
"smiley_cat": "😺",
|
||||
"smile_cat": "😸",
|
||||
"joy_cat": "😹",
|
||||
"heart_eyes_cat": "😻",
|
||||
"smirk_cat": "😼",
|
||||
"kissing_cat": "😽",
|
||||
"scream_cat": "🙀",
|
||||
"crying_cat_face": "😿",
|
||||
"pouting_cat": "😾",
|
||||
"heart": "❤️",
|
||||
"hand": "✋",
|
||||
"raised_hand": "✋",
|
||||
"v": "✌️",
|
||||
"point_up": "☝️",
|
||||
"fist_raised": "✊",
|
||||
"fist": "✊",
|
||||
"monkey_face": "🐵",
|
||||
"cat": "🐱",
|
||||
"cow": "🐮",
|
||||
"mouse": "🐭",
|
||||
"coffee": "☕",
|
||||
"hotsprings": "♨️",
|
||||
"anchor": "⚓",
|
||||
"airplane": "✈️",
|
||||
"hourglass": "⌛",
|
||||
"watch": "⌚",
|
||||
"sunny": "☀️",
|
||||
"star": "⭐",
|
||||
"cloud": "☁️",
|
||||
"umbrella": "☔",
|
||||
"zap": "⚡",
|
||||
"snowflake": "❄️",
|
||||
"sparkles": "✨",
|
||||
"black_joker": "🃏",
|
||||
"mahjong": "🀄",
|
||||
"phone": "☎️",
|
||||
"telephone": "☎️",
|
||||
"envelope": "✉️",
|
||||
"pencil2": "✏️",
|
||||
"black_nib": "✒️",
|
||||
"scissors": "✂️",
|
||||
"wheelchair": "♿",
|
||||
"warning": "⚠️",
|
||||
"aries": "♈",
|
||||
"taurus": "♉",
|
||||
"gemini": "♊",
|
||||
"cancer": "♋",
|
||||
"leo": "♌",
|
||||
"virgo": "♍",
|
||||
"libra": "♎",
|
||||
"scorpius": "♏",
|
||||
"sagittarius": "♐",
|
||||
"capricorn": "♑",
|
||||
"aquarius": "♒",
|
||||
"pisces": "♓",
|
||||
"heavy_multiplication_x": "✖️",
|
||||
"heavy_plus_sign": "➕",
|
||||
"heavy_minus_sign": "➖",
|
||||
"heavy_division_sign": "➗",
|
||||
"bangbang": "‼️",
|
||||
"interrobang": "⁉️",
|
||||
"question": "❓",
|
||||
"grey_question": "❔",
|
||||
"grey_exclamation": "❕",
|
||||
"exclamation": "❗",
|
||||
"heavy_exclamation_mark": "❗",
|
||||
"wavy_dash": "〰️",
|
||||
"recycle": "♻️",
|
||||
"white_check_mark": "✅",
|
||||
"ballot_box_with_check": "☑️",
|
||||
"heavy_check_mark": "✔️",
|
||||
"x": "❌",
|
||||
"negative_squared_cross_mark": "❎",
|
||||
"curly_loop": "➰",
|
||||
"loop": "➿",
|
||||
"part_alternation_mark": "〽️",
|
||||
"eight_spoked_asterisk": "✳️",
|
||||
"eight_pointed_black_star": "✴️",
|
||||
"sparkle": "❇️",
|
||||
"copyright": "©️",
|
||||
"registered": "®️",
|
||||
"tm": "™️",
|
||||
"information_source": "ℹ️",
|
||||
"m": "Ⓜ️",
|
||||
"black_circle": "⚫",
|
||||
"white_circle": "⚪",
|
||||
"black_large_square": "⬛",
|
||||
"white_large_square": "⬜",
|
||||
"black_medium_square": "◼️",
|
||||
"white_medium_square": "◻️",
|
||||
"black_medium_small_square": "◾",
|
||||
"white_medium_small_square": "◽",
|
||||
"black_small_square": "▪️",
|
||||
"white_small_square": "▫️"
|
||||
};
|
||||
|
||||
export default emojies;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Emoticons -> Emoji mapping.
|
||||
//
|
||||
// (!) Some patterns skipped, to avoid collisions
|
||||
// without increase matcher complicity. Than can change in future.
|
||||
//
|
||||
// Places to look for more emoticons info:
|
||||
//
|
||||
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
|
||||
// - https://github.com/wooorm/emoticon/blob/master/Support.md
|
||||
// - http://factoryjoe.com/projects/emoticons/
|
||||
//
|
||||
|
||||
import { EmojiShortcuts } from '../normalize_opts';
|
||||
|
||||
const shortcuts: EmojiShortcuts = {
|
||||
angry: ['>:(', '>:-('],
|
||||
blush: [':")', ':-")'],
|
||||
broken_heart: ['</3', '<\\3'],
|
||||
// :\ and :-\ not used because of conflict with markdown escaping
|
||||
confused: [':/', ':-/'], // twemoji shows question
|
||||
cry: [":'(", ":'-(", ':,(', ':,-('],
|
||||
frowning: [':(', ':-('],
|
||||
heart: ['<3'],
|
||||
imp: [']:(', ']:-('],
|
||||
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
|
||||
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
|
||||
kissing: [':*', ':-*'],
|
||||
laughing: ['x-)', 'X-)'],
|
||||
neutral_face: [':|', ':-|'],
|
||||
open_mouth: [':o', ':-o', ':O', ':-O'],
|
||||
rage: [':@', ':-@'],
|
||||
smile: [':D', ':-D'],
|
||||
smiley: [':)', ':-)'],
|
||||
smiling_imp: [']:)', ']:-)'],
|
||||
sob: [":,'(", ":,'-(", ';(', ';-('],
|
||||
stuck_out_tongue: [':P', ':-P'],
|
||||
sunglasses: ['8-)', 'B-)'],
|
||||
sweat: [',:(', ',:-('],
|
||||
sweat_smile: [',:)', ',:-)'],
|
||||
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
|
||||
wink: [';)', ';-)']
|
||||
};
|
||||
|
||||
export default shortcuts;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/full';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Full emoji 插件(包含完整的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/light';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Light emoji 插件(包含常用的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Emoji 定义类型
|
||||
*/
|
||||
export interface EmojiDefs {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji 快捷方式类型
|
||||
*/
|
||||
export interface EmojiShortcuts {
|
||||
[key: string]: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入选项接口
|
||||
*/
|
||||
export interface EmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: EmojiShortcuts;
|
||||
enabled: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化后的选项接口
|
||||
*/
|
||||
export interface NormalizedEmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: { [key: string]: string };
|
||||
scanRE: RegExp;
|
||||
replaceRE: RegExp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
*/
|
||||
function quoteRE(str: string): string {
|
||||
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将输入选项转换为更可用的格式并编译搜索正则表达式
|
||||
*/
|
||||
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
|
||||
let emojies = options.defs;
|
||||
|
||||
// Filter emojies by whitelist, if needed
|
||||
if (options.enabled.length) {
|
||||
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
|
||||
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Flatten shortcuts to simple object: { alias: emoji_name }
|
||||
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
|
||||
// Skip aliases for filtered emojies, to reduce regexp
|
||||
if (!emojies[key]) return acc;
|
||||
|
||||
if (Array.isArray(options.shortcuts[key])) {
|
||||
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[options.shortcuts[key] as string] = key;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const keys = Object.keys(emojies);
|
||||
let names: string;
|
||||
|
||||
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
|
||||
if (keys.length === 0) {
|
||||
names = '^$';
|
||||
} else {
|
||||
// Compile regexp
|
||||
names = keys
|
||||
.map((name: string) => { return `:${name}:`; })
|
||||
.concat(Object.keys(shortcuts))
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((name: string) => { return quoteRE(name); })
|
||||
.join('|');
|
||||
}
|
||||
const scanRE = RegExp(names);
|
||||
const replaceRE = RegExp(names, 'g');
|
||||
|
||||
return {
|
||||
defs: emojies,
|
||||
shortcuts,
|
||||
scanRE,
|
||||
replaceRE
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* Emoji 渲染函数
|
||||
*/
|
||||
export default function emoji_html(tokens: Token[], idx: number): string {
|
||||
return tokens[idx].content;
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import MarkdownIt, { StateCore, Token } from 'markdown-it';
|
||||
import { EmojiDefs } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Emoji 和快捷方式替换逻辑
|
||||
*
|
||||
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
|
||||
* 但是,谁在乎呢...
|
||||
*/
|
||||
export default function create_rule(
|
||||
md: MarkdownIt,
|
||||
emojies: EmojiDefs,
|
||||
shortcuts: { [key: string]: string },
|
||||
scanRE: RegExp,
|
||||
replaceRE: RegExp
|
||||
) {
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
const ucm = md.utils.lib.ucmicro;
|
||||
const has = md.utils.has;
|
||||
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
|
||||
|
||||
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
|
||||
let last_pos = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
|
||||
let emoji_name: string;
|
||||
// Validate emoji name
|
||||
if (has(shortcuts, match)) {
|
||||
// replace shortcut with full name
|
||||
emoji_name = shortcuts[match];
|
||||
|
||||
// Don't allow letters before any shortcut (as in no ":/" in http://)
|
||||
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
|
||||
|
||||
// Don't allow letters after any shortcut
|
||||
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
emoji_name = match.slice(1, -1);
|
||||
}
|
||||
|
||||
// Add new tokens to pending list
|
||||
if (offset > last_pos) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos, offset);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token = new TokenConstructor('emoji', '', 0);
|
||||
token.markup = emoji_name;
|
||||
token.content = emojies[emoji_name];
|
||||
nodes.push(token);
|
||||
|
||||
last_pos = offset + match.length;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (last_pos < text.length) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return function emoji_replace(state: StateCore): void {
|
||||
let token: Token;
|
||||
const blockTokens = state.tokens;
|
||||
let autolinkLevel = 0;
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
// Use reversed logic in links start/end match
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
token = tokens[i];
|
||||
|
||||
if (token.type === 'link_open' || token.type === 'link_close') {
|
||||
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
|
||||
}
|
||||
|
||||
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(
|
||||
tokens, i, splitTextToken(token.content, token.level, state.Token)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 脚注元数据接口
|
||||
*/
|
||||
interface FootnoteMeta {
|
||||
id: number;
|
||||
subId: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脚注列表项接口
|
||||
*/
|
||||
interface FootnoteItem {
|
||||
label?: string;
|
||||
content?: string;
|
||||
tokens?: Token[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境接口
|
||||
*/
|
||||
interface FootnoteEnv {
|
||||
footnotes?: {
|
||||
refs?: { [key: string]: number };
|
||||
list?: FootnoteItem[];
|
||||
};
|
||||
docId?: string;
|
||||
}
|
||||
|
||||
/// /////////////////////////////////////////////////////////////////////////////
|
||||
// Renderer partials
|
||||
|
||||
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
|
||||
const n = Number(tokens[idx].meta.id + 1).toString();
|
||||
let prefix = '';
|
||||
|
||||
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
|
||||
|
||||
return prefix + n;
|
||||
}
|
||||
|
||||
function render_footnote_caption(tokens: Token[], idx: number): string {
|
||||
let n = Number(tokens[idx].meta.id + 1).toString();
|
||||
|
||||
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `[${n}]`;
|
||||
}
|
||||
|
||||
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
|
||||
let refid = id;
|
||||
|
||||
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
|
||||
}
|
||||
|
||||
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
|
||||
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
|
||||
'<section class="footnotes">\n' +
|
||||
'<ol class="footnotes-list">\n';
|
||||
}
|
||||
|
||||
function render_footnote_block_close(): string {
|
||||
return '</ol>\n</section>\n';
|
||||
}
|
||||
|
||||
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<li id="fn${id}" class="footnote-item">`;
|
||||
}
|
||||
|
||||
function render_footnote_close(): string {
|
||||
return '</li>\n';
|
||||
}
|
||||
|
||||
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
|
||||
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-footnote 插件
|
||||
* 用于支持脚注语法
|
||||
*/
|
||||
export default function footnote_plugin(md: MarkdownIt): void {
|
||||
const parseLinkLabel = md.helpers.parseLinkLabel;
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
md.renderer.rules.footnote_ref = render_footnote_ref;
|
||||
md.renderer.rules.footnote_block_open = render_footnote_block_open;
|
||||
md.renderer.rules.footnote_block_close = render_footnote_block_close;
|
||||
md.renderer.rules.footnote_open = render_footnote_open;
|
||||
md.renderer.rules.footnote_close = render_footnote_close;
|
||||
md.renderer.rules.footnote_anchor = render_footnote_anchor;
|
||||
|
||||
// helpers (only used in other rules, no tokens are attached to those)
|
||||
md.renderer.rules.footnote_caption = render_footnote_caption;
|
||||
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
|
||||
|
||||
// Process footnote block definition
|
||||
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
// line should be at least 5 chars - "[^x]:"
|
||||
if (start + 4 > max) return false;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
|
||||
if (silent) return true;
|
||||
pos++;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.refs) env.footnotes.refs = {};
|
||||
const label = state.src.slice(start + 2, pos - 2);
|
||||
env.footnotes.refs[`:${label}`] = -1;
|
||||
|
||||
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
|
||||
token_fref_o.meta = { label };
|
||||
token_fref_o.level = state.level++;
|
||||
state.tokens.push(token_fref_o);
|
||||
|
||||
const oldBMark = state.bMarks[startLine];
|
||||
const oldTShift = state.tShift[startLine];
|
||||
const oldSCount = state.sCount[startLine];
|
||||
const oldParentType = state.parentType;
|
||||
|
||||
const posAfterColon = pos;
|
||||
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
|
||||
let offset = initial;
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
state.tShift[startLine] = pos - posAfterColon;
|
||||
state.sCount[startLine] = offset - initial;
|
||||
|
||||
state.bMarks[startLine] = posAfterColon;
|
||||
state.blkIndent += 4;
|
||||
state.parentType = 'footnote' as any;
|
||||
|
||||
if (state.sCount[startLine] < state.blkIndent) {
|
||||
state.sCount[startLine] += state.blkIndent;
|
||||
}
|
||||
|
||||
state.md.block.tokenize(state, startLine, endLine);
|
||||
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent -= 4;
|
||||
state.tShift[startLine] = oldTShift;
|
||||
state.sCount[startLine] = oldSCount;
|
||||
state.bMarks[startLine] = oldBMark;
|
||||
|
||||
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
|
||||
token_fref_c.level = --state.level;
|
||||
state.tokens.push(token_fref_c);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process inline footnotes (^[...])
|
||||
function footnote_inline(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (start + 2 >= max) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
|
||||
|
||||
const labelStart = start + 2;
|
||||
const labelEnd = parseLinkLabel(state, start + 1);
|
||||
|
||||
// parser failed to find ']', so it's not a valid note
|
||||
if (labelEnd < 0) return false;
|
||||
|
||||
// We found the end of the link, and know for a fact it's a valid link;
|
||||
// so all that's left to do is to call tokenizer.
|
||||
//
|
||||
if (!silent) {
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
const footnoteId = env.footnotes.list.length;
|
||||
const tokens: Token[] = [];
|
||||
|
||||
state.md.inline.parse(
|
||||
state.src.slice(labelStart, labelEnd),
|
||||
state.md,
|
||||
state.env,
|
||||
tokens
|
||||
);
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId };
|
||||
|
||||
env.footnotes.list[footnoteId] = {
|
||||
content: state.src.slice(labelStart, labelEnd),
|
||||
tokens,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
state.pos = labelEnd + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process footnote references ([^...])
|
||||
function footnote_ref(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
// should be at least 4 chars - "[^x]"
|
||||
if (start + 3 > max) return false;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes || !env.footnotes.refs) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x0A) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos >= max) return false;
|
||||
pos++;
|
||||
|
||||
const label = state.src.slice(start + 2, pos - 1);
|
||||
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
|
||||
|
||||
if (!silent) {
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
|
||||
let footnoteId: number;
|
||||
|
||||
if (env.footnotes.refs[`:${label}`] < 0) {
|
||||
footnoteId = env.footnotes.list.length;
|
||||
env.footnotes.list[footnoteId] = { label, count: 0 };
|
||||
env.footnotes.refs[`:${label}`] = footnoteId;
|
||||
} else {
|
||||
footnoteId = env.footnotes.refs[`:${label}`];
|
||||
}
|
||||
|
||||
const footnoteSubId = env.footnotes.list[footnoteId].count;
|
||||
env.footnotes.list[footnoteId].count++;
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId, subId: footnoteSubId, label };
|
||||
}
|
||||
|
||||
state.pos = pos;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Glue footnote tokens to end of token stream
|
||||
function footnote_tail(state: StateCore): void {
|
||||
let tokens: Token[] | null = null;
|
||||
let current: Token[];
|
||||
let currentLabel: string;
|
||||
let insideRef = false;
|
||||
const refTokens: { [key: string]: Token[] } = {};
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) { return; }
|
||||
|
||||
state.tokens = state.tokens.filter(function (tok) {
|
||||
if (tok.type === 'footnote_reference_open') {
|
||||
insideRef = true;
|
||||
current = [];
|
||||
currentLabel = tok.meta.label;
|
||||
return false;
|
||||
}
|
||||
if (tok.type === 'footnote_reference_close') {
|
||||
insideRef = false;
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
refTokens[':' + currentLabel] = current;
|
||||
return false;
|
||||
}
|
||||
if (insideRef) { current.push(tok); }
|
||||
return !insideRef;
|
||||
});
|
||||
|
||||
if (!env.footnotes.list) { return; }
|
||||
const list = env.footnotes.list;
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_open', '', 1));
|
||||
|
||||
for (let i = 0, l = list.length; i < l; i++) {
|
||||
const token_fo = new state.Token('footnote_open', '', 1);
|
||||
token_fo.meta = { id: i, label: list[i].label };
|
||||
state.tokens.push(token_fo);
|
||||
|
||||
if (list[i].tokens) {
|
||||
tokens = [];
|
||||
|
||||
const token_po = new state.Token('paragraph_open', 'p', 1);
|
||||
token_po.block = true;
|
||||
tokens.push(token_po);
|
||||
|
||||
const token_i = new state.Token('inline', '', 0);
|
||||
token_i.children = list[i].tokens || null;
|
||||
token_i.content = list[i].content || '';
|
||||
tokens.push(token_i);
|
||||
|
||||
const token_pc = new state.Token('paragraph_close', 'p', -1);
|
||||
token_pc.block = true;
|
||||
tokens.push(token_pc);
|
||||
} else if (list[i].label) {
|
||||
tokens = refTokens[`:${list[i].label}`] || null;
|
||||
}
|
||||
|
||||
if (tokens) state.tokens = state.tokens.concat(tokens);
|
||||
|
||||
let lastParagraph: Token | null;
|
||||
|
||||
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
|
||||
lastParagraph = state.tokens.pop()!;
|
||||
} else {
|
||||
lastParagraph = null;
|
||||
}
|
||||
|
||||
const t = list[i].count > 0 ? list[i].count : 1;
|
||||
for (let j = 0; j < t; j++) {
|
||||
const token_a = new state.Token('footnote_anchor', '', 0);
|
||||
token_a.meta = { id: i, subId: j, label: list[i].label };
|
||||
state.tokens.push(token_a);
|
||||
}
|
||||
|
||||
if (lastParagraph) {
|
||||
state.tokens.push(lastParagraph);
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_close', '', -1));
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_close', '', -1));
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
|
||||
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
|
||||
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
|
||||
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-ins 插件
|
||||
* 用于支持插入文本语法 ++text++
|
||||
*/
|
||||
export default function ins_plugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x2B/* + */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
let token: Token;
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x2B/* + */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
token = state.tokens[startDelim.token];
|
||||
token.type = 'ins_open';
|
||||
token.tag = 'ins';
|
||||
token.nesting = 1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
token = state.tokens[endDelim.token];
|
||||
token.type = 'ins_close';
|
||||
token.tag = 'ins';
|
||||
token.nesting = -1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '+') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'ins', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (let curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import MarkdownIt, {StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-mark 插件
|
||||
* 用于支持 ==标记文本== 语法
|
||||
*/
|
||||
export default function markPlugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x3D/* = */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x3D/* = */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
const token_o = state.tokens[startDelim.token];
|
||||
token_o.type = 'mark_open';
|
||||
token_o.tag = 'mark';
|
||||
token_o.nesting = 1;
|
||||
token_o.markup = '==';
|
||||
token_o.content = '';
|
||||
|
||||
const token_c = state.tokens[endDelim.token];
|
||||
token_c.type = 'mark_close';
|
||||
token_c.tag = 'mark';
|
||||
token_c.nesting = -1;
|
||||
token_c.markup = '==';
|
||||
token_c.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '=') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
const token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'mark', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
|
||||
let curr: number;
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import mermaid from "mermaid";
|
||||
import {genUid, hashCode, sleep} from "./utils";
|
||||
|
||||
const mermaidCache = new Map<string, HTMLElement>();
|
||||
|
||||
// 缓存计数器,用于清除缓存
|
||||
const mermaidCacheCount = new Map<string, number>();
|
||||
let count = 0;
|
||||
|
||||
|
||||
let countTmo = setTimeout(() => undefined, 0);
|
||||
const addCount = () => {
|
||||
clearTimeout(countTmo);
|
||||
countTmo = setTimeout(() => {
|
||||
count++;
|
||||
clearCache();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const clearCache = () => {
|
||||
for (const key of mermaidCacheCount.keys()) {
|
||||
const value = mermaidCacheCount.get(key)!;
|
||||
if (value + 3 < count) {
|
||||
mermaidCache.delete(key);
|
||||
mermaidCacheCount.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 mermaid
|
||||
* @param code mermaid 代码
|
||||
* @param targetId 目标 id
|
||||
* @param count 计数器
|
||||
*/
|
||||
const renderMermaid = async (code: string, targetId: string, count: number) => {
|
||||
let limit = 100;
|
||||
while (limit-- > 0) {
|
||||
const container = document.getElementById(targetId);
|
||||
if (!container) {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
|
||||
container.innerHTML = svg;
|
||||
mermaidCache.set(targetId, container);
|
||||
mermaidCacheCount.set(targetId, count);
|
||||
} catch (e) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export interface MermaidItOptions {
|
||||
theme?: "default" | "dark" | "forest" | "neutral" | "base";
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 mermaid 主题
|
||||
*/
|
||||
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
// 清空缓存,强制重新渲染
|
||||
mermaidCache.clear();
|
||||
mermaidCacheCount.clear();
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* mermaid 插件
|
||||
* @param md markdown-it
|
||||
* @param options 配置选项
|
||||
* @constructor MermaidIt
|
||||
*/
|
||||
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
|
||||
const theme = options?.theme || "default";
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
|
||||
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
|
||||
addCount();
|
||||
const token = tokens[idx];
|
||||
const info = token.info.trim();
|
||||
if (info === "mermaid") {
|
||||
const containerId = "mermaid-container-" + hashCode(token.content);
|
||||
const container = document.createElement("div");
|
||||
container.id = containerId;
|
||||
if (mermaidCache.has(containerId)) {
|
||||
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
|
||||
mermaidCacheCount.set(containerId, count);
|
||||
} else {
|
||||
renderMermaid(token.content, containerId, count).then();
|
||||
}
|
||||
return container.outerHTML;
|
||||
}
|
||||
// 使用默认的渲染规则
|
||||
return defaultRenderer(tokens, idx, options, env, self);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/**
|
||||
* uuid 生成函数
|
||||
* @param split 分隔符
|
||||
*/
|
||||
export const genUid = (split = "") => {
|
||||
return uuidv4().split("-").join(split);
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的sleep函数
|
||||
*/
|
||||
export const sleep = async (ms: number) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算字符串的hash值
|
||||
* 返回一个数字
|
||||
* @param str
|
||||
*/
|
||||
export const hashCode = (str: string) => {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的阻塞函数
|
||||
*/
|
||||
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
if (cb()) return true;
|
||||
if (timeout && Date.now() - start > timeout) {
|
||||
console.error("阻塞超时: " + errText);
|
||||
return false;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
// Process ~subscript~
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function subscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sub_open', 'sub', 1);
|
||||
token_so.markup = '~';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sub_close', 'sub', -1);
|
||||
token_sc.markup = '~';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sub 插件
|
||||
* 用于支持下标语法 ~text~
|
||||
*/
|
||||
export default function sub_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sub', subscript);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Process ^superscript^
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function superscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sup_open', 'sup', 1);
|
||||
token_so.markup = '^';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sup_close', 'sup', -1);
|
||||
token_sc.markup = '^';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sup 插件
|
||||
* 用于支持上标语法 ^text^
|
||||
*/
|
||||
export default function sup_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sup', superscript);
|
||||
}
|
||||
@@ -13,8 +13,6 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
|
||||
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
||||
import {markdownPreviewManager} from "@/views/editor/extensions/markdownPreview/manager";
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const configStore = useConfigStore();
|
||||
@@ -25,7 +23,6 @@ const {t} = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const canFormatCurrentBlock = ref(false);
|
||||
const canPreviewMarkdown = ref(false);
|
||||
const isLoaded = shallowRef(false);
|
||||
|
||||
const { documentStats } = toRefs(editorStore);
|
||||
@@ -36,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => {
|
||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||
});
|
||||
|
||||
// 当前文档的预览是否打开
|
||||
const isCurrentBlockPreviewing = computed(() => {
|
||||
return markdownPreviewManager.isVisible();
|
||||
});
|
||||
|
||||
// 切换窗口置顶状态
|
||||
const toggleAlwaysOnTop = async () => {
|
||||
@@ -68,22 +61,12 @@ const formatCurrentBlock = () => {
|
||||
formatBlockContent(editorStore.editorView);
|
||||
};
|
||||
|
||||
// 切换 Markdown 预览
|
||||
const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
|
||||
if (!canPreviewMarkdown.value || !editorStore.editorView) return;
|
||||
toggleMarkdownPreview(editorStore.editorView as any);
|
||||
}, { delay: 200 });
|
||||
|
||||
const togglePreview = () => {
|
||||
debouncedTogglePreview();
|
||||
};
|
||||
|
||||
// 统一更新按钮状态
|
||||
const updateButtonStates = () => {
|
||||
const view: any = editorStore.editorView;
|
||||
if (!view) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,7 +77,6 @@ const updateButtonStates = () => {
|
||||
// 提前返回,减少不必要的计算
|
||||
if (!activeBlock) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,11 +84,9 @@ const updateButtonStates = () => {
|
||||
const language = getLanguage(languageName as any);
|
||||
|
||||
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
||||
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
|
||||
} catch (error) {
|
||||
console.warn('Error checking block capabilities:', error);
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,7 +140,6 @@ watch(
|
||||
cleanupListeners = setupEditorListeners(newView);
|
||||
} else {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -254,21 +233,6 @@ const statsData = computed(() => ({
|
||||
<!-- 块语言选择器 -->
|
||||
<BlockLanguageSelector/>
|
||||
|
||||
<!-- Markdown预览按钮 -->
|
||||
<div
|
||||
v-if="canPreviewMarkdown"
|
||||
class="preview-button"
|
||||
:class="{ 'active': isCurrentBlockPreviewing }"
|
||||
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 格式化按钮 - 支持点击操作 -->
|
||||
<div
|
||||
v-if="canFormatCurrentBlock"
|
||||
|
||||
@@ -111,7 +111,6 @@ export default {
|
||||
deleteCharForward: 'Delete character forward',
|
||||
deleteGroupBackward: 'Delete group backward',
|
||||
deleteGroupForward: 'Delete group forward',
|
||||
textHighlightToggle: 'Toggle text highlight',
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
@@ -257,7 +256,7 @@ export default {
|
||||
},
|
||||
colorSelector: {
|
||||
name: 'Color Selector',
|
||||
description: 'Visual color picker and color value display'
|
||||
description: 'CSS code block visual color picker and color value display'
|
||||
},
|
||||
translator: {
|
||||
name: 'Text Translator',
|
||||
@@ -275,19 +274,29 @@ export default {
|
||||
name: 'Code Folding',
|
||||
description: 'Collapse and expand code sections for better readability'
|
||||
},
|
||||
textHighlight: {
|
||||
name: 'Text Highlight',
|
||||
description: 'Highlight selected text content (Ctrl+Shift+H to toggle highlight)',
|
||||
backgroundColor: 'Background Color',
|
||||
opacity: 'Opacity'
|
||||
},
|
||||
checkbox: {
|
||||
name: 'Checkbox',
|
||||
description: 'Render [x] and [ ] as interactive checkboxes'
|
||||
markdown: {
|
||||
name: 'Markdown Renderer',
|
||||
description: 'Render Markdown elements, "what you see is what you get"'
|
||||
},
|
||||
codeblock: {
|
||||
name: 'Code Block',
|
||||
description: 'Code block related functionality'
|
||||
},
|
||||
lineNumbers: {
|
||||
name: 'Line Numbers',
|
||||
description: 'Display line numbers on the left side of the editor and highlight the current line'
|
||||
},
|
||||
contextMenu: {
|
||||
name: 'Context Menu',
|
||||
description: 'Show context menu when right-clicking in the editor'
|
||||
},
|
||||
highlightWhitespace: {
|
||||
name: 'Highlight Whitespace',
|
||||
description: 'Display whitespace characters such as spaces and tabs in the editor'
|
||||
},
|
||||
highlightTrailingWhitespace: {
|
||||
name: 'Highlight Trailing Whitespace',
|
||||
description: 'Highlight trailing whitespace at the end of lines'
|
||||
}
|
||||
},
|
||||
monitor: {
|
||||
|
||||
@@ -111,7 +111,6 @@ export default {
|
||||
deleteCharForward: '向前删除字符',
|
||||
deleteGroupBackward: '向后删除组',
|
||||
deleteGroupForward: '向前删除组',
|
||||
textHighlightToggle: '切换文本高亮',
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
@@ -259,7 +258,7 @@ export default {
|
||||
},
|
||||
colorSelector: {
|
||||
name: '颜色选择器',
|
||||
description: '颜色值的可视化和选择'
|
||||
description: 'CSS代码块颜色值的可视化和选择'
|
||||
},
|
||||
translator: {
|
||||
name: '划词翻译',
|
||||
@@ -277,19 +276,29 @@ export default {
|
||||
name: '代码折叠',
|
||||
description: '折叠和展开代码段以提高代码可读性'
|
||||
},
|
||||
textHighlight: {
|
||||
name: '文本高亮',
|
||||
description: '高亮选中的文本内容 (Ctrl+Shift+H 切换高亮)',
|
||||
backgroundColor: '背景颜色',
|
||||
opacity: '透明度'
|
||||
},
|
||||
checkbox: {
|
||||
name: '选择框',
|
||||
description: '将 [x] 和 [ ] 渲染为可交互的选择框'
|
||||
markdown: {
|
||||
name: 'Markdown 渲染',
|
||||
description: '渲染 Markdown 元素,“所见即所得”'
|
||||
},
|
||||
codeblock: {
|
||||
name: '代码块',
|
||||
description: '代码块相关功能'
|
||||
},
|
||||
lineNumbers: {
|
||||
name: '行号显示',
|
||||
description: '在编辑器左侧显示行号,并高亮当前行'
|
||||
},
|
||||
contextMenu: {
|
||||
name: '上下文菜单',
|
||||
description: '在编辑器中右键点击时显示上下文菜单'
|
||||
},
|
||||
highlightWhitespace: {
|
||||
name: '显示空白字符',
|
||||
description: '在编辑器中显示空格和制表符等空白字符'
|
||||
},
|
||||
highlightTrailingWhitespace: {
|
||||
name: '高亮行尾空白',
|
||||
description: '高亮显示行尾的多余空白字符'
|
||||
}
|
||||
},
|
||||
monitor: {
|
||||
|
||||
@@ -3,29 +3,23 @@ import {computed, reactive} from 'vue';
|
||||
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
AuthMethod,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
GitBackupConfig,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig
|
||||
TabType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {ConfigUtils} from '@/common/utils/configUtils';
|
||||
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
||||
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||
import {
|
||||
APPEARANCE_CONFIG_KEY_MAP,
|
||||
BACKUP_CONFIG_KEY_MAP,
|
||||
CONFIG_KEY_MAP,
|
||||
CONFIG_LIMITS,
|
||||
ConfigKey,
|
||||
ConfigSection,
|
||||
DEFAULT_CONFIG,
|
||||
EDITING_CONFIG_KEY_MAP,
|
||||
GENERAL_CONFIG_KEY_MAP,
|
||||
NumberConfigKey,
|
||||
UPDATES_CONFIG_KEY_MAP
|
||||
NumberConfigKey
|
||||
} from '@/common/constant/config';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
@@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => {
|
||||
// Font options (no longer localized)
|
||||
const fontOptions = computed(() => FONT_OPTIONS);
|
||||
|
||||
// 计算属性 - 使用工厂函数简化
|
||||
// 计算属性
|
||||
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
||||
const limits = Object.fromEntries(
|
||||
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
||||
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
|
||||
|
||||
// 通用配置更新方法
|
||||
const updateGeneralConfig = async <K extends keyof GeneralConfig>(key: K, value: GeneralConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
// 统一配置更新方法
|
||||
const updateConfig = async <K extends ConfigKey>(key: K, value: any): Promise<void> => {
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = GENERAL_CONFIG_KEY_MAP[key];
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for general.${key.toString()}`);
|
||||
throw new Error(`No backend key mapping found for ${String(key)}`);
|
||||
}
|
||||
|
||||
// 从 backendKey 提取 section(例如 'general.alwaysOnTop' -> 'general')
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.general[key] = value;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateEditingConfig = async <K extends keyof EditingConfig>(key: K, value: EditingConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = EDITING_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for editing.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.editing[key] = value;
|
||||
// 只更新本地状态,不保存到后端
|
||||
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateAppearanceConfig = async <K extends keyof AppearanceConfig>(key: K, value: AppearanceConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = APPEARANCE_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for appearance.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.appearance[key] = value;
|
||||
};
|
||||
|
||||
const updateUpdatesConfig = async <K extends keyof UpdatesConfig>(key: K, value: UpdatesConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = UPDATES_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for updates.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.updates[key] = value;
|
||||
};
|
||||
|
||||
const updateBackupConfig = async <K extends keyof GitBackupConfig>(key: K, value: GitBackupConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = BACKUP_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for backup.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.backup[key] = value;
|
||||
// 保存指定配置到后端
|
||||
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
|
||||
};
|
||||
|
||||
// 加载配置
|
||||
@@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
||||
|
||||
return {
|
||||
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
|
||||
reset: async () => await updateEditingConfig(key, limit.default)
|
||||
increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateConfig(key, clamp(value)),
|
||||
reset: async () => await updateConfig(key, limit.default),
|
||||
increaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] + 1)),
|
||||
decreaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] - 1))
|
||||
};
|
||||
};
|
||||
|
||||
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
|
||||
async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]);
|
||||
async () => await updateConfig(key as ConfigKey, !state.config.editing[key] as EditingConfig[T]);
|
||||
|
||||
// 枚举值切换器
|
||||
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
|
||||
async () => {
|
||||
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
||||
const nextIndex = (currentIndex + 1) % values.length;
|
||||
return await updateEditingConfig(key, values[nextIndex]);
|
||||
return await updateConfig(key, values[nextIndex]);
|
||||
};
|
||||
|
||||
// 重置配置
|
||||
@@ -192,21 +144,19 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 语言设置方法
|
||||
const setLanguage = async (language: LanguageType): Promise<void> => {
|
||||
await updateAppearanceConfig('language', language);
|
||||
|
||||
// 同步更新前端语言
|
||||
await updateConfig('language', language);
|
||||
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
||||
locale.value = frontendLocale as any;
|
||||
};
|
||||
|
||||
// 系统主题设置方法
|
||||
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
||||
await updateAppearanceConfig('systemTheme', systemTheme);
|
||||
await updateConfig('systemTheme', systemTheme);
|
||||
};
|
||||
|
||||
// 当前主题设置方法
|
||||
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
||||
await updateAppearanceConfig('currentTheme', themeName);
|
||||
await updateConfig('currentTheme', themeName);
|
||||
};
|
||||
|
||||
|
||||
@@ -238,21 +188,12 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const togglers = {
|
||||
tabIndent: createEditingToggler('enableTabIndent'),
|
||||
alwaysOnTop: async () => {
|
||||
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
// 立即应用窗口置顶状态
|
||||
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
||||
},
|
||||
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
|
||||
};
|
||||
|
||||
// 字符串配置设置器
|
||||
const setters = {
|
||||
fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value),
|
||||
fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value),
|
||||
dataPath: async (value: string) => await updateGeneralConfig('dataPath', value),
|
||||
autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value)
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
config: computed(() => state.config),
|
||||
@@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => {
|
||||
decreaseFontSize: adjusters.fontSize.decrease,
|
||||
resetFontSize: adjusters.fontSize.reset,
|
||||
setFontSize: adjusters.fontSize.set,
|
||||
// 字体大小操作
|
||||
increaseFontSizeLocal: adjusters.fontSize.increaseLocal,
|
||||
decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal,
|
||||
saveFontSize: () => saveConfig('fontSize'),
|
||||
|
||||
// Tab操作
|
||||
toggleTabIndent: togglers.tabIndent,
|
||||
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
|
||||
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
|
||||
...adjusters.tabSize,
|
||||
increaseTabSize: adjusters.tabSize.increase,
|
||||
decreaseTabSize: adjusters.tabSize.decrease,
|
||||
@@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 窗口操作
|
||||
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
||||
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
|
||||
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
|
||||
|
||||
// 字体操作
|
||||
setFontFamily: setters.fontFamily,
|
||||
setFontWeight: setters.fontWeight,
|
||||
setFontFamily: (value: string) => updateConfig('fontFamily', value),
|
||||
setFontWeight: (value: string) => updateConfig('fontWeight', value),
|
||||
|
||||
// 路径操作
|
||||
setDataPath: setters.dataPath,
|
||||
setDataPath: (value: string) => updateConfig('dataPath', value),
|
||||
|
||||
// 保存配置相关方法
|
||||
setAutoSaveDelay: setters.autoSaveDelay,
|
||||
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
|
||||
|
||||
// 热键配置相关方法
|
||||
setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
|
||||
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
|
||||
|
||||
// 系统托盘配置相关方法
|
||||
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
|
||||
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
|
||||
|
||||
// 开机启动配置相关方法
|
||||
setStartAtLogin: async (value: boolean) => {
|
||||
// 先更新配置文件
|
||||
await updateGeneralConfig('startAtLogin', value);
|
||||
// 再调用系统设置API
|
||||
await updateConfig('startAtLogin', value);
|
||||
await StartupService.SetEnabled(value);
|
||||
},
|
||||
|
||||
// 窗口吸附配置相关方法
|
||||
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
|
||||
setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value),
|
||||
|
||||
// 加载动画配置相关方法
|
||||
setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value),
|
||||
setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value),
|
||||
|
||||
// 标签页配置相关方法
|
||||
setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value),
|
||||
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
|
||||
|
||||
// 更新配置相关方法
|
||||
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
|
||||
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),
|
||||
|
||||
// 备份配置相关方法
|
||||
setEnableBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('enabled', value);
|
||||
},
|
||||
setAutoBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('auto_backup', value);
|
||||
},
|
||||
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
|
||||
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
|
||||
setUsername: async (value: string) => await updateBackupConfig('username', value),
|
||||
setPassword: async (value: string) => await updateBackupConfig('password', value),
|
||||
setToken: async (value: string) => await updateBackupConfig('token', value),
|
||||
setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value),
|
||||
setEnableBackup: (value: boolean) => updateConfig('enabled', value),
|
||||
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value),
|
||||
setRepoUrl: (value: string) => updateConfig('repo_url', value),
|
||||
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value),
|
||||
setUsername: (value: string) => updateConfig('username', value),
|
||||
setPassword: (value: string) => updateConfig('password', value),
|
||||
setToken: (value: string) => updateConfig('token', value),
|
||||
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: (value: number) => updateConfig('backup_interval', value),
|
||||
};
|
||||
});
|
||||
@@ -29,7 +29,6 @@ import {generateContentHash} from "@/common/utils/hashUtils";
|
||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
export interface DocumentStats {
|
||||
@@ -242,10 +241,12 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
fontWeight: configStore.config.editing.fontWeight
|
||||
});
|
||||
|
||||
const wheelZoomExtension = createWheelZoomExtension(
|
||||
() => configStore.increaseFontSize(),
|
||||
() => configStore.decreaseFontSize()
|
||||
);
|
||||
const wheelZoomExtension = createWheelZoomExtension({
|
||||
increaseFontSize: () => configStore.increaseFontSizeLocal(),
|
||||
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
|
||||
onSave: () => configStore.saveFontSize(),
|
||||
saveDelay: 500
|
||||
});
|
||||
|
||||
// 统计扩展
|
||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||
@@ -261,8 +262,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
const httpExtension = createHttpClientExtension();
|
||||
|
||||
// Markdown预览扩展
|
||||
const previewExtension = markdownPreviewExtension();
|
||||
|
||||
// 再次检查操作有效性
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
@@ -298,7 +297,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
codeBlockExtension,
|
||||
...dynamicExtensions,
|
||||
...httpExtension,
|
||||
previewExtension
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
|
||||
@@ -138,7 +138,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
|
||||
const refreshEditorTheme = () => {
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
editorStore?.applyThemeSettings();
|
||||
};
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useDocumentStore } from '@/stores/documentStore';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
||||
import { useWindowStore } from '@/stores/windowStore';
|
||||
import {useWindowStore} from '@/stores/windowStore';
|
||||
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||
import { useTabStore } from '@/stores/tabStore';
|
||||
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||
import { contextMenuManager } from './contextMenu/manager';
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import ContextMenu from '@/views/editor/extensions/contextMenu/ContextMenu.vue';
|
||||
import {contextMenuManager} from '@/views/editor/extensions/contextMenu/manager';
|
||||
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||
import { translatorManager } from './extensions/translator/manager';
|
||||
import {markdownPreviewManager} from "@/views/editor/extensions/markdownPreview/manager";
|
||||
import PreviewPanel from "@/views/editor/extensions/markdownPreview/PreviewPanel.vue";
|
||||
import {translatorManager} from './extensions/translator/manager';
|
||||
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const documentStore = useDocumentStore();
|
||||
@@ -39,7 +38,6 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
contextMenuManager.destroy();
|
||||
translatorManager.destroy();
|
||||
markdownPreviewManager.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -47,21 +45,17 @@ onBeforeUnmount(() => {
|
||||
<div class="editor-container">
|
||||
<!-- 加载动画 -->
|
||||
<transition name="loading-fade">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||
</transition>
|
||||
<!-- 编辑器和预览面板的容器 -->
|
||||
<div class="editor-wrapper">
|
||||
<!-- 编辑器区域 -->
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
<!-- Markdown 预览面板 -->
|
||||
<PreviewPanel />
|
||||
</div>
|
||||
<!-- 编辑器区域 -->
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar />
|
||||
<Toolbar/>
|
||||
<!-- 右键菜单 -->
|
||||
<ContextMenu :portal-target="editorElement" />
|
||||
<ContextMenu :portal-target="editorElement"/>
|
||||
<!-- 翻译器弹窗 -->
|
||||
<TranslatorDialog :portal-target="editorElement" />
|
||||
<TranslatorDialog :portal-target="editorElement"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,15 +68,6 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,30 +5,20 @@ import {
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
rectangularSelection,
|
||||
scrollPastEnd
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
bracketMatching,
|
||||
defaultHighlightStyle,
|
||||
foldGutter,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import {bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting,} from '@codemirror/language';
|
||||
import {history} from '@codemirror/commands';
|
||||
import {highlightSelectionMatches} from '@codemirror/search';
|
||||
import {autocompletion, closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
|
||||
import createEditorContextMenu from '../contextMenu';
|
||||
import {closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
|
||||
|
||||
// 基本编辑器设置
|
||||
export const createBasicSetup = (): Extension[] => {
|
||||
return [
|
||||
// 基础UI
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
dropCursor(),
|
||||
EditorView.lineWrapping,
|
||||
@@ -36,9 +26,6 @@ export const createBasicSetup = (): Extension[] => {
|
||||
// 历史记录
|
||||
history(),
|
||||
|
||||
// 代码折叠
|
||||
foldGutter(),
|
||||
|
||||
// 选择与高亮
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
@@ -52,11 +39,7 @@ export const createBasicSetup = (): Extension[] => {
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
|
||||
// 自动完成
|
||||
autocompletion(),
|
||||
|
||||
// 上下文菜单
|
||||
createEditorContextMenu(),
|
||||
scrollPastEnd(),
|
||||
|
||||
// 键盘映射
|
||||
keymap.of([
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import type {Extension} from '@codemirror/state';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
type FontAdjuster = () => Promise<void> | void;
|
||||
type FontAdjuster = () => void;
|
||||
type SaveCallback = () => Promise<void> | void;
|
||||
|
||||
const runAdjuster = (adjuster: FontAdjuster) => {
|
||||
try {
|
||||
const result = adjuster();
|
||||
if (result && typeof (result as Promise<void>).then === 'function') {
|
||||
(result as Promise<void>).catch((error) => {
|
||||
console.error('Failed to adjust font size:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to adjust font size:', error);
|
||||
}
|
||||
};
|
||||
export interface WheelZoomOptions {
|
||||
/** 增加字体大小的回调(立即执行) */
|
||||
increaseFontSize: FontAdjuster;
|
||||
/** 减少字体大小的回调(立即执行) */
|
||||
decreaseFontSize: FontAdjuster;
|
||||
/** 保存回调(防抖执行),在滚动结束后调用 */
|
||||
onSave?: SaveCallback;
|
||||
/** 保存防抖延迟(毫秒),默认 300ms */
|
||||
saveDelay?: number;
|
||||
}
|
||||
|
||||
export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => {
|
||||
const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options;
|
||||
|
||||
// 如果有 onSave 回调,创建防抖版本
|
||||
const {debouncedFn: debouncedSave} = onSave
|
||||
? createDebounce(() => {
|
||||
try {
|
||||
const result = onSave();
|
||||
if (result && typeof (result as Promise<void>).then === 'function') {
|
||||
(result as Promise<void>).catch((error) => {
|
||||
console.error('Failed to save font size:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save font size:', error);
|
||||
}
|
||||
}, {delay: saveDelay})
|
||||
: {debouncedFn: null};
|
||||
|
||||
export const createWheelZoomExtension = (
|
||||
increaseFontSize: FontAdjuster,
|
||||
decreaseFontSize: FontAdjuster
|
||||
): Extension => {
|
||||
return EditorView.domEventHandlers({
|
||||
wheel(event) {
|
||||
if (!event.ctrlKey) {
|
||||
@@ -28,10 +43,16 @@ export const createWheelZoomExtension = (
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 立即更新字体大小
|
||||
if (event.deltaY < 0) {
|
||||
runAdjuster(increaseFontSize);
|
||||
increaseFontSize();
|
||||
} else if (event.deltaY > 0) {
|
||||
runAdjuster(decreaseFontSize);
|
||||
decreaseFontSize();
|
||||
}
|
||||
|
||||
// 防抖保存
|
||||
if (debouncedSave) {
|
||||
debouncedSave();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { EditorView, Decoration } from "@codemirror/view";
|
||||
import { WidgetType } from "@codemirror/view";
|
||||
import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view";
|
||||
import { Extension, StateEffect } from "@codemirror/state";
|
||||
|
||||
// 创建字体变化效果
|
||||
const fontChangeEffect = StateEffect.define<void>();
|
||||
|
||||
/**
|
||||
* 复选框小部件类
|
||||
*/
|
||||
class CheckboxWidget extends WidgetType {
|
||||
constructor(readonly checked: boolean) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: CheckboxWidget) {
|
||||
return other.checked == this.checked;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.setAttribute("aria-hidden", "true");
|
||||
wrap.className = "cm-checkbox-toggle";
|
||||
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.checked = this.checked;
|
||||
box.tabIndex = -1;
|
||||
box.style.margin = "0";
|
||||
box.style.padding = "0";
|
||||
box.style.cursor = "pointer";
|
||||
box.style.position = "relative";
|
||||
box.style.top = "0.1em";
|
||||
box.style.marginRight = "0.5em";
|
||||
// 设置相对单位,让复选框跟随字体大小变化
|
||||
box.style.width = "1em";
|
||||
box.style.height = "1em";
|
||||
|
||||
wrap.appendChild(box);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找并创建复选框装饰
|
||||
*/
|
||||
function findCheckboxes(view: EditorView) {
|
||||
const widgets: any = [];
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
// 使用正则表达式查找 [x] 或 [ ] 模式
|
||||
const text = doc.sliceString(from, to);
|
||||
const checkboxRegex = /\[([ x])\]/gi;
|
||||
let match;
|
||||
|
||||
while ((match = checkboxRegex.exec(text)) !== null) {
|
||||
const matchPos = from + match.index;
|
||||
const matchEnd = matchPos + match[0].length;
|
||||
|
||||
// 检查前面是否有 "- " 模式
|
||||
const beforeTwoChars = matchPos >= 2 ? doc.sliceString(matchPos - 2, matchPos) : "";
|
||||
const afterChar = matchEnd < doc.length ? doc.sliceString(matchEnd, matchEnd + 1) : "";
|
||||
|
||||
// 只有当前面是 "- " 且后面跟空格或行尾时才渲染
|
||||
if (beforeTwoChars === "- " &&
|
||||
(afterChar === "" || afterChar === " " || afterChar === "\t" || afterChar === "\n")) {
|
||||
|
||||
const isChecked = match[1].toLowerCase() === "x";
|
||||
const deco = Decoration.replace({
|
||||
widget: new CheckboxWidget(isChecked),
|
||||
inclusive: false,
|
||||
});
|
||||
// 替换整个 "- [ ]" 或 "- [x]" 模式,包括前面的 "- "
|
||||
widgets.push(deco.range(matchPos - 2, matchEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换复选框状态
|
||||
*/
|
||||
function toggleCheckbox(view: EditorView, pos: number) {
|
||||
const doc = view.state.doc;
|
||||
|
||||
// 查找当前位置附近的复选框模式(需要前面有 "- ")
|
||||
for (let offset = -5; offset <= 0; offset++) {
|
||||
const checkPos = pos + offset;
|
||||
if (checkPos >= 2 && checkPos + 3 <= doc.length) {
|
||||
// 检查是否有 "- " 前缀
|
||||
const prefix = doc.sliceString(checkPos - 2, checkPos);
|
||||
const text = doc.sliceString(checkPos, checkPos + 3).toLowerCase();
|
||||
|
||||
if (prefix === "- ") {
|
||||
let change;
|
||||
|
||||
if (text === "[x]") {
|
||||
// 替换整个 "- [x]" 为 "- [ ]"
|
||||
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [ ]" };
|
||||
} else if (text === "[ ]") {
|
||||
// 替换整个 "- [ ]" 为 "- [x]"
|
||||
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [x]" };
|
||||
}
|
||||
|
||||
if (change) {
|
||||
view.dispatch({ changes: change });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建字体变化效果的便捷函数
|
||||
export const triggerFontChange = (view: EditorView) => {
|
||||
view.dispatch({
|
||||
effects: fontChangeEffect.of(undefined)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建复选框扩展
|
||||
*/
|
||||
export function createCheckboxExtension(): Extension {
|
||||
return [
|
||||
// 主要的复选框插件
|
||||
ViewPlugin.fromClass(class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = findCheckboxes(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// 检查是否需要重新渲染复选框
|
||||
const shouldUpdate = update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
update.geometryChanged ||
|
||||
update.transactions.some(tr => tr.effects.some(e => e.is(fontChangeEffect)));
|
||||
|
||||
if (shouldUpdate) {
|
||||
this.decorations = findCheckboxes(update.view);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
decorations: v => v.decorations,
|
||||
|
||||
eventHandlers: {
|
||||
mousedown: (e, view) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.nodeName == "INPUT" && target.parentElement!.classList.contains("cm-checkbox-toggle")) {
|
||||
const pos = view.posAtDOM(target);
|
||||
return toggleCheckbox(view, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 复选框样式
|
||||
EditorView.theme({
|
||||
".cm-checkbox-toggle": {
|
||||
display: "inline-block",
|
||||
verticalAlign: "baseline",
|
||||
},
|
||||
".cm-checkbox-toggle input[type=checkbox]": {
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
verticalAlign: "baseline",
|
||||
cursor: "pointer",
|
||||
// 确保复选框大小跟随字体
|
||||
fontSize: "inherit",
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
// 默认导出
|
||||
export const checkboxExtension = createCheckboxExtension();
|
||||
|
||||
// 导出类型和工具函数
|
||||
export {
|
||||
CheckboxWidget,
|
||||
toggleCheckbox,
|
||||
findCheckboxes
|
||||
};
|
||||
@@ -115,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass(
|
||||
|
||||
/**
|
||||
* 块背景层 - 修复高度计算问题
|
||||
*
|
||||
* 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。
|
||||
* 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0),
|
||||
* 行的坐标也不会受影响,边界线位置正确。
|
||||
*/
|
||||
const blockLayer = layer({
|
||||
above: false,
|
||||
@@ -135,14 +139,17 @@ const blockLayer = layer({
|
||||
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;
|
||||
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from);
|
||||
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to);
|
||||
|
||||
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
|
||||
idx++;
|
||||
return;
|
||||
}
|
||||
// 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0)影响
|
||||
const fromLineBlock = view.lineBlockAt(fromPos);
|
||||
const toLineBlock = view.lineBlockAt(toPos);
|
||||
|
||||
// lineBlockAt 返回的 top 是相对于内容区域的偏移
|
||||
// 转换为视口坐标进行后续计算
|
||||
const fromCoordsTop = fromLineBlock.top + view.documentTop;
|
||||
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
|
||||
|
||||
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
||||
if (idx === blocks.length - 1) {
|
||||
@@ -151,7 +158,7 @@ const blockLayer = layer({
|
||||
|
||||
// 让最后一个块直接延伸到编辑器底部
|
||||
if (contentBottom < editorHeight) {
|
||||
const extraHeight = editorHeight - contentBottom-10;
|
||||
const extraHeight = editorHeight - contentBottom - 10;
|
||||
toCoordsBottom += extraHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ const blockLineNumbers = lineNumbers({
|
||||
|
||||
/**
|
||||
* 创建代码块扩展
|
||||
* 注意:blockLineNumbers 已移至动态扩展管理,通过 ExtensionLineNumbers 控制
|
||||
*/
|
||||
export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension {
|
||||
const {
|
||||
@@ -91,9 +92,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
|
||||
// 核心状态管理
|
||||
blockState,
|
||||
|
||||
// 块内行号
|
||||
blockLineNumbers,
|
||||
|
||||
// 语言解析支持
|
||||
...getCodeBlockLanguageExtension(),
|
||||
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
import {jsonLanguage} from "@codemirror/lang-json";
|
||||
import {pythonLanguage} from "@codemirror/lang-python";
|
||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
||||
import {htmlLanguage} from "@codemirror/lang-html";
|
||||
import {html, htmlLanguage} from "@codemirror/lang-html";
|
||||
import {StandardSQL} from "@codemirror/lang-sql";
|
||||
import {markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
||||
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
||||
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||
import {Emoji} from "@/views/editor/extensions/markdown/syntax/emoji";
|
||||
import {javaLanguage} from "@codemirror/lang-java";
|
||||
import {phpLanguage} from "@codemirror/lang-php";
|
||||
import {cssLanguage} from "@codemirror/lang-css";
|
||||
@@ -22,9 +28,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
|
||||
import {sassLanguage} from "@codemirror/lang-sass";
|
||||
import {lessLanguage} from "@codemirror/lang-less";
|
||||
import {angularLanguage} from "@codemirror/lang-angular";
|
||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import { mermaidLanguage } from '@/views/editor/language/mermaid';
|
||||
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
|
||||
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import {mermaidLanguage} from '@/views/editor/language/mermaid';
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||
@@ -64,6 +70,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||
import {languages} from "@codemirror/language-data";
|
||||
|
||||
/**
|
||||
* 语言信息类
|
||||
@@ -110,7 +117,19 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
parser: "sql",
|
||||
plugins: [sqlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
|
||||
new LanguageInfo("md", "Markdown", markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji],
|
||||
completeHTMLTags: true,
|
||||
pasteURLAsLink: true,
|
||||
htmlTagLanguage: html({
|
||||
matchClosingTags: true,
|
||||
autoCloseTags: true
|
||||
}),
|
||||
addKeymap: true,
|
||||
codeLanguages: languages,
|
||||
|
||||
}).language.parser, ["md"], {
|
||||
parser: "markdown",
|
||||
plugins: [markdownPrettierPlugin]
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { copyCommand, cutCommand, pasteCommand } from '../extensions/codeblock/copyPaste';
|
||||
import { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||
import { copyCommand, cutCommand, pasteCommand } from '../codeblock/copyPaste';
|
||||
import { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||
import { undo, redo } from '@codemirror/commands';
|
||||
import i18n from '@/i18n';
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import type { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||
import type { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
|
||||
|
||||
export interface MenuContext {
|
||||
view: EditorView;
|
||||
@@ -1,37 +0,0 @@
|
||||
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,14 +3,106 @@ import {
|
||||
EditorView,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
MatchDecorator,
|
||||
WidgetType,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { Extension, ChangeSet } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import * as runtime from "@wailsio/runtime";
|
||||
|
||||
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
||||
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
||||
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/g;
|
||||
|
||||
/** Stored hyperlink info for incremental updates */
|
||||
interface HyperLinkInfo {
|
||||
url: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document changes affect any of the given link regions.
|
||||
*/
|
||||
function changesAffectLinks(changes: ChangeSet, links: HyperLinkInfo[]): boolean {
|
||||
if (links.length === 0) return true;
|
||||
|
||||
let affected = false;
|
||||
changes.iterChanges((fromA, toA) => {
|
||||
if (affected) return;
|
||||
for (const link of links) {
|
||||
// Check if change overlaps with link region (with some buffer for insertions)
|
||||
if (fromA <= link.to && toA >= link.from) {
|
||||
affected = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return affected;
|
||||
}
|
||||
|
||||
// Markdown link parent nodes that should be excluded from hyperlink decoration
|
||||
const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']);
|
||||
|
||||
/**
|
||||
* Check if a position is inside a markdown link syntax node.
|
||||
* This prevents hyperlink decorations from conflicting with markdown rendering.
|
||||
*/
|
||||
function isInMarkdownLink(view: EditorView, from: number, to: number): boolean {
|
||||
const tree = syntaxTree(view.state);
|
||||
let inLink = false;
|
||||
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
if (MARKDOWN_LINK_PARENTS.has(node.name)) {
|
||||
inLink = true;
|
||||
return false; // Stop iteration
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return inLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract hyperlinks from visible ranges only.
|
||||
* This is the key optimization - we only scan what's visible.
|
||||
*/
|
||||
function extractVisibleLinks(view: EditorView): HyperLinkInfo[] {
|
||||
const result: HyperLinkInfo[] = [];
|
||||
const seen = new Set<string>(); // Dedupe by position key
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
// Get the text for this visible range
|
||||
const rangeText = view.state.sliceDoc(from, to);
|
||||
|
||||
// Reset regex lastIndex for each range
|
||||
const regex = new RegExp(defaultRegexp.source, 'gi');
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(rangeText)) !== null) {
|
||||
const linkFrom = from + match.index;
|
||||
const linkTo = linkFrom + match[0].length;
|
||||
const key = `${linkFrom}:${linkTo}`;
|
||||
|
||||
// Skip duplicates
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
// Skip URLs inside markdown link syntax
|
||||
if (isInMarkdownLink(view, linkFrom, linkTo)) continue;
|
||||
|
||||
result.push({
|
||||
url: match[0],
|
||||
from: linkFrom,
|
||||
to: linkTo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface HyperLinkState {
|
||||
at: number;
|
||||
@@ -44,86 +136,80 @@ class HyperLinkIcon extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) {
|
||||
const widgets: Array<Range<Decoration>> = [];
|
||||
const doc = view.state.doc.toString();
|
||||
let match;
|
||||
|
||||
while ((match = defaultRegexp.exec(doc)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
/**
|
||||
* Build decorations from extracted link info.
|
||||
*/
|
||||
function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
|
||||
const decorations: ReturnType<Decoration['range']>[] = [];
|
||||
|
||||
for (const link of links) {
|
||||
// Add text decoration
|
||||
decorations.push(Decoration.mark({
|
||||
class: 'cm-hyper-link-text'
|
||||
});
|
||||
widgets.push(linkMark.range(from, to));
|
||||
}).range(link.from, link.to));
|
||||
|
||||
const widget = Decoration.widget({
|
||||
// Add icon widget
|
||||
decorations.push(Decoration.widget({
|
||||
widget: new HyperLinkIcon({
|
||||
at: to,
|
||||
url: match[0],
|
||||
at: link.to,
|
||||
url: link.url,
|
||||
anchor,
|
||||
}),
|
||||
side: 1,
|
||||
});
|
||||
widgets.push(widget.range(to));
|
||||
}).range(link.to));
|
||||
}
|
||||
|
||||
return Decoration.set(widgets);
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
const linkDecorator = (
|
||||
regexp?: RegExp,
|
||||
matchData?: Record<string, string>,
|
||||
matchFn?: (str: string, input: string, from: number, to: number) => string,
|
||||
anchor?: HyperLinkExtensionOptions['anchor'],
|
||||
) =>
|
||||
new MatchDecorator({
|
||||
regexp: regexp || defaultRegexp,
|
||||
decorate: (add, from, to, match, _view) => {
|
||||
const url = match[0];
|
||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||
if (matchData && matchData[url]) {
|
||||
urlStr = matchData[url];
|
||||
}
|
||||
const start = to,
|
||||
end = to;
|
||||
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
|
||||
|
||||
add(from, to, Decoration.mark({
|
||||
class: 'cm-hyper-link-text cm-hyper-link-underline'
|
||||
}));
|
||||
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
|
||||
},
|
||||
});
|
||||
|
||||
export type HyperLinkExtensionOptions = {
|
||||
regexp?: RegExp;
|
||||
match?: Record<string, string>;
|
||||
handle?: (value: string, input: string, from: number, to: number) => string;
|
||||
/** Custom anchor element transformer */
|
||||
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) {
|
||||
/**
|
||||
* Optimized hyperlink extension with visible-range-only scanning.
|
||||
*
|
||||
* Performance optimizations:
|
||||
* 1. Only scans visible ranges (not the entire document)
|
||||
* 2. Incremental updates: maps positions when changes don't affect links
|
||||
* 3. Caches link info to avoid redundant re-extraction
|
||||
*/
|
||||
export function hyperLinkExtension({ anchor }: HyperLinkExtensionOptions = {}) {
|
||||
return ViewPlugin.fromClass(
|
||||
class HyperLinkView {
|
||||
decorator?: MatchDecorator;
|
||||
decorations: DecorationSet;
|
||||
links: HyperLinkInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
if (regexp) {
|
||||
this.decorator = linkDecorator(regexp, match, handle, anchor);
|
||||
this.decorations = this.decorator.createDeco(view);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(view, anchor);
|
||||
}
|
||||
this.links = extractVisibleLinks(view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
if (regexp && this.decorator) {
|
||||
this.decorations = this.decorator.updateDeco(update, this.decorations);
|
||||
// Always rebuild on viewport change (new content visible)
|
||||
if (update.viewportChanged) {
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
return;
|
||||
}
|
||||
|
||||
// For document changes, check if they affect link regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectLinks(update.changes, this.links);
|
||||
|
||||
if (needsRebuild) {
|
||||
// Changes affect links, full rebuild
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(update.view, anchor);
|
||||
// Just update positions of existing decorations
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
this.links = this.links.map(link => ({
|
||||
...link,
|
||||
from: update.changes.mapPos(link.from),
|
||||
to: update.changes.mapPos(link.to)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,8 +257,8 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
||||
|
||||
'.cm-hyper-link-icon svg': {
|
||||
display: 'block',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
width: 'inherit',
|
||||
height: 'inherit',
|
||||
},
|
||||
|
||||
'.cm-editor.cm-focused .cm-hyper-link-text': {
|
||||
|
||||
19
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
19
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { image } from './plugins/image';
|
||||
import { headingSlugField } from './state/heading-slug';
|
||||
import {html} from './plugins/html';
|
||||
import { render } from './plugins/render';
|
||||
import { Theme } from './plugins/theme';
|
||||
|
||||
/**
|
||||
* Markdown extensions.
|
||||
*/
|
||||
export const markdownExtensions: Extension = [
|
||||
headingSlugField,
|
||||
render(),
|
||||
Theme,
|
||||
image(),
|
||||
html()
|
||||
];
|
||||
|
||||
export default markdownExtensions;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Blockquote handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_BLOCKQUOTE_LINE = Decoration.line({ class: 'cm-blockquote' });
|
||||
|
||||
/**
|
||||
* Handle Blockquote node.
|
||||
*/
|
||||
export function handleBlockquote(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): boolean {
|
||||
if (ctx.seen.has(nf)) return false;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return false;
|
||||
|
||||
const startLine = ctx.view.state.doc.lineAt(nf).number;
|
||||
const endLine = ctx.view.state.doc.lineAt(nt).number;
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
if (!ctx.processedLines.has(i)) {
|
||||
ctx.processedLines.add(i);
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(i).from, to: ctx.view.state.doc.line(i).from, deco: DECO_BLOCKQUOTE_LINE });
|
||||
}
|
||||
}
|
||||
|
||||
// Use TreeCursor to traverse all descendant QuoteMarks
|
||||
// getChildren() only returns direct children, but QuoteMarks may be nested
|
||||
// deeper in the syntax tree (e.g., in nested blockquotes for empty lines)
|
||||
// cursor.next() is the official Lezer API for depth-first tree traversal
|
||||
const cursor = node.cursor();
|
||||
while (cursor.next() && cursor.to <= nt) {
|
||||
if (cursor.name === 'QuoteMark') {
|
||||
ctx.items.push({ from: cursor.from, to: cursor.to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for blockquotes.
|
||||
*/
|
||||
export const blockquoteTheme = EditorView.baseTheme({
|
||||
'.cm-blockquote': {
|
||||
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
||||
color: 'var(--cm-blockquote-color, #666)'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Code block handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_CODEBLOCK_LINE = Decoration.line({ class: 'cm-codeblock' });
|
||||
const DECO_CODEBLOCK_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
|
||||
const DECO_CODEBLOCK_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
|
||||
const DECO_CODEBLOCK_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
|
||||
|
||||
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
||||
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
|
||||
class CodeBlockInfoWidget extends WidgetType {
|
||||
constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); }
|
||||
eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; }
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const container = document.createElement('span');
|
||||
container.className = 'cm-code-block-info';
|
||||
if (this.language) {
|
||||
const lang = document.createElement('span');
|
||||
lang.className = 'cm-code-block-lang';
|
||||
lang.textContent = this.language;
|
||||
container.append(lang);
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cm-code-block-copy-btn';
|
||||
btn.title = 'Copy';
|
||||
btn.innerHTML = ICON_COPY;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = view.state.doc.sliceString(this.from, this.to);
|
||||
const lines = text.split('\n');
|
||||
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
btn.innerHTML = ICON_CHECK;
|
||||
setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500);
|
||||
});
|
||||
}
|
||||
});
|
||||
btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
|
||||
container.append(btn);
|
||||
return container;
|
||||
}
|
||||
ignoreEvent() { return true; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FencedCode / CodeBlock node.
|
||||
*/
|
||||
export function handleCodeBlock(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
const line = ctx.view.state.doc.line(num);
|
||||
let deco = DECO_CODEBLOCK_LINE;
|
||||
if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE;
|
||||
else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN;
|
||||
else if (num === endLine.number) deco = DECO_CODEBLOCK_END;
|
||||
ctx.items.push({ from: line.from, to: line.from, deco });
|
||||
}
|
||||
if (!inCursor) {
|
||||
const codeInfo = node.getChild('CodeInfo');
|
||||
const codeMarks = node.getChildren('CodeMark');
|
||||
const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null;
|
||||
ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 });
|
||||
if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration });
|
||||
for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for code blocks.
|
||||
*/
|
||||
export const codeBlockTheme = EditorView.baseTheme({
|
||||
'.cm-codeblock': {
|
||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||
fontFamily: 'inherit'
|
||||
},
|
||||
'.cm-codeblock-begin': {
|
||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||
position: 'relative'
|
||||
},
|
||||
'.cm-codeblock-end': {
|
||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
|
||||
},
|
||||
'.cm-code-block-info': {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5em',
|
||||
zIndex: '5',
|
||||
opacity: '0.5',
|
||||
transition: 'opacity 0.15s'
|
||||
},
|
||||
'.cm-code-block-info:hover': { opacity: '1' },
|
||||
'.cm-code-block-lang': {
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
textTransform: 'lowercase',
|
||||
userSelect: 'none'
|
||||
},
|
||||
'.cm-code-block-copy-btn': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.15em',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
background: 'transparent',
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.15s, background 0.15s'
|
||||
},
|
||||
'.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' },
|
||||
'.cm-code-block-copy-btn svg': { width: '1em', height: '1em' }
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Emoji handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import { emojies } from '@/common/constant/emojies';
|
||||
|
||||
class EmojiWidget extends WidgetType {
|
||||
constructor(readonly emoji: string, readonly name: string) { super(); }
|
||||
eq(other: EmojiWidget) { return other.emoji === this.emoji; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-emoji';
|
||||
span.textContent = this.emoji;
|
||||
span.title = `:${this.name}:`;
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Emoji node (:emoji:).
|
||||
*/
|
||||
export function handleEmoji(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const nameNode = node.getChild('EmojiName');
|
||||
if (!nameNode) return;
|
||||
const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase();
|
||||
const emojiChar = emojies[name];
|
||||
if (emojiChar) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for emoji.
|
||||
*/
|
||||
export const emojiTheme = EditorView.baseTheme({
|
||||
'.cm-emoji': {
|
||||
cursor: 'default',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Footnote handlers and theme.
|
||||
* Handles: FootnoteDefinition, FootnoteReference, InlineFootnote
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
/** Extended context for footnotes */
|
||||
export interface FootnoteContext extends BuildContext {
|
||||
definitionIds: Set<string>;
|
||||
pendingRefs: { from: number; to: number; id: string; index: number }[];
|
||||
pendingInlines: { from: number; to: number; index: number }[];
|
||||
seenIds: Map<string, number>;
|
||||
inlineFootnoteIdx: number;
|
||||
}
|
||||
|
||||
class FootnoteRefWidget extends WidgetType {
|
||||
constructor(readonly index: number, readonly hasDefinition: boolean) { super(); }
|
||||
eq(other: FootnoteRefWidget) { return this.index === other.index && this.hasDefinition === other.hasDefinition; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-ref';
|
||||
span.textContent = `[${this.index}]`;
|
||||
if (!this.hasDefinition) span.classList.add('cm-footnote-ref-undefined');
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
class InlineFootnoteWidget extends WidgetType {
|
||||
constructor(readonly index: number) { super(); }
|
||||
eq(other: InlineFootnoteWidget) { return this.index === other.index; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-footnote-ref';
|
||||
span.textContent = `[${this.index}]`;
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
class FootnoteDefLabelWidget extends WidgetType {
|
||||
constructor(readonly id: string) { super(); }
|
||||
eq(other: FootnoteDefLabelWidget) { return this.id === other.id; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-def-label';
|
||||
span.textContent = `[${this.id}]`;
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FootnoteDefinition node.
|
||||
*/
|
||||
export function handleFootnoteDefinition(
|
||||
ctx: FootnoteContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||
if (marks.length >= 2 && labelNode) {
|
||||
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
ctx.definitionIds.add(id);
|
||||
ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FootnoteReference node.
|
||||
*/
|
||||
export function handleFootnoteReference(
|
||||
ctx: FootnoteContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||
const marks = node.getChildren('FootnoteReferenceMark');
|
||||
if (labelNode && marks.length >= 2) {
|
||||
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1);
|
||||
ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle InlineFootnote node.
|
||||
*/
|
||||
export function handleInlineFootnote(
|
||||
ctx: FootnoteContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
const marks = node.getChildren('InlineFootnoteMark');
|
||||
if (contentNode && marks.length >= 2) {
|
||||
ctx.inlineFootnoteIdx++;
|
||||
ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending footnote refs after all definitions are collected.
|
||||
*/
|
||||
export function processPendingFootnotes(ctx: FootnoteContext): void {
|
||||
for (const ref of ctx.pendingRefs) {
|
||||
ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: ref.to, to: ref.to, deco: Decoration.widget({ widget: new FootnoteRefWidget(ref.index, ctx.definitionIds.has(ref.id)), side: 1 }), priority: 1 });
|
||||
}
|
||||
for (const inline of ctx.pendingInlines) {
|
||||
ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for footnotes.
|
||||
*/
|
||||
export const footnoteTheme = EditorView.baseTheme({
|
||||
'.cm-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '1em',
|
||||
height: '1.2em',
|
||||
padding: '0 0.25em',
|
||||
marginLeft: '1px',
|
||||
fontSize: '0.75em',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1',
|
||||
verticalAlign: 'super',
|
||||
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
|
||||
borderRadius: '3px'
|
||||
},
|
||||
'.cm-footnote-ref-undefined': {
|
||||
color: 'var(--cm-footnote-undefined-color, #d93025)',
|
||||
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))'
|
||||
},
|
||||
'.cm-inline-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '1em',
|
||||
height: '1.2em',
|
||||
padding: '0 0.25em',
|
||||
marginLeft: '1px',
|
||||
fontSize: '0.75em',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1',
|
||||
verticalAlign: 'super',
|
||||
color: 'var(--cm-inline-footnote-color, #e67e22)',
|
||||
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
|
||||
borderRadius: '3px'
|
||||
},
|
||||
'.cm-footnote-def-label': {
|
||||
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
||||
fontWeight: '600'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Heading handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_HEADING_HIDDEN = Decoration.mark({ class: 'cm-heading-mark-hidden' });
|
||||
|
||||
/**
|
||||
* Handle ATXHeading node (# Heading).
|
||||
*/
|
||||
export function handleATXHeading(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const header = node.firstChild;
|
||||
if (header && header.type.name === 'HeaderMark') {
|
||||
ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SetextHeading node (underline style).
|
||||
*/
|
||||
export function handleSetextHeading(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const headerMarks = node.getChildren('HeaderMark');
|
||||
for (const mark of headerMarks) {
|
||||
ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for headings.
|
||||
*/
|
||||
export const headingTheme = EditorView.baseTheme({
|
||||
'.cm-heading-mark-hidden': {
|
||||
fontSize: '0'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Horizontal rule handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
class HorizontalRuleWidget extends WidgetType {
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-horizontal-rule-widget';
|
||||
const hr = document.createElement('hr');
|
||||
hr.className = 'cm-horizontal-rule';
|
||||
span.appendChild(hr);
|
||||
return span;
|
||||
}
|
||||
eq() { return true; }
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
const hrWidget = new HorizontalRuleWidget();
|
||||
|
||||
/**
|
||||
* Handle HorizontalRule node.
|
||||
*/
|
||||
export function handleHorizontalRule(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (!inCursor) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: hrWidget }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for horizontal rules.
|
||||
*/
|
||||
export const horizontalRuleTheme = EditorView.baseTheme({
|
||||
'.cm-horizontal-rule-widget': {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
verticalAlign: 'middle'
|
||||
},
|
||||
'.cm-horizontal-rule': {
|
||||
width: '100%',
|
||||
height: '0',
|
||||
border: 'none',
|
||||
borderTop: '2px solid var(--cm-hr-color, rgba(128, 128, 128, 0.3))',
|
||||
margin: '0.5em 0'
|
||||
}
|
||||
});
|
||||
348
frontend/src/views/editor/extensions/markdown/plugins/html.ts
Normal file
348
frontend/src/views/editor/extensions/markdown/plugins/html.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* HTML plugin for CodeMirror.
|
||||
*
|
||||
* Features:
|
||||
* - Identifies HTML blocks and tags (excluding those inside tables)
|
||||
* - Shows indicator icon at the end
|
||||
* - Click to preview rendered HTML
|
||||
*/
|
||||
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
|
||||
import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
WidgetType,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
showTooltip,
|
||||
Tooltip
|
||||
} from '@codemirror/view';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { LruCache } from '@/common/utils/lruCache';
|
||||
|
||||
interface HTMLBlockInfo {
|
||||
from: number;
|
||||
to: number;
|
||||
content: string;
|
||||
sanitized: string;
|
||||
}
|
||||
|
||||
// HTML5 official logo
|
||||
const HTML_ICON = `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M89.088 59.392l62.464 803.84c1.024 12.288 9.216 22.528 20.48 25.6L502.784 993.28c6.144 2.048 12.288 2.048 18.432 0l330.752-104.448c11.264-4.096 19.456-14.336 20.48-25.6l62.464-803.84c1.024-17.408-12.288-31.744-29.696-31.744H118.784c-17.408 0-31.744 14.336-29.696 31.744z" fill="#FC490B"/><path d="M774.144 309.248h-409.6l12.288 113.664h388.096l-25.6 325.632-227.328 71.68-227.328-71.68-13.312-169.984h118.784v82.944l124.928 33.792 123.904-33.792 10.24-132.096H267.264L241.664 204.8h540.672z" fill="#FFFFFF"/></svg>`;
|
||||
|
||||
/**
|
||||
* LRU cache for DOMPurify sanitize results.
|
||||
*/
|
||||
const sanitizeCache = new LruCache<string, string>(100);
|
||||
|
||||
/**
|
||||
* Sanitize HTML content with caching for performance.
|
||||
*/
|
||||
function sanitizeHTML(html: string): string {
|
||||
const cached = sanitizeCache.get(html);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ['img'],
|
||||
ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'],
|
||||
ALLOW_DATA_ATTR: true
|
||||
});
|
||||
|
||||
sanitizeCache.set(html, sanitized);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document changes affect any of the given regions.
|
||||
*/
|
||||
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
|
||||
if (regions.length === 0) return true;
|
||||
|
||||
let affected = false;
|
||||
changes.iterChanges((fromA, toA) => {
|
||||
if (affected) return;
|
||||
for (const region of regions) {
|
||||
if (fromA <= region.to && toA >= region.from) {
|
||||
affected = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is inside a table.
|
||||
*/
|
||||
function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
const name = current.type.name;
|
||||
if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') {
|
||||
return true;
|
||||
}
|
||||
current = current.parent as typeof current;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all HTML blocks from visible ranges.
|
||||
* Excludes HTML inside tables (tables have their own rendering).
|
||||
*/
|
||||
function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] {
|
||||
const result: HTMLBlockInfo[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (nodeRef) => {
|
||||
const { name, from: f, to: t, node } = nodeRef;
|
||||
|
||||
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
|
||||
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
|
||||
|
||||
// Skip HTML inside tables
|
||||
if (isInsideTable(node)) return;
|
||||
|
||||
const content = view.state.sliceDoc(f, t);
|
||||
const sanitized = sanitizeHTML(content);
|
||||
|
||||
// Skip empty content after sanitization
|
||||
if (!sanitized.trim()) return;
|
||||
|
||||
result.push({ from: f, to: t, content, sanitized });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Effect to toggle tooltip visibility */
|
||||
const toggleHTMLTooltip = StateEffect.define<HTMLBlockInfo | null>();
|
||||
|
||||
/** Effect to close tooltip */
|
||||
const closeHTMLTooltip = StateEffect.define<null>();
|
||||
|
||||
/** StateField to track active tooltip */
|
||||
const htmlTooltipState = StateField.define<HTMLBlockInfo | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(toggleHTMLTooltip)) {
|
||||
// Toggle: if same block, close; otherwise open new
|
||||
if (value && effect.value && value.from === effect.value.from) {
|
||||
return null;
|
||||
}
|
||||
return effect.value;
|
||||
}
|
||||
if (effect.is(closeHTMLTooltip)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Close tooltip on document changes
|
||||
if (tr.docChanged) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
provide: (field) =>
|
||||
showTooltip.from(field, (block): Tooltip | null => {
|
||||
if (!block) return null;
|
||||
return {
|
||||
pos: block.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-html-tooltip';
|
||||
dom.innerHTML = block.sanitized;
|
||||
|
||||
// Prevent clicks inside tooltip from closing it
|
||||
dom.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
return { dom };
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
/**
|
||||
* Indicator widget shown at the end of HTML blocks.
|
||||
* Clicking toggles the tooltip.
|
||||
*/
|
||||
class HTMLIndicatorWidget extends WidgetType {
|
||||
constructor(readonly info: HTMLBlockInfo) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'cm-html-indicator';
|
||||
el.innerHTML = HTML_ICON;
|
||||
el.title = 'Click to preview HTML';
|
||||
|
||||
// Click handler to toggle tooltip
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({
|
||||
effects: toggleHTMLTooltip.of(this.info)
|
||||
});
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
eq(other: HTMLIndicatorWidget): boolean {
|
||||
return this.info.from === other.info.from && this.info.content === other.info.content;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to manage HTML block decorations.
|
||||
* Optimized with incremental updates when changes don't affect HTML regions.
|
||||
*/
|
||||
class HTMLBlockPlugin {
|
||||
decorations: DecorationSet;
|
||||
blocks: HTMLBlockInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.blocks = extractHTMLBlocks(view);
|
||||
this.decorations = this.build();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on viewport change
|
||||
if (update.viewportChanged) {
|
||||
this.blocks = extractHTMLBlocks(update.view);
|
||||
this.decorations = this.build();
|
||||
return;
|
||||
}
|
||||
|
||||
// For document changes, only rebuild if changes affect HTML regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectRegions(update.changes, this.blocks);
|
||||
|
||||
if (needsRebuild) {
|
||||
this.blocks = extractHTMLBlocks(update.view);
|
||||
this.decorations = this.build();
|
||||
} else {
|
||||
// Just update positions of existing decorations
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
this.blocks = this.blocks.map(block => ({
|
||||
...block,
|
||||
from: update.changes.mapPos(block.from),
|
||||
to: update.changes.mapPos(block.to)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private build(): DecorationSet {
|
||||
const deco: Range<Decoration>[] = [];
|
||||
for (const block of this.blocks) {
|
||||
deco.push(
|
||||
Decoration.widget({
|
||||
widget: new HTMLIndicatorWidget(block),
|
||||
side: 1
|
||||
}).range(block.to)
|
||||
);
|
||||
}
|
||||
return Decoration.set(deco, true);
|
||||
}
|
||||
}
|
||||
|
||||
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Close tooltip when clicking outside.
|
||||
*/
|
||||
const clickOutsideHandler = EditorView.domEventHandlers({
|
||||
click(event, view) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Don't close if clicking on indicator or inside tooltip
|
||||
if (target.closest('.cm-html-indicator') || target.closest('.cm-html-tooltip')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close tooltip if one is open
|
||||
const currentTooltip = view.state.field(htmlTooltipState);
|
||||
if (currentTooltip) {
|
||||
view.dispatch({
|
||||
effects: closeHTMLTooltip.of(null)
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const theme = EditorView.baseTheme({
|
||||
// Indicator icon
|
||||
'.cm-html-indicator': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: '4px',
|
||||
verticalAlign: 'middle',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.5',
|
||||
color: 'var(--cm-html-color, #e44d26)',
|
||||
transition: 'opacity 0.15s',
|
||||
'& svg': { width: '14px', height: '14px' }
|
||||
},
|
||||
'.cm-html-indicator:hover': {
|
||||
opacity: '1'
|
||||
},
|
||||
|
||||
// Tooltip content
|
||||
'.cm-html-tooltip': {
|
||||
padding: '8px 12px',
|
||||
maxWidth: '60vw',
|
||||
maxHeight: '50vh',
|
||||
overflow: 'auto'
|
||||
},
|
||||
|
||||
// Images inside tooltip
|
||||
'.cm-html-tooltip img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
display: 'block'
|
||||
},
|
||||
|
||||
// Style the parent tooltip container
|
||||
'.cm-tooltip:has(.cm-html-tooltip)': {
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* HTML block extension.
|
||||
*
|
||||
* Features:
|
||||
* - Identifies HTML blocks and tags (excluding those inside tables)
|
||||
* - Shows indicator icon at the end
|
||||
* - Click to preview rendered HTML
|
||||
*/
|
||||
export const html = (): Extension => [
|
||||
htmlBlockPlugin,
|
||||
htmlTooltipState,
|
||||
clickOutsideHandler,
|
||||
theme
|
||||
];
|
||||
331
frontend/src/views/editor/extensions/markdown/plugins/image.ts
Normal file
331
frontend/src/views/editor/extensions/markdown/plugins/image.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Image plugin for CodeMirror.
|
||||
*
|
||||
* Features:
|
||||
* - Identifies markdown images
|
||||
* - Shows indicator icon at the end
|
||||
* - Click to preview image
|
||||
*/
|
||||
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
|
||||
import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
WidgetType,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
showTooltip,
|
||||
Tooltip
|
||||
} from '@codemirror/view';
|
||||
|
||||
interface ImageInfo {
|
||||
src: string;
|
||||
from: number;
|
||||
to: number;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tiff?)(\?.*)?$/i;
|
||||
const IMAGE_ALT_RE = /(?:!\[)(.*?)(?:\])/;
|
||||
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
|
||||
|
||||
function isImageUrl(url: string): boolean {
|
||||
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document changes affect any of the given regions.
|
||||
*/
|
||||
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
|
||||
if (regions.length === 0) return true;
|
||||
|
||||
let affected = false;
|
||||
changes.iterChanges((fromA, toA) => {
|
||||
if (affected) return;
|
||||
for (const region of regions) {
|
||||
if (fromA <= region.to && toA >= region.from) {
|
||||
affected = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return affected;
|
||||
}
|
||||
|
||||
function extractImages(view: EditorView): ImageInfo[] {
|
||||
const result: ImageInfo[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ name, node, from: f, to: t }) => {
|
||||
if (name !== 'Image') return;
|
||||
const urlNode = node.getChild('URL');
|
||||
if (!urlNode) return;
|
||||
const src = view.state.sliceDoc(urlNode.from, urlNode.to);
|
||||
if (!isImageUrl(src)) return;
|
||||
const text = view.state.sliceDoc(f, t);
|
||||
const alt = text.match(IMAGE_ALT_RE)?.[1] ?? '';
|
||||
result.push({ src, from: f, to: t, alt });
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Effect to toggle tooltip visibility */
|
||||
const toggleImageTooltip = StateEffect.define<ImageInfo | null>();
|
||||
|
||||
/** Effect to close tooltip */
|
||||
const closeImageTooltip = StateEffect.define<null>();
|
||||
|
||||
/** StateField to track active tooltip */
|
||||
const imageTooltipState = StateField.define<ImageInfo | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(toggleImageTooltip)) {
|
||||
// Toggle: if same image, close; otherwise open new
|
||||
if (value && effect.value && value.from === effect.value.from) {
|
||||
return null;
|
||||
}
|
||||
return effect.value;
|
||||
}
|
||||
if (effect.is(closeImageTooltip)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Close tooltip on document changes
|
||||
if (tr.docChanged) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
provide: (field) =>
|
||||
showTooltip.from(field, (img): Tooltip | null => {
|
||||
if (!img) return null;
|
||||
return {
|
||||
pos: img.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-image-tooltip cm-image-loading';
|
||||
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'cm-image-spinner';
|
||||
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = img.src;
|
||||
imgEl.alt = img.alt;
|
||||
|
||||
imgEl.onload = () => {
|
||||
dom.classList.remove('cm-image-loading');
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
spinner.remove();
|
||||
imgEl.remove();
|
||||
dom.textContent = 'Failed to load image';
|
||||
dom.classList.remove('cm-image-loading');
|
||||
dom.classList.add('cm-image-tooltip-error');
|
||||
};
|
||||
|
||||
dom.append(spinner, imgEl);
|
||||
|
||||
// Prevent clicks inside tooltip from closing it
|
||||
dom.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
return { dom };
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Indicator widget shown at the end of images.
|
||||
* Clicking toggles the tooltip.
|
||||
*/
|
||||
class IndicatorWidget extends WidgetType {
|
||||
constructor(readonly info: ImageInfo) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'cm-image-indicator';
|
||||
el.innerHTML = ICON;
|
||||
el.title = 'Click to preview image';
|
||||
|
||||
// Click handler to toggle tooltip
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({
|
||||
effects: toggleImageTooltip.of(this.info)
|
||||
});
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
eq(other: IndicatorWidget): boolean {
|
||||
return this.info.from === other.info.from && this.info.src === other.info.src;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to manage image decorations.
|
||||
* Optimized with incremental updates when changes don't affect image regions.
|
||||
*/
|
||||
class ImagePlugin {
|
||||
decorations: DecorationSet;
|
||||
images: ImageInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.images = extractImages(view);
|
||||
this.decorations = this.build();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on viewport change
|
||||
if (update.viewportChanged) {
|
||||
this.images = extractImages(update.view);
|
||||
this.decorations = this.build();
|
||||
return;
|
||||
}
|
||||
|
||||
// For document changes, only rebuild if changes affect image regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectRegions(update.changes, this.images);
|
||||
|
||||
if (needsRebuild) {
|
||||
this.images = extractImages(update.view);
|
||||
this.decorations = this.build();
|
||||
} else {
|
||||
// Just update positions of existing decorations
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
this.images = this.images.map(img => ({
|
||||
...img,
|
||||
from: update.changes.mapPos(img.from),
|
||||
to: update.changes.mapPos(img.to)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private build(): DecorationSet {
|
||||
const deco: Range<Decoration>[] = [];
|
||||
for (const img of this.images) {
|
||||
deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to));
|
||||
}
|
||||
return Decoration.set(deco, true);
|
||||
}
|
||||
}
|
||||
|
||||
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Close tooltip when clicking outside.
|
||||
*/
|
||||
const clickOutsideHandler = EditorView.domEventHandlers({
|
||||
click(event, view) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Don't close if clicking on indicator or inside tooltip
|
||||
if (target.closest('.cm-image-indicator') || target.closest('.cm-image-tooltip')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close tooltip if one is open
|
||||
const currentTooltip = view.state.field(imageTooltipState);
|
||||
if (currentTooltip) {
|
||||
view.dispatch({
|
||||
effects: closeImageTooltip.of(null)
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const theme = EditorView.baseTheme({
|
||||
'.cm-image-indicator': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: '4px',
|
||||
verticalAlign: 'middle',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.5',
|
||||
color: 'var(--cm-link-color, #1a73e8)',
|
||||
transition: 'opacity 0.15s',
|
||||
'& svg': { width: '14px', height: '14px' }
|
||||
},
|
||||
'.cm-image-indicator:hover': { opacity: '1' },
|
||||
|
||||
'.cm-image-tooltip': {
|
||||
position: 'relative',
|
||||
background: `
|
||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)
|
||||
`,
|
||||
backgroundColor: '#fff',
|
||||
backgroundSize: '12px 12px',
|
||||
backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0px',
|
||||
border: '1px solid var(--border-color)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'& img': {
|
||||
display: 'block',
|
||||
maxWidth: '60vw',
|
||||
maxHeight: '50vh',
|
||||
opacity: '1',
|
||||
transition: 'opacity 0.15s ease-out'
|
||||
}
|
||||
},
|
||||
'.cm-image-loading': {
|
||||
minWidth: '48px',
|
||||
minHeight: '48px',
|
||||
'& img': { opacity: '0' }
|
||||
},
|
||||
'.cm-image-spinner': {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
marginTop: '-8px',
|
||||
marginLeft: '-8px',
|
||||
border: '2px solid #ccc',
|
||||
borderTopColor: '#666',
|
||||
borderRadius: '50%',
|
||||
animation: 'cm-spin 0.5s linear infinite'
|
||||
},
|
||||
'.cm-image-tooltip:not(.cm-image-loading) .cm-image-spinner': {
|
||||
display: 'none'
|
||||
},
|
||||
'@keyframes cm-spin': {
|
||||
to: { transform: 'rotate(360deg)' }
|
||||
},
|
||||
'.cm-image-tooltip-error': {
|
||||
padding: '16px 24px',
|
||||
fontSize: '12px',
|
||||
color: 'red'
|
||||
}
|
||||
});
|
||||
|
||||
export const image = (): Extension => [
|
||||
imagePlugin,
|
||||
imageTooltipState,
|
||||
clickOutsideHandler,
|
||||
theme
|
||||
];
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Inline styles handlers and theme.
|
||||
* Handles: Highlight, InlineCode, Emphasis, StrongEmphasis, Strikethrough, Insert, Superscript, Subscript
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_HIGHLIGHT = Decoration.mark({ class: 'cm-highlight' });
|
||||
const DECO_INLINE_CODE = Decoration.mark({ class: 'cm-inline-code' });
|
||||
const DECO_INSERT = Decoration.mark({ class: 'cm-insert' });
|
||||
const DECO_SUPERSCRIPT = Decoration.mark({ class: 'cm-superscript' });
|
||||
const DECO_SUBSCRIPT = Decoration.mark({ class: 'cm-subscript' });
|
||||
|
||||
const MARK_TYPES: Record<string, string> = {
|
||||
'Emphasis': 'EmphasisMark',
|
||||
'StrongEmphasis': 'EmphasisMark',
|
||||
'Strikethrough': 'StrikethroughMark'
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Highlight node (==text==).
|
||||
*/
|
||||
export function handleHighlight(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('HighlightMark');
|
||||
if (marks.length >= 2) {
|
||||
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||
if (marks[0].to < marks[marks.length - 1].from) {
|
||||
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_HIGHLIGHT });
|
||||
}
|
||||
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle InlineCode node (`code`).
|
||||
*/
|
||||
export function handleInlineCode(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const text = ctx.view.state.doc.sliceString(nf, nt);
|
||||
let i = 0; while (i < text.length && text[i] === '`') i++;
|
||||
let j = text.length - 1; while (j >= 0 && text[j] === '`') j--;
|
||||
const codeStart = nf + i, codeEnd = nf + j + 1;
|
||||
if (nf < codeStart) ctx.items.push({ from: nf, to: codeStart, deco: invisibleDecoration });
|
||||
if (codeStart < codeEnd) ctx.items.push({ from: codeStart, to: codeEnd, deco: DECO_INLINE_CODE });
|
||||
if (codeEnd < nt) ctx.items.push({ from: codeEnd, to: nt, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Emphasis, StrongEmphasis, Strikethrough nodes.
|
||||
*/
|
||||
export function handleEmphasis(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
typeName: string,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const markType = MARK_TYPES[typeName];
|
||||
if (markType) {
|
||||
const marks = node.getChildren(markType);
|
||||
for (const mark of marks) {
|
||||
ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Insert node (++text++).
|
||||
*/
|
||||
export function handleInsert(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('InsertMark');
|
||||
if (marks.length >= 2) {
|
||||
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||
if (marks[0].to < marks[marks.length - 1].from) {
|
||||
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_INSERT });
|
||||
}
|
||||
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Superscript / Subscript nodes.
|
||||
*/
|
||||
export function handleScript(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
typeName: string,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const isSuper = typeName === 'Superscript';
|
||||
const markName = isSuper ? 'SuperscriptMark' : 'SubscriptMark';
|
||||
const marks = node.getChildren(markName);
|
||||
if (marks.length >= 2) {
|
||||
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||
if (marks[0].to < marks[marks.length - 1].from) {
|
||||
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: isSuper ? DECO_SUPERSCRIPT : DECO_SUBSCRIPT });
|
||||
}
|
||||
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for inline styles.
|
||||
*/
|
||||
export const inlineStylesTheme = EditorView.baseTheme({
|
||||
'.cm-highlight': {
|
||||
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
|
||||
borderRadius: '2px'
|
||||
},
|
||||
'.cm-inline-code': {
|
||||
backgroundColor: 'var(--cm-inline-code-bg)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.1rem 0.3rem',
|
||||
fontFamily: 'var(--voidraft-font-mono)'
|
||||
},
|
||||
'.cm-insert': {
|
||||
textDecoration: 'underline'
|
||||
},
|
||||
'.cm-superscript': {
|
||||
verticalAlign: 'super',
|
||||
fontSize: '0.75em',
|
||||
color: 'inherit'
|
||||
},
|
||||
'.cm-subscript': {
|
||||
verticalAlign: 'sub',
|
||||
fontSize: '0.75em',
|
||||
color: 'inherit'
|
||||
}
|
||||
});
|
||||
111
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
111
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Link handler with underline and clickable icon.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import * as runtime from "@wailsio/runtime";
|
||||
|
||||
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
|
||||
|
||||
/** Link text decoration with underline */
|
||||
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
|
||||
|
||||
/** Link icon widget - clickable to open URL */
|
||||
class LinkIconWidget extends WidgetType {
|
||||
constructor(readonly url: string) { super(); }
|
||||
eq(other: LinkIconWidget) { return this.url === other.url; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-md-link-icon';
|
||||
span.textContent = '🔗';
|
||||
span.title = this.url;
|
||||
span.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
runtime.Browser.OpenURL(this.url);
|
||||
};
|
||||
return span;
|
||||
}
|
||||
ignoreEvent(e: Event) { return e.type === 'mousedown'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle URL node (within Link).
|
||||
*/
|
||||
export function handleURL(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const parent = node.parent;
|
||||
if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return;
|
||||
if (ctx.seen.has(parent.from)) return;
|
||||
ctx.seen.add(parent.from);
|
||||
ranges.push([parent.from, parent.to]);
|
||||
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return;
|
||||
|
||||
// Get link text node (content between first [ and ])
|
||||
const linkText = parent.getChild('LinkLabel');
|
||||
const marks = parent.getChildren('LinkMark');
|
||||
const linkTitle = parent.getChild('LinkTitle');
|
||||
const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
|
||||
|
||||
if (closeBracket && nf < closeBracket.from) return;
|
||||
|
||||
// Get URL for the icon
|
||||
const url = ctx.view.state.sliceDoc(nf, nt);
|
||||
|
||||
// Add underline decoration to link text
|
||||
if (linkText) {
|
||||
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
|
||||
}
|
||||
|
||||
// Hide markdown syntax marks
|
||||
for (const m of marks) {
|
||||
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
// Hide URL
|
||||
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||
|
||||
// Hide link title if present
|
||||
if (linkTitle) {
|
||||
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
// Add clickable icon widget after link text (at close bracket position)
|
||||
if (closeBracket) {
|
||||
ctx.items.push({
|
||||
from: closeBracket.from,
|
||||
to: closeBracket.from,
|
||||
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
|
||||
priority: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for markdown links.
|
||||
*/
|
||||
export const linkTheme = EditorView.baseTheme({
|
||||
'.cm-md-link-text': {
|
||||
color: 'var(--cm-link-color, #0969da)',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '2px',
|
||||
cursor: 'text'
|
||||
},
|
||||
'.cm-md-link-icon': {
|
||||
cursor: 'pointer',
|
||||
marginLeft: '0.2em',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.15s ease',
|
||||
'&:hover': {
|
||||
opacity: '1'
|
||||
}
|
||||
}
|
||||
});
|
||||
121
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
121
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* List handlers and theme.
|
||||
* Handles: ListMark (bullets), Task (checkboxes)
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const BULLET_RE = /^[-+*]$/;
|
||||
|
||||
class ListBulletWidget extends WidgetType {
|
||||
constructor(readonly bullet: string) { super(); }
|
||||
eq(other: ListBulletWidget) { return other.bullet === this.bullet; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-list-bullet';
|
||||
span.textContent = '•';
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
class TaskCheckboxWidget extends WidgetType {
|
||||
constructor(readonly checked: boolean, readonly pos: number) { super(); }
|
||||
eq(other: TaskCheckboxWidget) { return other.checked === this.checked && other.pos === this.pos; }
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
wrap.className = 'cm-task-checkbox';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = this.checked;
|
||||
checkbox.tabIndex = -1;
|
||||
checkbox.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({ changes: { from: this.pos, to: this.pos + 1, insert: this.checked ? ' ' : 'x' } });
|
||||
});
|
||||
wrap.appendChild(checkbox);
|
||||
return wrap;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle ListMark node (bullet markers).
|
||||
*/
|
||||
export function handleListMark(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const parent = node.parent;
|
||||
if (parent?.getChild('Task')) return;
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const bullet = ctx.view.state.sliceDoc(nf, nt);
|
||||
if (BULLET_RE.test(bullet)) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new ListBulletWidget(bullet) }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Task node (checkboxes).
|
||||
*/
|
||||
export function handleTask(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const listItem = node.parent;
|
||||
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||
const listMark = listItem.getChild('ListMark');
|
||||
const taskMarker = node.getChild('TaskMarker');
|
||||
if (!listMark || !taskMarker) return;
|
||||
if (ctx.seen.has(listMark.from)) return;
|
||||
ctx.seen.add(listMark.from);
|
||||
ranges.push([listMark.from, taskMarker.to]);
|
||||
if (checkRangeOverlap([listMark.from, taskMarker.to], ctx.selRange)) return;
|
||||
|
||||
const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||
if (isChecked) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 });
|
||||
}
|
||||
ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for lists.
|
||||
*/
|
||||
export const listTheme = EditorView.baseTheme({
|
||||
'.cm-list-bullet': {
|
||||
color: 'var(--cm-list-bullet-color, inherit)'
|
||||
},
|
||||
'.cm-task-checked': {
|
||||
textDecoration: 'line-through',
|
||||
opacity: '0.6'
|
||||
},
|
||||
'.cm-task-checkbox': {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'baseline'
|
||||
},
|
||||
'.cm-task-checkbox input': {
|
||||
cursor: 'pointer',
|
||||
margin: '0',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
position: 'relative',
|
||||
top: '0.1em'
|
||||
}
|
||||
});
|
||||
163
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
163
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Math handlers and theme.
|
||||
* Handles: InlineMath, BlockMath
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
class InlineMathWidget extends WidgetType {
|
||||
constructor(readonly latex: string) { super(); }
|
||||
eq(other: InlineMathWidget) { return this.latex === other.latex; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-math';
|
||||
try {
|
||||
span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
|
||||
} catch (e) {
|
||||
span.textContent = this.latex;
|
||||
span.title = e instanceof Error ? e.message : 'Render error';
|
||||
}
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
class BlockMathWidget extends WidgetType {
|
||||
constructor(readonly latex: string, readonly lineCount: number, readonly lineHeight: number) { super(); }
|
||||
eq(other: BlockMathWidget) { return this.latex === other.latex && this.lineCount === other.lineCount; }
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-block-math-container';
|
||||
container.style.height = `${this.lineCount * this.lineHeight}px`;
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'cm-block-math';
|
||||
try {
|
||||
inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
|
||||
} catch (e) {
|
||||
inner.textContent = this.latex;
|
||||
inner.title = e instanceof Error ? e.message : 'Render error';
|
||||
}
|
||||
container.appendChild(inner);
|
||||
return container;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
const DECO_BLOCK_MATH_LINE = Decoration.line({ class: 'cm-block-math-line' });
|
||||
const DECO_BLOCK_MATH_HIDDEN = Decoration.mark({ class: 'cm-block-math-content-hidden' });
|
||||
|
||||
/**
|
||||
* Handle InlineMath node ($...$).
|
||||
*/
|
||||
export function handleInlineMath(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('InlineMathMark');
|
||||
if (marks.length >= 2) {
|
||||
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: nt, to: nt, deco: Decoration.widget({ widget: new InlineMathWidget(latex), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle BlockMath node ($$...$$).
|
||||
*/
|
||||
export function handleBlockMath(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('BlockMathMark');
|
||||
if (marks.length >= 2) {
|
||||
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
if (lineCount > 1) {
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE });
|
||||
}
|
||||
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||
} else {
|
||||
ctx.items.push({ from: nf, to: nt, deco: DECO_BLOCK_MATH_HIDDEN });
|
||||
ctx.items.push({ from: nf, to: nf, deco: Decoration.widget({ widget: new BlockMathWidget(latex, 1, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for math.
|
||||
*/
|
||||
export const mathTheme = EditorView.baseTheme({
|
||||
'.cm-inline-math': {
|
||||
display: 'inline',
|
||||
verticalAlign: 'baseline'
|
||||
},
|
||||
'.cm-inline-math .katex': {
|
||||
fontSize: 'inherit'
|
||||
},
|
||||
'.cm-block-math-container': {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1'
|
||||
},
|
||||
'.cm-block-math': {
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'auto'
|
||||
},
|
||||
'.cm-block-math .katex-display': {
|
||||
margin: '0'
|
||||
},
|
||||
'.cm-block-math .katex': {
|
||||
fontSize: '1.1em'
|
||||
},
|
||||
'.cm-line.cm-block-math-line': {
|
||||
color: 'transparent !important',
|
||||
caretColor: 'transparent'
|
||||
},
|
||||
'.cm-line.cm-block-math-line span': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-block-math-content-hidden': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-block-math-content-hidden span': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important'
|
||||
}
|
||||
});
|
||||
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal file
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||
import { DecoItem } from './types';
|
||||
import { blockState } from '@/views/editor/extensions/codeblock/state';
|
||||
import { Block } from '@/views/editor/extensions/codeblock/types';
|
||||
|
||||
import { handleBlockquote } from './blockquote';
|
||||
import { handleCodeBlock } from './code-block';
|
||||
import { handleATXHeading, handleSetextHeading } from './heading';
|
||||
import { handleHorizontalRule } from './horizontal-rule';
|
||||
import { handleHighlight, handleInlineCode, handleEmphasis, handleInsert, handleScript } from './inline-styles';
|
||||
import { handleURL } from './link';
|
||||
import { handleListMark, handleTask } from './list';
|
||||
import { handleFootnoteDefinition, handleFootnoteReference, handleInlineFootnote, processPendingFootnotes, FootnoteContext } from './footnote';
|
||||
import { handleInlineMath, handleBlockMath } from './math';
|
||||
import { handleEmoji } from './emoji';
|
||||
import { handleTable } from './table';
|
||||
|
||||
|
||||
interface BuildResult {
|
||||
decorations: DecorationSet;
|
||||
trackedRanges: RangeTuple[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get markdown block ranges from visible ranges.
|
||||
* Only returns ranges that are within 'md' language blocks.
|
||||
*/
|
||||
function getMdBlockRanges(view: EditorView): { from: number; to: number }[] {
|
||||
const blocks = view.state.field(blockState, false);
|
||||
if (!blocks || blocks.length === 0) {
|
||||
// No blocks, treat entire document as md
|
||||
return view.visibleRanges.map(r => ({ from: r.from, to: r.to }));
|
||||
}
|
||||
|
||||
// Filter md blocks
|
||||
const mdBlocks = blocks.filter((b: Block) => b.language.name === 'md');
|
||||
if (mdBlocks.length === 0) return [];
|
||||
|
||||
// Intersect visible ranges with md block content ranges
|
||||
const result: { from: number; to: number }[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (const block of mdBlocks) {
|
||||
const intersectFrom = Math.max(from, block.content.from);
|
||||
const intersectTo = Math.min(to, block.content.to);
|
||||
if (intersectFrom < intersectTo) {
|
||||
result.push({ from: intersectFrom, to: intersectTo });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
function buildDecorationsAndRanges(view: EditorView): BuildResult {
|
||||
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||
|
||||
// Create context with footnote extensions
|
||||
const ctx: FootnoteContext = {
|
||||
view,
|
||||
items: [],
|
||||
selRange: [selFrom, selTo],
|
||||
seen: new Set(),
|
||||
processedLines: new Set(),
|
||||
contentWidth: view.contentDOM.clientWidth - 10,
|
||||
lineHeight: view.defaultLineHeight,
|
||||
// Footnote state
|
||||
definitionIds: new Set(),
|
||||
pendingRefs: [],
|
||||
pendingInlines: [],
|
||||
seenIds: new Map(),
|
||||
inlineFootnoteIdx: 0
|
||||
};
|
||||
|
||||
const trackedRanges: RangeTuple[] = [];
|
||||
|
||||
// Only traverse md blocks (not other language blocks like js, py, etc.)
|
||||
const mdRanges = getMdBlockRanges(view);
|
||||
|
||||
// Single traversal - dispatch to all handlers
|
||||
for (const { from, to } of mdRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from, to,
|
||||
enter: (nodeRef: SyntaxNodeRef) => {
|
||||
const { type, from: nf, to: nt, node } = nodeRef;
|
||||
const typeName = type.name;
|
||||
const inCursor = checkRangeOverlap([nf, nt], ctx.selRange);
|
||||
|
||||
// Dispatch to handlers
|
||||
if (typeName === 'Blockquote') return handleBlockquote(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'FencedCode' || typeName === 'CodeBlock') return handleCodeBlock(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName.startsWith('ATXHeading')) return handleATXHeading(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName.startsWith('SetextHeading')) return handleSetextHeading(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'HorizontalRule') return handleHorizontalRule(ctx, nf, nt, inCursor, trackedRanges);
|
||||
if (typeName === 'Highlight') return handleHighlight(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'InlineCode') return handleInlineCode(ctx, nf, nt, inCursor, trackedRanges);
|
||||
if (typeName === 'Emphasis' || typeName === 'StrongEmphasis' || typeName === 'Strikethrough') return handleEmphasis(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
|
||||
if (typeName === 'Insert') return handleInsert(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Superscript' || typeName === 'Subscript') return handleScript(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
|
||||
if (typeName === 'URL') return handleURL(ctx, nf, nt, node, trackedRanges);
|
||||
if (typeName === 'ListMark') return handleListMark(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Task') return handleTask(ctx, nf, nt, node, trackedRanges);
|
||||
if (typeName === 'FootnoteDefinition') return handleFootnoteDefinition(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'FootnoteReference') return handleFootnoteReference(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'InlineFootnote') return handleInlineFootnote(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'InlineMath') return handleInlineMath(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'BlockMath') return handleBlockMath(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Emoji') return handleEmoji(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Table') return handleTable(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process pending footnotes
|
||||
processPendingFootnotes(ctx);
|
||||
|
||||
// Sort and filter
|
||||
ctx.items.sort((a, b) => {
|
||||
if (a.from !== b.from) return a.from - b.from;
|
||||
if (a.to !== b.to) return a.to - b.to;
|
||||
return (a.priority || 0) - (b.priority || 0);
|
||||
});
|
||||
|
||||
const result: DecoItem[] = [];
|
||||
let replaceMaxTo = -1;
|
||||
for (const item of ctx.items) {
|
||||
const isReplace = item.deco.spec?.widget !== undefined || item.deco === invisibleDecoration;
|
||||
if (item.from === item.to) {
|
||||
result.push(item);
|
||||
} else if (isReplace) {
|
||||
if (item.from >= replaceMaxTo) {
|
||||
result.push(item);
|
||||
replaceMaxTo = item.to;
|
||||
}
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: Decoration.set(result.map(r => r.deco.range(r.from, r.to)), true),
|
||||
trackedRanges
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
class MarkdownRenderPlugin {
|
||||
decorations: DecorationSet;
|
||||
private trackedRanges: RangeTuple[] = [];
|
||||
private lastSelFrom = -1;
|
||||
private lastSelTo = -1;
|
||||
private lastWidth = 0;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
const result = buildDecorationsAndRanges(view);
|
||||
this.decorations = result.decorations;
|
||||
this.trackedRanges = result.trackedRanges;
|
||||
const { from, to } = view.state.selection.main;
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
this.lastWidth = view.contentDOM.clientWidth;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const { docChanged, viewportChanged, selectionSet, geometryChanged } = update;
|
||||
const widthChanged = Math.abs(update.view.contentDOM.clientWidth - this.lastWidth) > 1;
|
||||
if (widthChanged) this.lastWidth = update.view.contentDOM.clientWidth;
|
||||
|
||||
// Full rebuild for structural changes
|
||||
if (docChanged || viewportChanged || geometryChanged || widthChanged) {
|
||||
const result = buildDecorationsAndRanges(update.view);
|
||||
this.decorations = result.decorations;
|
||||
this.trackedRanges = result.trackedRanges;
|
||||
const { from, to } = update.state.selection.main;
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection change handling with fine-grained detection
|
||||
if (selectionSet) {
|
||||
const { from, to } = update.state.selection.main;
|
||||
const isPointCursor = from === to;
|
||||
const wasPointCursor = this.lastSelFrom === this.lastSelTo;
|
||||
|
||||
// Optimization: Point cursor moving within same tracked range - no rebuild needed
|
||||
if (isPointCursor && wasPointCursor) {
|
||||
const oldRange = this.findContainingRange(this.lastSelFrom);
|
||||
const newRange = this.findContainingRange(from);
|
||||
|
||||
if (this.rangeSame(oldRange, newRange)) {
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if overlapping ranges changed
|
||||
const oldOverlaps = this.getOverlappingRanges(this.lastSelFrom, this.lastSelTo);
|
||||
const newOverlaps = this.getOverlappingRanges(from, to);
|
||||
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
|
||||
if (!this.rangesSame(oldOverlaps, newOverlaps)) {
|
||||
const result = buildDecorationsAndRanges(update.view);
|
||||
this.decorations = result.decorations;
|
||||
this.trackedRanges = result.trackedRanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findContainingRange(pos: number): RangeTuple | null {
|
||||
for (const range of this.trackedRanges) {
|
||||
if (pos >= range[0] && pos <= range[1]) return range;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private rangeSame(a: RangeTuple | null, b: RangeTuple | null): boolean {
|
||||
if (a === null && b === null) return true;
|
||||
if (a === null || b === null) return false;
|
||||
return a[0] === b[0] && a[1] === b[1];
|
||||
}
|
||||
|
||||
private getOverlappingRanges(from: number, to: number): RangeTuple[] {
|
||||
const selRange: RangeTuple = [from, to];
|
||||
return this.trackedRanges.filter(r => checkRangeOverlap(r, selRange));
|
||||
}
|
||||
|
||||
private rangesSame(a: RangeTuple[], b: RangeTuple[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const renderPlugin = ViewPlugin.fromClass(MarkdownRenderPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
export const render = (): Extension => [renderPlugin];
|
||||
251
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
251
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Table handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { foldedRanges } from '@codemirror/language';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
type CellAlign = 'left' | 'center' | 'right';
|
||||
interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; }
|
||||
|
||||
const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' });
|
||||
|
||||
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
|
||||
const BOLD_UNDER_RE = /__(.+?)__/g;
|
||||
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
|
||||
const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
|
||||
const CODE_RE = /`([^`]+)`/g;
|
||||
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const STRIKE_RE = /~~(.+?)~~/g;
|
||||
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
|
||||
|
||||
function renderInlineMarkdown(text: string): string {
|
||||
let html = text;
|
||||
if (HTML_TAG_RE.test(text)) {
|
||||
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||
if (!html.includes('<code>')) html = html.replace(CODE_RE, '<code>$1</code>');
|
||||
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
|
||||
html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] });
|
||||
} else {
|
||||
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||
html = html.replace(CODE_RE, '<code>$1</code>');
|
||||
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseRowText(rowText: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let start = 0, end = rowText.length;
|
||||
while (start < end && rowText.charCodeAt(start) <= 32) start++;
|
||||
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
|
||||
if (start < end && rowText.charCodeAt(start) === 124) start++;
|
||||
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
|
||||
let cellStart = start;
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i === end || rowText.charCodeAt(i) === 124) {
|
||||
let cs = cellStart, ce = i;
|
||||
while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
|
||||
while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
|
||||
cells.push(rowText.substring(cs, ce));
|
||||
cellStart = i + 1;
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseAlignment(text: string): CellAlign {
|
||||
const len = text.length;
|
||||
if (len === 0) return 'left';
|
||||
let start = 0, end = len - 1;
|
||||
while (start < len && text.charCodeAt(start) === 32) start++;
|
||||
while (end > start && text.charCodeAt(end) === 32) end--;
|
||||
if (start > end) return 'left';
|
||||
const hasLeft = text.charCodeAt(start) === 58;
|
||||
const hasRight = text.charCodeAt(end) === 58;
|
||||
if (hasLeft && hasRight) return 'center';
|
||||
if (hasRight) return 'right';
|
||||
return 'left';
|
||||
}
|
||||
|
||||
class TableWidget extends WidgetType {
|
||||
constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
|
||||
eq(other: TableWidget) {
|
||||
if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
|
||||
if (this.data === other.data) return true;
|
||||
if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
|
||||
for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
|
||||
for (let i = 0; i < this.data.rows.length; i++) {
|
||||
if (this.data.rows[i].length !== other.data.rows[i].length) return false;
|
||||
for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-table-container';
|
||||
container.style.height = `${this.visualHeight}px`;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'cm-table-wrapper';
|
||||
wrapper.style.maxWidth = `${this.contentWidth}px`;
|
||||
wrapper.style.maxHeight = `${this.visualHeight}px`;
|
||||
const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount;
|
||||
const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio;
|
||||
const headerCells = this.data.headers.map((h, i) => `<th class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${h.replace(/"/g, '"')}">${renderInlineMarkdown(h)}</th>`).join('');
|
||||
const bodyRows = this.data.rows.map(row => `<tr style="height:${dataRowHeight}px">${row.map((c, i) => `<td class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${c.replace(/"/g, '"')}">${renderInlineMarkdown(c)}</td>`).join('')}</tr>`).join('');
|
||||
wrapper.innerHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
|
||||
container.appendChild(wrapper);
|
||||
return container;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
|
||||
const folded = foldedRanges(view.state);
|
||||
const cursor = folded.iter();
|
||||
while (cursor.value) {
|
||||
if (cursor.from < to && cursor.to > from) return true;
|
||||
cursor.next();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Table node.
|
||||
*/
|
||||
export function handleTable(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (isInFoldedRange(ctx.view, nf, nt) || inCursor) return;
|
||||
|
||||
const headerNode = node.getChild('TableHeader');
|
||||
if (!headerNode) return;
|
||||
const headers: string[] = [];
|
||||
const alignments: CellAlign[] = [];
|
||||
const rows: string[][] = [];
|
||||
const headerCells = headerNode.getChildren('TableCell');
|
||||
if (headerCells.length > 0) {
|
||||
for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
|
||||
} else {
|
||||
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
|
||||
}
|
||||
if (headers.length === 0) return;
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
if (child.type.name === 'TableDelimiter') {
|
||||
const delimText = ctx.view.state.sliceDoc(child.from, child.to);
|
||||
if (delimText.includes('-')) {
|
||||
for (const part of parseRowText(delimText)) if (part.includes('-')) alignments.push(parseAlignment(part));
|
||||
break;
|
||||
}
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
while (alignments.length < headers.length) alignments.push('left');
|
||||
for (const rowNode of node.getChildren('TableRow')) {
|
||||
const rowData: string[] = [];
|
||||
const cells = rowNode.getChildren('TableCell');
|
||||
if (cells.length > 0) { for (const cell of cells) rowData.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); }
|
||||
else { rowData.push(...parseRowText(ctx.view.state.sliceDoc(rowNode.from, rowNode.to))); }
|
||||
while (rowData.length < headers.length) rowData.push('');
|
||||
rows.push(rowData);
|
||||
}
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
const startBlock = ctx.view.lineBlockAt(nf);
|
||||
const endBlock = ctx.view.lineBlockAt(nt);
|
||||
const visualHeight = endBlock.bottom - startBlock.top;
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_TABLE_LINE_HIDDEN });
|
||||
}
|
||||
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new TableWidget({ headers, alignments, rows }, lineCount, visualHeight, ctx.contentWidth), side: -1 }), priority: -1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for tables.
|
||||
*/
|
||||
export const tableTheme = EditorView.baseTheme({
|
||||
'.cm-table-container': {
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '2',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
'.cm-table-wrapper': {
|
||||
display: 'inline-block',
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
'.cm-table': {
|
||||
borderCollapse: 'separate',
|
||||
borderSpacing: '0',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
backgroundColor: 'var(--cm-table-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
|
||||
color: 'var(--text-primary) !important'
|
||||
},
|
||||
'.cm-table th, .cm-table td': {
|
||||
padding: '0 8px',
|
||||
border: 'none',
|
||||
color: 'inherit !important',
|
||||
verticalAlign: 'middle',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '300px'
|
||||
},
|
||||
'.cm-table td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' },
|
||||
'.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
|
||||
'.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' },
|
||||
'.cm-table tbody tr:last-child td:first-child': { boxShadow: 'none' },
|
||||
'.cm-table th': {
|
||||
backgroundColor: 'var(--cm-table-header-bg)',
|
||||
fontWeight: '600',
|
||||
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)'
|
||||
},
|
||||
'.cm-table th:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
|
||||
'.cm-table tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' },
|
||||
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': { textAlign: 'left' },
|
||||
'.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': { textAlign: 'center' },
|
||||
'.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': { textAlign: 'right' },
|
||||
'.cm-table code': {
|
||||
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'var(--voidraft-font-mono)'
|
||||
},
|
||||
'.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' },
|
||||
'.cm-table a:hover': { textDecoration: 'underline' },
|
||||
'.cm-line.cm-table-line-hidden': { color: 'transparent !important', caretColor: 'transparent' },
|
||||
'.cm-line.cm-table-line-hidden span': { color: 'transparent !important' },
|
||||
'.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': { color: 'transparent !important' }
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Unified theme - combines all markdown plugin themes.
|
||||
*/
|
||||
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { blockquoteTheme } from './blockquote';
|
||||
import { codeBlockTheme } from './code-block';
|
||||
import { headingTheme } from './heading';
|
||||
import { horizontalRuleTheme } from './horizontal-rule';
|
||||
import { inlineStylesTheme } from './inline-styles';
|
||||
import { linkTheme } from './link';
|
||||
import { listTheme } from './list';
|
||||
import { footnoteTheme } from './footnote';
|
||||
import { mathTheme } from './math';
|
||||
import { emojiTheme } from './emoji';
|
||||
import { tableTheme } from './table';
|
||||
|
||||
/**
|
||||
* All markdown themes combined.
|
||||
*/
|
||||
export const Theme: Extension = [
|
||||
blockquoteTheme,
|
||||
codeBlockTheme,
|
||||
headingTheme,
|
||||
horizontalRuleTheme,
|
||||
inlineStylesTheme,
|
||||
linkTheme,
|
||||
listTheme,
|
||||
footnoteTheme,
|
||||
mathTheme,
|
||||
emojiTheme,
|
||||
tableTheme
|
||||
];
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared types for unified markdown plugin handlers.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
|
||||
/** Decoration item to be added */
|
||||
export interface DecoItem {
|
||||
from: number;
|
||||
to: number;
|
||||
deco: Decoration;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/** Shared build context passed to all handlers */
|
||||
export interface BuildContext {
|
||||
view: EditorView;
|
||||
items: DecoItem[];
|
||||
selRange: RangeTuple;
|
||||
seen: Set<number>;
|
||||
processedLines: Set<number>;
|
||||
contentWidth: number;
|
||||
lineHeight: number;
|
||||
}
|
||||
|
||||
/** Handler function type */
|
||||
export type NodeHandler = (
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
) => void | boolean;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, StateField } from '@codemirror/state';
|
||||
import { Slugger } from '../util';
|
||||
import {SyntaxNode} from "@lezer/common";
|
||||
|
||||
/**
|
||||
* A heading slug is a string that is used to identify/reference
|
||||
* a heading in the document. Heading slugs are URI-compatible and can be used
|
||||
* in permalinks as heading IDs.
|
||||
*/
|
||||
export interface HeadingSlug {
|
||||
slug: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that stores the calculated slugs of the document headings in the
|
||||
* editor state. These can be useful when resolving links to headings inside
|
||||
* the document.
|
||||
*/
|
||||
export const headingSlugField = StateField.define<HeadingSlug[]>({
|
||||
create: (state) => {
|
||||
const slugs = extractSlugs(state);
|
||||
return slugs;
|
||||
},
|
||||
update: (value, tx) => {
|
||||
if (tx.docChanged) return extractSlugs(tx.state);
|
||||
return value;
|
||||
},
|
||||
compare: (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every((slug, i) => slug.slug === b[i].slug && slug.pos === b[i].pos)
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param state - The current editor state.
|
||||
* @returns An array of heading slugs.
|
||||
*/
|
||||
function extractSlugs(state: EditorState): HeadingSlug[] {
|
||||
const slugs: HeadingSlug[] = [];
|
||||
const slugger = new Slugger();
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ name, from, to, node }) => {
|
||||
// Capture ATXHeading and SetextHeading
|
||||
if (!name.includes('Heading')) return;
|
||||
const mark: SyntaxNode | null = node.getChild('HeaderMark');
|
||||
|
||||
const headerText = state.sliceDoc(from, to).split('');
|
||||
headerText.splice(mark!.from - from, mark!.to - mark!.from);
|
||||
const slug = slugger.slug(headerText.join('').trim());
|
||||
slugs.push({ slug, pos: from });
|
||||
}
|
||||
});
|
||||
return slugs;
|
||||
}
|
||||
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal file
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Emoji extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses :emoji_name: syntax for emoji shortcodes.
|
||||
*
|
||||
* Syntax: :emoji_name: → renders as actual emoji character
|
||||
*
|
||||
* Examples:
|
||||
* - :smile: → 😄
|
||||
* - :heart: → ❤️
|
||||
* - :+1: → 👍
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode } from '../util';
|
||||
import { emojies } from '@/common/constant/emojies';
|
||||
|
||||
/**
|
||||
* Pre-computed lookup table for emoji name characters.
|
||||
* Valid characters: a-z, 0-9, _, +, -
|
||||
* Uses Uint8Array for memory efficiency and O(1) lookup.
|
||||
*/
|
||||
const EMOJI_NAME_CHARS = new Uint8Array(128);
|
||||
// Initialize lookup table
|
||||
for (let i = 48; i <= 57; i++) EMOJI_NAME_CHARS[i] = 1; // 0-9
|
||||
for (let i = 97; i <= 122; i++) EMOJI_NAME_CHARS[i] = 1; // a-z
|
||||
EMOJI_NAME_CHARS[95] = 1; // _
|
||||
EMOJI_NAME_CHARS[43] = 1; // +
|
||||
EMOJI_NAME_CHARS[45] = 1; // -
|
||||
|
||||
/**
|
||||
* O(1) check if a character is valid for emoji name.
|
||||
* @param code - ASCII character code
|
||||
* @returns True if valid emoji name character
|
||||
*/
|
||||
function isEmojiNameChar(code: number): boolean {
|
||||
return code < 128 && EMOJI_NAME_CHARS[code] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse emoji :name: syntax.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at :)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseEmoji(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: : + name + : = at least 3 chars, name must be non-empty
|
||||
if (end < pos + 2) return -1;
|
||||
|
||||
// Track content for validation
|
||||
let hasContent = false;
|
||||
const contentStart = pos + 1;
|
||||
|
||||
// Search for closing :
|
||||
for (let i = contentStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Found closing :
|
||||
if (char === CharCode.Colon) {
|
||||
// Must have content
|
||||
if (!hasContent) return -1;
|
||||
|
||||
// Extract and validate emoji name
|
||||
const name = cx.slice(contentStart, i).toLowerCase();
|
||||
|
||||
// Check if this is a valid emoji
|
||||
if (!emojies[name]) return -1;
|
||||
|
||||
// Create element with marks and name
|
||||
return cx.addElement(cx.elt('Emoji', pos, i + 1, [
|
||||
cx.elt('EmojiMark', pos, contentStart),
|
||||
cx.elt('EmojiName', contentStart, i),
|
||||
cx.elt('EmojiMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
|
||||
// Newline not allowed in emoji
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Space not allowed in emoji name
|
||||
if (char === CharCode.Space || char === CharCode.Tab) return -1;
|
||||
|
||||
// Validate name character using O(1) lookup table
|
||||
// Also check for uppercase A-Z (65-90) and convert mentally
|
||||
const lowerChar = char >= 65 && char <= 90 ? char + 32 : char;
|
||||
if (isEmojiNameChar(lowerChar)) {
|
||||
hasContent = true;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji extension for Lezer Markdown.
|
||||
*
|
||||
* Defines:
|
||||
* - Emoji: The container node for emoji shortcode
|
||||
* - EmojiMark: The : delimiter marks
|
||||
* - EmojiName: The emoji name part
|
||||
*/
|
||||
export const Emoji: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Emoji' },
|
||||
{ name: 'EmojiMark' },
|
||||
{ name: 'EmojiName' }
|
||||
],
|
||||
parseInline: [
|
||||
{
|
||||
name: 'Emoji',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with :
|
||||
if (next !== CharCode.Colon) return -1;
|
||||
return parseEmoji(cx, pos);
|
||||
},
|
||||
// Parse after emphasis to avoid conflicts with other syntax
|
||||
after: 'Emphasis'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default Emoji;
|
||||
259
frontend/src/views/editor/extensions/markdown/syntax/footnote.ts
Normal file
259
frontend/src/views/editor/extensions/markdown/syntax/footnote.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Footnote extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses footnote syntax compatible with MultiMarkdown/PHP Markdown Extra.
|
||||
*
|
||||
* Syntax:
|
||||
* - Footnote reference: [^id] or [^1]
|
||||
* - Footnote definition: [^id]: content (at line start)
|
||||
* - Inline footnote: ^[content] (content is inline, no separate definition needed)
|
||||
*
|
||||
* Examples:
|
||||
* - This is text[^1] with a footnote.
|
||||
* - [^1]: This is the footnote content.
|
||||
* - This is text^[inline footnote content] with inline footnote.
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, Line, BlockContext, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode, isFootnoteIdChar } from '../util';
|
||||
|
||||
/**
|
||||
* Parse inline footnote ^[content].
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at ^)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseInlineFootnote(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: ^[ + content + ] = at least 4 chars
|
||||
if (end < pos + 3) return -1;
|
||||
|
||||
// Track bracket depth for nested brackets
|
||||
let bracketDepth = 1;
|
||||
let hasContent = false;
|
||||
const contentStart = pos + 2;
|
||||
|
||||
for (let i = contentStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Don't allow newlines
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Track bracket depth
|
||||
if (char === CharCode.OpenBracket) {
|
||||
bracketDepth++;
|
||||
} else if (char === CharCode.CloseBracket) {
|
||||
bracketDepth--;
|
||||
if (bracketDepth === 0) {
|
||||
// Found closing bracket - must have content
|
||||
if (!hasContent) return -1;
|
||||
|
||||
// Create element with marks and content
|
||||
return cx.addElement(cx.elt('InlineFootnote', pos, i + 1, [
|
||||
cx.elt('InlineFootnoteMark', pos, contentStart),
|
||||
cx.elt('InlineFootnoteContent', contentStart, i),
|
||||
cx.elt('InlineFootnoteMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse footnote reference [^id].
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at [)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseFootnoteReference(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: [^ + id + ] = at least 4 chars
|
||||
if (end < pos + 3) return -1;
|
||||
|
||||
let hasValidId = false;
|
||||
const labelStart = pos + 2;
|
||||
|
||||
for (let i = labelStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Found closing bracket
|
||||
if (char === CharCode.CloseBracket) {
|
||||
if (!hasValidId) return -1;
|
||||
|
||||
// Create element with marks and label
|
||||
return cx.addElement(cx.elt('FootnoteReference', pos, i + 1, [
|
||||
cx.elt('FootnoteReferenceMark', pos, labelStart),
|
||||
cx.elt('FootnoteReferenceLabel', labelStart, i),
|
||||
cx.elt('FootnoteReferenceMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
|
||||
// Don't allow newlines
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Validate id character using O(1) lookup table
|
||||
if (isFootnoteIdChar(char)) {
|
||||
hasValidId = true;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse footnote definition [^id]: content.
|
||||
*
|
||||
* @param cx - Block context
|
||||
* @param line - Current line
|
||||
* @returns True if parsed successfully
|
||||
*/
|
||||
function parseFootnoteDefinition(cx: BlockContext, line: Line): boolean {
|
||||
const text = line.text;
|
||||
const len = text.length;
|
||||
|
||||
// Minimum: [^id]: = at least 5 chars
|
||||
if (len < 5) return false;
|
||||
|
||||
// Find ]: pattern - use O(1) lookup for ID chars
|
||||
let labelEnd = 2;
|
||||
while (labelEnd < len) {
|
||||
const char = text.charCodeAt(labelEnd);
|
||||
|
||||
if (char === CharCode.CloseBracket) {
|
||||
// Check for : after ]
|
||||
if (labelEnd + 1 < len && text.charCodeAt(labelEnd + 1) === CharCode.Colon) {
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use O(1) lookup table
|
||||
if (!isFootnoteIdChar(char)) return false;
|
||||
|
||||
labelEnd++;
|
||||
}
|
||||
|
||||
// Validate ]: was found
|
||||
if (labelEnd >= len ||
|
||||
text.charCodeAt(labelEnd) !== CharCode.CloseBracket ||
|
||||
text.charCodeAt(labelEnd + 1) !== CharCode.Colon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate positions (all at once to avoid repeated arithmetic)
|
||||
const start = cx.lineStart;
|
||||
const openMarkEnd = start + 2;
|
||||
const labelEndPos = start + labelEnd;
|
||||
const closeMarkEnd = start + labelEnd + 2;
|
||||
|
||||
// Skip optional space after :
|
||||
let contentOffset = labelEnd + 2;
|
||||
if (contentOffset < len) {
|
||||
const spaceChar = text.charCodeAt(contentOffset);
|
||||
if (spaceChar === CharCode.Space || spaceChar === CharCode.Tab) {
|
||||
contentOffset++;
|
||||
}
|
||||
}
|
||||
|
||||
// Build children array
|
||||
const children = [
|
||||
cx.elt('FootnoteDefinitionMark', start, openMarkEnd),
|
||||
cx.elt('FootnoteDefinitionLabel', openMarkEnd, labelEndPos),
|
||||
cx.elt('FootnoteDefinitionMark', labelEndPos, closeMarkEnd)
|
||||
];
|
||||
|
||||
// Add content if present
|
||||
if (contentOffset < len) {
|
||||
children.push(cx.elt('FootnoteDefinitionContent', start + contentOffset, start + len));
|
||||
}
|
||||
|
||||
// Create and add block element
|
||||
cx.addElement(cx.elt('FootnoteDefinition', start, start + len, children));
|
||||
cx.nextLine();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footnote extension for Lezer Markdown.
|
||||
*
|
||||
* Defines nodes:
|
||||
* - FootnoteReference: Inline reference [^id]
|
||||
* - FootnoteReferenceMark: The [^ and ] delimiters
|
||||
* - FootnoteReferenceLabel: The id part
|
||||
* - FootnoteDefinition: Block definition [^id]: content
|
||||
* - FootnoteDefinitionMark: The [^, ]: delimiters
|
||||
* - FootnoteDefinitionLabel: The id part in definition
|
||||
* - FootnoteDefinitionContent: The content part
|
||||
* - InlineFootnote: Inline footnote ^[content]
|
||||
* - InlineFootnoteMark: The ^[ and ] delimiters
|
||||
* - InlineFootnoteContent: The content part
|
||||
*/
|
||||
export const Footnote: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
// Inline reference nodes
|
||||
{ name: 'FootnoteReference' },
|
||||
{ name: 'FootnoteReferenceMark' },
|
||||
{ name: 'FootnoteReferenceLabel' },
|
||||
// Block definition nodes
|
||||
{ name: 'FootnoteDefinition', block: true },
|
||||
{ name: 'FootnoteDefinitionMark' },
|
||||
{ name: 'FootnoteDefinitionLabel' },
|
||||
{ name: 'FootnoteDefinitionContent' },
|
||||
// Inline footnote nodes
|
||||
{ name: 'InlineFootnote' },
|
||||
{ name: 'InlineFootnoteMark' },
|
||||
{ name: 'InlineFootnoteContent' },
|
||||
],
|
||||
|
||||
parseInline: [
|
||||
{
|
||||
name: 'InlineFootnote',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with ^[
|
||||
if (next !== CharCode.Caret || cx.char(pos + 1) !== CharCode.OpenBracket) {
|
||||
return -1;
|
||||
}
|
||||
return parseInlineFootnote(cx, pos);
|
||||
},
|
||||
before: 'Superscript',
|
||||
},
|
||||
{
|
||||
name: 'FootnoteReference',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with [^
|
||||
if (next !== CharCode.OpenBracket || cx.char(pos + 1) !== CharCode.Caret) {
|
||||
return -1;
|
||||
}
|
||||
return parseFootnoteReference(cx, pos);
|
||||
},
|
||||
before: 'Link',
|
||||
},
|
||||
],
|
||||
|
||||
parseBlock: [
|
||||
{
|
||||
name: 'FootnoteDefinition',
|
||||
parse(cx: BlockContext, line: Line): boolean {
|
||||
// Fast path: must start with [^
|
||||
if (line.text.charCodeAt(0) !== CharCode.OpenBracket ||
|
||||
line.text.charCodeAt(1) !== CharCode.Caret) {
|
||||
return false;
|
||||
}
|
||||
return parseFootnoteDefinition(cx, line);
|
||||
},
|
||||
before: 'LinkReference',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default Footnote;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Highlight extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses ==highlight== syntax similar to Obsidian/Mark style.
|
||||
*
|
||||
* Syntax: ==text== → renders as highlighted text
|
||||
*
|
||||
* Example:
|
||||
* - This is ==important== text → This is <mark>important</mark> text
|
||||
*/
|
||||
|
||||
import { MarkdownConfig } from '@lezer/markdown';
|
||||
import { CharCode, createPairedDelimiterParser } from '../util';
|
||||
|
||||
/**
|
||||
* Highlight extension for Lezer Markdown.
|
||||
* Defines:
|
||||
* - Highlight: The container node for highlighted content
|
||||
* - HighlightMark: The == delimiter marks
|
||||
*/
|
||||
export const Highlight: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Highlight' },
|
||||
{ name: 'HighlightMark' }
|
||||
],
|
||||
parseInline: [
|
||||
createPairedDelimiterParser({
|
||||
name: 'Highlight',
|
||||
nodeName: 'Highlight',
|
||||
markName: 'HighlightMark',
|
||||
delimChar: CharCode.Equal,
|
||||
isDouble: true,
|
||||
after: 'Emphasis'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
export default Highlight;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Insert extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses ++insert++ syntax for inserted/underlined text.
|
||||
*
|
||||
* Syntax: ++text++ → renders as inserted text (underline)
|
||||
*
|
||||
* Example:
|
||||
* - This is ++inserted++ text → This is <ins>inserted</ins> text
|
||||
*/
|
||||
|
||||
import { MarkdownConfig } from '@lezer/markdown';
|
||||
import { CharCode, createPairedDelimiterParser } from '../util';
|
||||
|
||||
/**
|
||||
* Insert extension for Lezer Markdown.
|
||||
*
|
||||
* Uses optimized factory function for O(n) single-pass parsing.
|
||||
*
|
||||
* Defines:
|
||||
* - Insert: The container node for inserted content
|
||||
* - InsertMark: The ++ delimiter marks
|
||||
*/
|
||||
export const Insert: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Insert' },
|
||||
{ name: 'InsertMark' }
|
||||
],
|
||||
parseInline: [
|
||||
createPairedDelimiterParser({
|
||||
name: 'Insert',
|
||||
nodeName: 'Insert',
|
||||
markName: 'InsertMark',
|
||||
delimChar: CharCode.Plus,
|
||||
isDouble: true,
|
||||
after: 'Emphasis'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
export default Insert;
|
||||
146
frontend/src/views/editor/extensions/markdown/syntax/math.ts
Normal file
146
frontend/src/views/editor/extensions/markdown/syntax/math.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Math extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses LaTeX math syntax:
|
||||
* - Inline math: $E=mc^2$ → renders as inline formula
|
||||
* - Block math: $$...$$ → renders as block formula (can be multi-line)
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode } from '../util';
|
||||
|
||||
/**
|
||||
* Parse block math ($$...$$).
|
||||
* Allows multi-line content and handles escaped $.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at first $)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseBlockMath(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Don't match $$$ or more
|
||||
if (cx.char(pos + 2) === CharCode.Dollar) return -1;
|
||||
|
||||
// Minimum: $$ + content + $$ = at least 5 chars
|
||||
const minEnd = pos + 4;
|
||||
if (end < minEnd) return -1;
|
||||
|
||||
// Search for closing $$
|
||||
const searchEnd = end - 1;
|
||||
for (let i = pos + 2; i < searchEnd; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Skip escaped $ (backslash followed by any char)
|
||||
if (char === CharCode.Backslash) {
|
||||
i++; // Skip next char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found potential closing $$
|
||||
if (char === CharCode.Dollar) {
|
||||
const nextChar = cx.char(i + 1);
|
||||
if (nextChar !== CharCode.Dollar) continue;
|
||||
|
||||
// Don't match $$$
|
||||
if (i + 2 < end && cx.char(i + 2) === CharCode.Dollar) continue;
|
||||
|
||||
// Ensure content exists
|
||||
if (i === pos + 2) return -1;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt('BlockMath', pos, i + 2, [
|
||||
cx.elt('BlockMathMark', pos, pos + 2),
|
||||
cx.elt('BlockMathMark', i, i + 2)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline math ($...$).
|
||||
* Single line only, handles escaped $.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at $)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseInlineMath(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Don't match if preceded by backslash (escaped)
|
||||
if (pos > 0 && cx.char(pos - 1) === CharCode.Backslash) return -1;
|
||||
|
||||
// Minimum: $ + content + $ = at least 3 chars
|
||||
if (end < pos + 2) return -1;
|
||||
|
||||
// Search for closing $
|
||||
for (let i = pos + 1; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Newline not allowed in inline math
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Skip escaped $
|
||||
if (char === CharCode.Backslash && i + 1 < end && cx.char(i + 1) === CharCode.Dollar) {
|
||||
i++; // Skip next char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found potential closing $
|
||||
if (char === CharCode.Dollar) {
|
||||
// Don't match $$
|
||||
if (i + 1 < end && cx.char(i + 1) === CharCode.Dollar) continue;
|
||||
|
||||
// Ensure content exists
|
||||
if (i === pos + 1) return -1;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt('InlineMath', pos, i + 1, [
|
||||
cx.elt('InlineMathMark', pos, pos + 1),
|
||||
cx.elt('InlineMathMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Math extension for Lezer Markdown.
|
||||
*
|
||||
* Defines:
|
||||
* - InlineMath: Inline math formula $...$
|
||||
* - InlineMathMark: The $ delimiter marks for inline
|
||||
* - BlockMath: Block math formula $$...$$
|
||||
* - BlockMathMark: The $$ delimiter marks for block
|
||||
*/
|
||||
export const Math: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'InlineMath' },
|
||||
{ name: 'InlineMathMark' },
|
||||
{ name: 'BlockMath' },
|
||||
{ name: 'BlockMathMark' }
|
||||
],
|
||||
parseInline: [
|
||||
{
|
||||
name: 'Math',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with $
|
||||
if (next !== CharCode.Dollar) return -1;
|
||||
|
||||
// Check for $$ (block math) vs $ (inline math)
|
||||
const isBlock = cx.char(pos + 1) === CharCode.Dollar;
|
||||
|
||||
return isBlock ? parseBlockMath(cx, pos) : parseInlineMath(cx, pos);
|
||||
},
|
||||
// Parse after emphasis to avoid conflicts
|
||||
after: 'Emphasis'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default Math;
|
||||
202
frontend/src/views/editor/extensions/markdown/util.ts
Normal file
202
frontend/src/views/editor/extensions/markdown/util.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Decoration } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import type { InlineContext, InlineParser } from '@lezer/markdown';
|
||||
|
||||
/**
|
||||
* ASCII character codes for common delimiters.
|
||||
*/
|
||||
export const enum CharCode {
|
||||
Space = 32,
|
||||
Tab = 9,
|
||||
Newline = 10,
|
||||
Backslash = 92,
|
||||
Dollar = 36, // $
|
||||
Plus = 43, // +
|
||||
Equal = 61, // =
|
||||
OpenBracket = 91, // [
|
||||
CloseBracket = 93, // ]
|
||||
Caret = 94, // ^
|
||||
Colon = 58, // :
|
||||
Hyphen = 45, // -
|
||||
Underscore = 95, // _
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computed lookup table for footnote ID characters.
|
||||
* Valid characters: 0-9, A-Z, a-z, _, -
|
||||
* Uses Uint8Array for memory efficiency and O(1) lookup.
|
||||
*/
|
||||
const FOOTNOTE_ID_CHARS = new Uint8Array(128);
|
||||
// Initialize lookup table (0-9: 48-57, A-Z: 65-90, a-z: 97-122, _: 95, -: 45)
|
||||
for (let i = 48; i <= 57; i++) FOOTNOTE_ID_CHARS[i] = 1; // 0-9
|
||||
for (let i = 65; i <= 90; i++) FOOTNOTE_ID_CHARS[i] = 1; // A-Z
|
||||
for (let i = 97; i <= 122; i++) FOOTNOTE_ID_CHARS[i] = 1; // a-z
|
||||
FOOTNOTE_ID_CHARS[95] = 1; // _
|
||||
FOOTNOTE_ID_CHARS[45] = 1; // -
|
||||
|
||||
/**
|
||||
* O(1) check if a character is valid for footnote ID.
|
||||
* @param code - ASCII character code
|
||||
* @returns True if valid footnote ID character
|
||||
*/
|
||||
export function isFootnoteIdChar(code: number): boolean {
|
||||
return code < 128 && FOOTNOTE_ID_CHARS[code] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for paired delimiter parser factory.
|
||||
*/
|
||||
export interface PairedDelimiterConfig {
|
||||
/** Parser name */
|
||||
name: string;
|
||||
/** Node name for the container element */
|
||||
nodeName: string;
|
||||
/** Node name for the delimiter marks */
|
||||
markName: string;
|
||||
/** First delimiter character code */
|
||||
delimChar: number;
|
||||
/** Whether delimiter is doubled (e.g., == vs =) */
|
||||
isDouble: true;
|
||||
/** Whether to allow newlines in content */
|
||||
allowNewlines?: boolean;
|
||||
/** Parse order - after which parser */
|
||||
after?: string;
|
||||
/** Parse order - before which parser */
|
||||
before?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a paired delimiter inline parser.
|
||||
* Optimized with:
|
||||
* - Fast path early return
|
||||
* - Minimal function calls in loop
|
||||
* - Pre-computed delimiter length
|
||||
*
|
||||
* @param config - Parser configuration
|
||||
* @returns InlineParser for MarkdownConfig
|
||||
*/
|
||||
export function createPairedDelimiterParser(config: PairedDelimiterConfig): InlineParser {
|
||||
const { name, nodeName, markName, delimChar, allowNewlines = false, after, before } = config;
|
||||
const delimLen = 2; // Always double delimiter for these parsers
|
||||
|
||||
return {
|
||||
name,
|
||||
parse(cx: InlineContext, next: number, pos: number): number {
|
||||
// Fast path: check first character
|
||||
if (next !== delimChar) return -1;
|
||||
|
||||
// Check second delimiter character
|
||||
if (cx.char(pos + 1) !== delimChar) return -1;
|
||||
|
||||
// Don't match triple delimiter (e.g., ===, +++)
|
||||
if (cx.char(pos + 2) === delimChar) return -1;
|
||||
|
||||
// Calculate search bounds
|
||||
const searchEnd = cx.end - 1;
|
||||
const contentStart = pos + delimLen;
|
||||
|
||||
// Look for closing delimiter
|
||||
for (let i = contentStart; i < searchEnd; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Check for newline (unless allowed)
|
||||
if (!allowNewlines && char === CharCode.Newline) return -1;
|
||||
|
||||
// Found potential closing delimiter
|
||||
if (char === delimChar && cx.char(i + 1) === delimChar) {
|
||||
// Don't match triple delimiter
|
||||
if (i + 2 < cx.end && cx.char(i + 2) === delimChar) continue;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt(nodeName, pos, i + delimLen, [
|
||||
cx.elt(markName, pos, contentStart),
|
||||
cx.elt(markName, i, i + delimLen)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
...(after && { after }),
|
||||
...(before && { before })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tuple representation of a range [from, to].
|
||||
*/
|
||||
export type RangeTuple = [number, number];
|
||||
|
||||
/**
|
||||
* Check if two ranges overlap (touch or intersect).
|
||||
* Based on the visual diagram on https://stackoverflow.com/a/25369187
|
||||
*
|
||||
* @param range1 - First range
|
||||
* @param range2 - Second range
|
||||
* @returns True if the ranges overlap
|
||||
*/
|
||||
export function checkRangeOverlap(
|
||||
range1: RangeTuple,
|
||||
range2: RangeTuple
|
||||
): boolean {
|
||||
return range1[0] <= range2[1] && range2[0] <= range1[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the editor cursors is in the given range.
|
||||
*
|
||||
* @param state - Editor state
|
||||
* @param range - Range to check
|
||||
* @returns True if the cursor is in the range
|
||||
*/
|
||||
export function isCursorInRange(
|
||||
state: EditorState,
|
||||
range: RangeTuple
|
||||
): boolean {
|
||||
return state.selection.ranges.some((selection) =>
|
||||
checkRangeOverlap(range, [selection.from, selection.to])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoration to simply hide anything (replace with nothing).
|
||||
*/
|
||||
export const invisibleDecoration = Decoration.replace({});
|
||||
|
||||
|
||||
/**
|
||||
* Class for generating unique slugs from heading contents.
|
||||
*/
|
||||
export class Slugger {
|
||||
/** Occurrences for each slug. */
|
||||
private occurrences: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* Generate a slug from the given content.
|
||||
*
|
||||
* @param text - Content to generate the slug from
|
||||
* @returns The generated slug
|
||||
*/
|
||||
public slug(text: string): string {
|
||||
let slug = text
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w-]+/g, '');
|
||||
|
||||
const count = this.occurrences.get(slug) || 0;
|
||||
if (count > 0) {
|
||||
slug += '-' + count;
|
||||
}
|
||||
this.occurrences.set(slug, count + 1);
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the slugger state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.occurrences.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { markdownPreviewManager } from './manager';
|
||||
import { createMarkdownRenderer } from './renderer';
|
||||
import { updateMermaidTheme } from '@/common/markdown-it/plugins/markdown-it-mermaid';
|
||||
import { useThemeStore } from '@/stores/themeStore';
|
||||
import { createDebounce } from '@/common/utils/debounce';
|
||||
import { morphHTML } from '@/common/utils/domDiff';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
import './github-markdown.css';
|
||||
|
||||
const state = markdownPreviewManager.useState();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const panelRef = ref<HTMLDivElement | null>(null);
|
||||
const contentRef = ref<HTMLDivElement | null>(null);
|
||||
const resizeHandleRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const isVisible = computed(() => state.value.visible);
|
||||
const content = computed(() => state.value.content);
|
||||
const height = computed(() => state.value.position.height);
|
||||
|
||||
// Markdown 渲染器
|
||||
let md = createMarkdownRenderer();
|
||||
|
||||
// 渲染的 HTML
|
||||
const renderedHtml = ref('');
|
||||
let lastRenderedContent = '';
|
||||
let isDestroyed = false;
|
||||
|
||||
/**
|
||||
* 使用 DOM Diff 渲染内容
|
||||
*/
|
||||
function renderWithDiff(markdownContent: string): void {
|
||||
if (isDestroyed || !contentRef.value) return;
|
||||
|
||||
try {
|
||||
const newHtml = md.render(markdownContent);
|
||||
|
||||
// 首次渲染或内容为空,直接设置
|
||||
if (!lastRenderedContent || contentRef.value.children.length === 0) {
|
||||
renderedHtml.value = newHtml;
|
||||
} else {
|
||||
// 使用 DOM Diff 增量更新
|
||||
morphHTML(contentRef.value, newHtml);
|
||||
}
|
||||
|
||||
lastRenderedContent = markdownContent;
|
||||
} catch (error) {
|
||||
console.warn('Markdown render error:', error);
|
||||
renderedHtml.value = `<div class="markdown-error">Render failed: ${error}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步渲染大内容
|
||||
*/
|
||||
function renderLargeContentAsync(markdownContent: string): void {
|
||||
if (isDestroyed || !isVisible.value) return;
|
||||
|
||||
// 首次渲染显示加载状态
|
||||
if (!lastRenderedContent) {
|
||||
renderedHtml.value = '<div class="markdown-loading">Rendering...</div>';
|
||||
}
|
||||
|
||||
// 使用 requestIdleCallback 在浏览器空闲时渲染
|
||||
const callback = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
|
||||
|
||||
callback(() => {
|
||||
// 再次检查状态,防止异步回调时预览已关闭
|
||||
if (isDestroyed || !isVisible.value || !contentRef.value) return;
|
||||
|
||||
try {
|
||||
const newHtml = md.render(markdownContent);
|
||||
|
||||
// 首次渲染或内容为空
|
||||
if (!lastRenderedContent || contentRef.value.children.length === 0) {
|
||||
// 使用 DocumentFragment 减少 DOM 操作
|
||||
const fragment = document.createRange().createContextualFragment(newHtml);
|
||||
if (contentRef.value) {
|
||||
contentRef.value.innerHTML = '';
|
||||
contentRef.value.appendChild(fragment);
|
||||
}
|
||||
} else {
|
||||
// 使用 DOM Diff 增量更新
|
||||
morphHTML(contentRef.value, newHtml);
|
||||
}
|
||||
|
||||
lastRenderedContent = markdownContent;
|
||||
} catch (error) {
|
||||
console.warn('Large content render error:', error);
|
||||
if (isVisible.value) {
|
||||
renderedHtml.value = `<div class="markdown-error">Render failed: ${error}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Markdown 内容
|
||||
*/
|
||||
function renderMarkdown(markdownContent: string): void {
|
||||
if (!markdownContent || markdownContent === lastRenderedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 大内容使用异步渲染
|
||||
if (markdownContent.length > 1000) {
|
||||
renderLargeContentAsync(markdownContent);
|
||||
} else {
|
||||
renderWithDiff(markdownContent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新创建渲染器
|
||||
*/
|
||||
function resetRenderer(): void {
|
||||
md = createMarkdownRenderer();
|
||||
const currentTheme = themeStore.isDarkMode ? 'dark' : 'default';
|
||||
updateMermaidTheme(currentTheme);
|
||||
|
||||
lastRenderedContent = '';
|
||||
if (content.value) {
|
||||
renderMarkdown(content.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 data-theme 属性值
|
||||
const dataTheme = computed(() => themeStore.isDarkMode ? 'dark' : 'light');
|
||||
|
||||
// 拖动相关状态
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
let currentHandle: HTMLDivElement | null = null;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const delta = startY - e.clientY; // 向上拖动增加高度
|
||||
|
||||
// 获取编辑器容器高度作为最大限制
|
||||
const editorView = state.value.view;
|
||||
const maxHeight = editorView
|
||||
? (editorView.dom.parentElement?.clientHeight || editorView.dom.clientHeight)
|
||||
: 9999;
|
||||
|
||||
const newHeight = Math.max(10, Math.min(maxHeight, startHeight + delta));
|
||||
markdownPreviewManager.updateHeight(newHeight);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
if (currentHandle) {
|
||||
currentHandle.classList.remove('dragging');
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startY = e.clientY;
|
||||
startHeight = height.value;
|
||||
currentHandle = resizeHandleRef.value;
|
||||
if (currentHandle) {
|
||||
currentHandle.classList.add('dragging');
|
||||
}
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理拖动事件
|
||||
*/
|
||||
function cleanupResize(): void {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理链接点击
|
||||
*/
|
||||
function handleLinkClick(e: MouseEvent): void {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
|
||||
// 处理锚点跳转
|
||||
if (href && href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
const targetId = href.substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
if (targetElement && contentRef.value?.contains(targetElement)) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理外部链接
|
||||
if (href && !href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
if (isValidUrl(href)) {
|
||||
runtime.Browser.OpenURL(href);
|
||||
} else {
|
||||
console.warn('Invalid or relative link:', href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// data-href 属性处理
|
||||
const dataHref = anchor.getAttribute('data-href');
|
||||
if (dataHref) {
|
||||
e.preventDefault();
|
||||
if (isValidUrl(dataHref)) {
|
||||
runtime.Browser.OpenURL(dataHref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 URL 是否有效
|
||||
*/
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
if (url.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) {
|
||||
const parsedUrl = new URL(url);
|
||||
const allowedProtocols = ['http:', 'https:', 'mailto:', 'file:', 'ftp:'];
|
||||
return allowedProtocols.includes(parsedUrl.protocol);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 面板样式
|
||||
const panelStyle = computed(() => ({
|
||||
height: `${height.value}px`
|
||||
}));
|
||||
|
||||
// 创建防抖渲染函数
|
||||
const { debouncedFn: debouncedRender, cancel: cancelDebounce } = createDebounce(
|
||||
(newContent: string) => {
|
||||
if (isVisible.value && newContent) {
|
||||
renderMarkdown(newContent);
|
||||
}
|
||||
},
|
||||
{ delay: 500 }
|
||||
);
|
||||
|
||||
// 监听内容变化
|
||||
watch(
|
||||
content,
|
||||
(newContent) => {
|
||||
if (isVisible.value && newContent) {
|
||||
debouncedRender(newContent);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听主题变化
|
||||
watch(() => themeStore.isDarkMode, resetRenderer);
|
||||
|
||||
// 监听可见性变化,初始化/清理拖动
|
||||
watch(isVisible, async (visible) => {
|
||||
if (visible) {
|
||||
await nextTick();
|
||||
// 初始化拖动手柄
|
||||
const handle = resizeHandleRef.value;
|
||||
if (handle) {
|
||||
handle.addEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
if (content.value) {
|
||||
renderMarkdown(content.value);
|
||||
}
|
||||
} else {
|
||||
// 清理拖动事件
|
||||
const handle = resizeHandleRef.value;
|
||||
if (handle) {
|
||||
handle.removeEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
cleanupResize();
|
||||
|
||||
cancelDebounce();
|
||||
renderedHtml.value = '';
|
||||
lastRenderedContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
isDestroyed = true;
|
||||
cancelDebounce();
|
||||
cleanupResize();
|
||||
lastRenderedContent = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="preview-slide">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
ref="panelRef"
|
||||
class="cm-markdown-preview-panel"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<!-- 拖动调整手柄 -->
|
||||
<div ref="resizeHandleRef" class="cm-preview-resize-handle">
|
||||
<div class="resize-indicator"></div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容 -->
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="cm-preview-content markdown-body"
|
||||
:data-theme="dataTheme"
|
||||
@click="handleLinkClick"
|
||||
v-html="renderedHtml"
|
||||
></div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cm-markdown-preview-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-height: 10px;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle.dragging {
|
||||
background: var(--bg-hover, rgba(66, 133, 244, 0.1));
|
||||
}
|
||||
|
||||
.resize-indicator {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-color, rgba(255, 255, 255, 0.2));
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle:hover .resize-indicator {
|
||||
background: var(--text-muted, rgba(255, 255, 255, 0.4));
|
||||
}
|
||||
|
||||
.cm-preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 45px;
|
||||
box-sizing: border-box;
|
||||
position: relative; /* 为绝对定位的 loading/error 提供定位上下文 */
|
||||
}
|
||||
|
||||
/* ========== macOS 窗口风格代码块(主题适配)========== */
|
||||
.cm-preview-content.markdown-body {
|
||||
:deep(pre) {
|
||||
position: relative;
|
||||
padding-top: 40px !important;
|
||||
}
|
||||
|
||||
/* 暗色主题 */
|
||||
&[data-theme="dark"] {
|
||||
:deep(pre) {
|
||||
/* macOS 窗口顶部栏 */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
background-color: #1c1c1e;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
border-radius: 6px 6px 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* macOS 三个控制按钮 */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #ec6a5f;
|
||||
box-shadow: 18px 0 0 0 #f4bf4f, 36px 0 0 0 #61c554;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
&[data-theme="light"] {
|
||||
:deep(pre) {
|
||||
/* macOS 窗口顶部栏 */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
background-color: #e8e8e8;
|
||||
border-bottom: 1px solid #d1d1d6;
|
||||
border-radius: 6px 6px 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* macOS 三个控制按钮 */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff5f57;
|
||||
box-shadow: 18px 0 0 0 #febc2e, 36px 0 0 0 #28c840;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading 和 Error 状态 - 居中显示 */
|
||||
.cm-preview-content :deep(.markdown-loading),
|
||||
.cm-preview-content :deep(.markdown-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted, #7d8590);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cm-preview-content :deep(.markdown-error) {
|
||||
color: #f85149;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 过渡动画 - 从下往上弹起 */
|
||||
.preview-slide-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.preview-slide-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
|
||||
.preview-slide-enter-from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.preview-slide-enter-to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-slide-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
||||
import { EditorView, ViewUpdate, ViewPlugin } from "@codemirror/view";
|
||||
import { Extension } from "@codemirror/state";
|
||||
import { useDocumentStore } from "@/stores/documentStore";
|
||||
import { getActiveNoteBlock } from "../codeblock/state";
|
||||
import { markdownPreviewManager } from "./manager";
|
||||
|
||||
/**
|
||||
* 切换预览面板的命令
|
||||
*/
|
||||
export function toggleMarkdownPreview(view: EditorView): boolean {
|
||||
const documentStore = useDocumentStore();
|
||||
const activeBlock = getActiveNoteBlock(view.state as any);
|
||||
|
||||
// 如果当前没有激活的 Markdown 块,不执行操作
|
||||
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取当前文档ID
|
||||
const currentDocumentId = documentStore.currentDocumentId;
|
||||
if (currentDocumentId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 切换预览状态
|
||||
if (markdownPreviewManager.isVisible()) {
|
||||
markdownPreviewManager.hide();
|
||||
} else {
|
||||
markdownPreviewManager.show(
|
||||
view,
|
||||
currentDocumentId,
|
||||
activeBlock.content.from,
|
||||
activeBlock.content.to
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览同步插件
|
||||
*/
|
||||
const previewSyncPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
constructor(private view: EditorView) {}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// 只在预览可见时处理
|
||||
if (!markdownPreviewManager.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocumentId = documentStore.currentDocumentId;
|
||||
const previewDocId = markdownPreviewManager.getCurrentDocumentId();
|
||||
|
||||
// 如果切换了文档,关闭预览
|
||||
if (currentDocumentId !== previewDocId) {
|
||||
markdownPreviewManager.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// 文档内容改变时,更新预览
|
||||
if (update.docChanged) {
|
||||
const activeBlock = getActiveNoteBlock(update.state as any);
|
||||
|
||||
// 如果不再是 Markdown 块,关闭预览
|
||||
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
|
||||
markdownPreviewManager.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = markdownPreviewManager.getCurrentBlockRange();
|
||||
|
||||
// 如果切换到其他块,关闭预览
|
||||
if (range && activeBlock.content.from !== range.from) {
|
||||
markdownPreviewManager.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新预览内容
|
||||
const newContent = update.state.doc.sliceString(
|
||||
activeBlock.content.from,
|
||||
activeBlock.content.to
|
||||
);
|
||||
markdownPreviewManager.updateContent(
|
||||
newContent,
|
||||
activeBlock.content.from,
|
||||
activeBlock.content.to
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
markdownPreviewManager.destroy();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 导出 Markdown 预览扩展
|
||||
*/
|
||||
export function markdownPreviewExtension(): Extension {
|
||||
return [previewSyncPlugin];
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { shallowRef, type ShallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* 预览面板位置配置
|
||||
*/
|
||||
interface PreviewPosition {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览状态
|
||||
*/
|
||||
interface PreviewState {
|
||||
visible: boolean;
|
||||
position: PreviewPosition;
|
||||
content: string;
|
||||
blockFrom: number;
|
||||
blockTo: number;
|
||||
documentId: number | null;
|
||||
view: EditorView | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 预览管理器类
|
||||
*/
|
||||
class MarkdownPreviewManager {
|
||||
private state: ShallowRef<PreviewState> = shallowRef({
|
||||
visible: false,
|
||||
position: { height: 300 },
|
||||
content: '',
|
||||
blockFrom: 0,
|
||||
blockTo: 0,
|
||||
documentId: null,
|
||||
view: null
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取状态(供 Vue 组件使用)
|
||||
*/
|
||||
useState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示预览面板
|
||||
*/
|
||||
show(view: EditorView, documentId: number, blockFrom: number, blockTo: number): void {
|
||||
const content = view.state.doc.sliceString(blockFrom, blockTo);
|
||||
|
||||
// 计算初始高度(编辑器容器高度的 50%)
|
||||
const containerHeight = view.dom.parentElement?.clientHeight || view.dom.clientHeight;
|
||||
const defaultHeight = Math.floor(containerHeight * 0.5);
|
||||
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { height: Math.max(10, defaultHeight) },
|
||||
content,
|
||||
blockFrom,
|
||||
blockTo,
|
||||
documentId,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预览内容(文档编辑时调用)
|
||||
*/
|
||||
updateContent(content: string, blockFrom: number, blockTo: number): void {
|
||||
if (!this.state.value.visible) return;
|
||||
|
||||
this.state.value = {
|
||||
...this.state.value,
|
||||
content,
|
||||
blockFrom,
|
||||
blockTo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新面板高度
|
||||
*/
|
||||
updateHeight(height: number): void {
|
||||
if (!this.state.value.visible) return;
|
||||
|
||||
this.state.value = {
|
||||
...this.state.value,
|
||||
position: { height: Math.max(10, height) }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏预览面板
|
||||
*/
|
||||
hide(): void {
|
||||
if (!this.state.value.visible) return;
|
||||
|
||||
const view = this.state.value.view;
|
||||
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { height: 300 },
|
||||
content: '',
|
||||
blockFrom: 0,
|
||||
blockTo: 0,
|
||||
documentId: null,
|
||||
view: null
|
||||
};
|
||||
|
||||
// 关闭后聚焦编辑器
|
||||
if (view) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查预览是否可见
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return this.state.value.visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前预览的文档 ID
|
||||
*/
|
||||
getCurrentDocumentId(): number | null {
|
||||
return this.state.value.documentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前预览的块范围
|
||||
*/
|
||||
getCurrentBlockRange(): { from: number; to: number } | null {
|
||||
if (!this.state.value.visible) return null;
|
||||
return {
|
||||
from: this.state.value.blockFrom,
|
||||
to: this.state.value.blockTo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
destroy(): void {
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { height: 300 },
|
||||
content: '',
|
||||
blockFrom: 0,
|
||||
blockTo: 0,
|
||||
documentId: null,
|
||||
view: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单例
|
||||
*/
|
||||
export const markdownPreviewManager = new MarkdownPreviewManager();
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Markdown 渲染器配置和自定义插件
|
||||
*/
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import {tasklist} from "@mdit/plugin-tasklist";
|
||||
import {katex} from "@mdit/plugin-katex";
|
||||
import markPlugin from "@/common/markdown-it/plugins/markdown-it-mark";
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/default.css';
|
||||
import {full as emoji} from '@/common/markdown-it/plugins/markdown-it-emojis/'
|
||||
import footnote_plugin from "@/common/markdown-it/plugins/markdown-it-footnote"
|
||||
import sup_plugin from "@/common/markdown-it/plugins/markdown-it-sup"
|
||||
import ins_plugin from "@/common/markdown-it/plugins/markdown-it-ins"
|
||||
import deflist_plugin from "@/common/markdown-it/plugins/markdown-it-deflist"
|
||||
import abbr_plugin from "@/common/markdown-it/plugins/markdown-it-abbr"
|
||||
import sub_plugin from "@/common/markdown-it/plugins/markdown-it-sub"
|
||||
import {MermaidIt} from "@/common/markdown-it/plugins/markdown-it-mermaid"
|
||||
import {useThemeStore} from '@/stores/themeStore'
|
||||
|
||||
/**
|
||||
* 自定义链接插件:使用 data-href 替代 href,配合事件委托实现自定义跳转
|
||||
*/
|
||||
export function customLinkPlugin(md: MarkdownIt) {
|
||||
// 保存默认的 link_open 渲染器
|
||||
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
|
||||
// 重写 link_open 渲染器
|
||||
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||
const token = tokens[idx];
|
||||
|
||||
// 获取 href 属性
|
||||
const hrefIndex = token.attrIndex('href');
|
||||
if (hrefIndex >= 0) {
|
||||
const href = token.attrs![hrefIndex][1];
|
||||
|
||||
// 添加 data-href 属性保存原始链接
|
||||
token.attrPush(['data-href', href]);
|
||||
|
||||
// 添加 class 用于样式
|
||||
const classIndex = token.attrIndex('class');
|
||||
if (classIndex < 0) {
|
||||
token.attrPush(['class', 'markdown-link']);
|
||||
} else {
|
||||
token.attrs![classIndex][1] += ' markdown-link';
|
||||
}
|
||||
|
||||
// 移除 href 属性,防止默认跳转
|
||||
token.attrs!.splice(hrefIndex, 1);
|
||||
}
|
||||
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Markdown-It 实例
|
||||
*/
|
||||
export function createMarkdownRenderer(): MarkdownIt {
|
||||
const themeStore = useThemeStore();
|
||||
const mermaidTheme = themeStore.isDarkMode ? "dark" : "default";
|
||||
|
||||
return new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true,
|
||||
langPrefix: "language-",
|
||||
highlight: (code, lang) => {
|
||||
// 对于大代码块(>1000行),跳过高亮以提升性能
|
||||
if (code.length > 50000) {
|
||||
return `<pre><code>${code}</code></pre>`;
|
||||
}
|
||||
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(code, {language: lang, ignoreIllegals: true}).value;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to highlight code block with language: ${lang}`, error);
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
// 对于中等大小的代码块(>5000字符),跳过自动检测
|
||||
if (code.length > 5000) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// 小代码块才使用自动检测
|
||||
try {
|
||||
return hljs.highlightAuto(code).value;
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-highlight code block', error);
|
||||
return code;
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(tasklist, {
|
||||
disabled: false,
|
||||
})
|
||||
.use(customLinkPlugin)
|
||||
.use(markPlugin)
|
||||
.use(emoji)
|
||||
.use(footnote_plugin)
|
||||
.use(sup_plugin)
|
||||
.use(ins_plugin)
|
||||
.use(deflist_plugin)
|
||||
.use(abbr_plugin)
|
||||
.use(sub_plugin)
|
||||
.use(katex)
|
||||
.use(MermaidIt, {
|
||||
theme: mermaidTheme
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +1,88 @@
|
||||
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'
|
||||
];
|
||||
}
|
||||
// 彩虹颜色数组
|
||||
const COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
|
||||
|
||||
const OPEN_BRACKETS = new Set(['(', '[', '{']);
|
||||
const CLOSE_BRACKETS = new Set([')', ']', '}']);
|
||||
const BRACKET_PAIRS: Record<string, string> = { ')': '(', ']': '[', '}': '{' };
|
||||
|
||||
/**
|
||||
* 彩虹括号插件
|
||||
*/
|
||||
class RainbowBracketsView {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.getBracketDecorations(view);
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = this.getBracketDecorations(update.view);
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
private getBracketDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
private buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const stack: { type: string; from: number }[] = [];
|
||||
const colors = generateColors();
|
||||
|
||||
// 遍历文档内容
|
||||
for (let pos = 0; pos < doc.length; pos++) {
|
||||
const doc = view.state.doc;
|
||||
|
||||
const visibleRanges = view.visibleRanges;
|
||||
if (visibleRanges.length === 0) {
|
||||
return Decoration.set([]);
|
||||
}
|
||||
|
||||
const visibleFrom = visibleRanges[0].from;
|
||||
const visibleTo = visibleRanges[visibleRanges.length - 1].to;
|
||||
|
||||
// 阶段1: 预计算到可视范围开始位置的栈状态(只维护栈,不创建装饰)
|
||||
const stack: { char: string; from: number }[] = [];
|
||||
|
||||
for (let pos = 0; pos < visibleFrom && 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 === '}') {
|
||||
|
||||
if (OPEN_BRACKETS.has(char)) {
|
||||
stack.push({ char, from: pos });
|
||||
} else if (CLOSE_BRACKETS.has(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)
|
||||
);
|
||||
if (open && open.char !== BRACKET_PAIRS[char]) {
|
||||
stack.push(open); // 不匹配,放回
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 阶段2: 处理可视范围内的括号(创建装饰)
|
||||
for (let pos = visibleFrom; pos < visibleTo && pos < doc.length; pos++) {
|
||||
const char = doc.sliceString(pos, pos + 1);
|
||||
|
||||
if (OPEN_BRACKETS.has(char)) {
|
||||
const depth = stack.length;
|
||||
stack.push({ char, from: pos });
|
||||
|
||||
// 添加开括号装饰
|
||||
const color = COLORS[depth % COLORS.length];
|
||||
decorations.push(
|
||||
Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1)
|
||||
);
|
||||
} else if (CLOSE_BRACKETS.has(char)) {
|
||||
const open = stack.pop();
|
||||
|
||||
if (open && open.char === BRACKET_PAIRS[char]) {
|
||||
const depth = stack.length;
|
||||
const color = COLORS[depth % COLORS.length];
|
||||
|
||||
// 添加闭括号装饰
|
||||
decorations.push(
|
||||
Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1)
|
||||
);
|
||||
} else if (open) {
|
||||
stack.push(open); // 不匹配,放回
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations.sort((a, b) => a.from - b.from));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +90,7 @@ const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
export default function index() {
|
||||
export default function rainbowBrackets() {
|
||||
return [
|
||||
rainbowBracketsPlugin,
|
||||
EditorView.baseTheme({
|
||||
@@ -83,4 +104,4 @@ export default function index() {
|
||||
'.cm-rainbow-bracket-violet': { color: '#9B5DE5' },
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
export const spellcheck = (): Extension => {
|
||||
return EditorView.contentAttributes.of({
|
||||
spellcheck: 'true',
|
||||
})
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { EditorState, StateEffect, StateField, Facet } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||
|
||||
// 高亮配置接口
|
||||
export interface TextHighlightConfig {
|
||||
backgroundColor?: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG: Required<TextHighlightConfig> = {
|
||||
backgroundColor: '#FFD700', // 金黄色
|
||||
opacity: 0.3
|
||||
};
|
||||
|
||||
// 定义添加和移除高亮的状态效果
|
||||
const addHighlight = StateEffect.define<{from: number, to: number}>({
|
||||
map: ({from, to}, change) => ({
|
||||
from: change.mapPos(from),
|
||||
to: change.mapPos(to)
|
||||
})
|
||||
});
|
||||
|
||||
const removeHighlight = StateEffect.define<{from: number, to: number}>({
|
||||
map: ({from, to}, change) => ({
|
||||
from: change.mapPos(from),
|
||||
to: change.mapPos(to)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 配置facet
|
||||
const highlightConfigFacet = Facet.define<TextHighlightConfig, Required<TextHighlightConfig>>({
|
||||
combine: (configs) => {
|
||||
const result = { ...DEFAULT_CONFIG };
|
||||
for (const config of configs) {
|
||||
if (config.backgroundColor !== undefined) {
|
||||
result.backgroundColor = config.backgroundColor;
|
||||
}
|
||||
if (config.opacity !== undefined) {
|
||||
result.opacity = config.opacity;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// 创建高亮装饰
|
||||
function createHighlightMark(config: Required<TextHighlightConfig>): Decoration {
|
||||
const { backgroundColor, opacity } = config;
|
||||
const rgbaColor = hexToRgba(backgroundColor, opacity);
|
||||
|
||||
return Decoration.mark({
|
||||
attributes: {
|
||||
style: `background-color: ${rgbaColor}; border-radius: 2px;`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 将十六进制颜色转换为RGBA
|
||||
function hexToRgba(hex: string, opacity: number): string {
|
||||
// 移除 # 符号
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
// 处理短格式 (如 #FFF -> #FFFFFF)
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map(char => char + char).join('');
|
||||
}
|
||||
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
|
||||
// 存储高亮范围的状态字段 - 支持撤销
|
||||
const highlightState = StateField.define<DecorationSet>({
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decorations, tr) {
|
||||
// 映射现有装饰以适应文档变化
|
||||
decorations = decorations.map(tr.changes);
|
||||
|
||||
// 处理效果
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(addHighlight)) {
|
||||
const { from, to } = effect.value;
|
||||
const config = tr.state.facet(highlightConfigFacet);
|
||||
const highlightMark = createHighlightMark(config);
|
||||
|
||||
decorations = decorations.update({
|
||||
add: [highlightMark.range(from, to)]
|
||||
});
|
||||
}
|
||||
else if (effect.is(removeHighlight)) {
|
||||
const { from, to } = effect.value;
|
||||
decorations = decorations.update({
|
||||
filter: (rangeFrom, rangeTo) => {
|
||||
// 移除与指定范围重叠的装饰
|
||||
return !(rangeFrom < to && rangeTo > from);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return decorations;
|
||||
},
|
||||
provide: field => EditorView.decorations.from(field)
|
||||
});
|
||||
|
||||
// 查找与给定范围重叠的所有高亮
|
||||
function findHighlightsInRange(state: EditorState, from: number, to: number): Array<{from: number, to: number}> {
|
||||
const highlights: Array<{from: number, to: number}> = [];
|
||||
|
||||
state.field(highlightState).between(from, to, (rangeFrom, rangeTo) => {
|
||||
if (rangeFrom < to && rangeTo > from) {
|
||||
highlights.push({ from: rangeFrom, to: rangeTo });
|
||||
}
|
||||
});
|
||||
|
||||
return highlights;
|
||||
}
|
||||
|
||||
// 查找指定位置包含的高亮
|
||||
function findHighlightsAt(state: EditorState, pos: number): Array<{from: number, to: number}> {
|
||||
const highlights: Array<{from: number, to: number}> = [];
|
||||
|
||||
state.field(highlightState).between(pos, pos, (from, to) => {
|
||||
highlights.push({ from, to });
|
||||
});
|
||||
|
||||
return highlights;
|
||||
}
|
||||
|
||||
// 添加高亮范围
|
||||
function addHighlightRange(view: EditorView, from: number, to: number): boolean {
|
||||
if (from === to) return false; // 不高亮空选择
|
||||
|
||||
// 检查是否已经完全高亮
|
||||
const overlappingHighlights = findHighlightsInRange(view.state, from, to);
|
||||
const isFullyHighlighted = overlappingHighlights.some(range =>
|
||||
range.from <= from && range.to >= to
|
||||
);
|
||||
|
||||
if (isFullyHighlighted) return false;
|
||||
|
||||
view.dispatch({
|
||||
effects: addHighlight.of({from, to})
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 移除高亮范围
|
||||
function removeHighlightRange(view: EditorView, from: number, to: number): boolean {
|
||||
const highlights = findHighlightsInRange(view.state, from, to);
|
||||
|
||||
if (highlights.length === 0) return false;
|
||||
|
||||
view.dispatch({
|
||||
effects: removeHighlight.of({from, to})
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 切换高亮状态
|
||||
function toggleHighlight(view: EditorView): boolean {
|
||||
const selection = view.state.selection.main;
|
||||
|
||||
// 如果有选择文本
|
||||
if (!selection.empty) {
|
||||
const {from, to} = selection;
|
||||
|
||||
// 检查选择范围内是否已经有高亮
|
||||
const highlights = findHighlightsInRange(view.state, from, to);
|
||||
|
||||
if (highlights.length > 0) {
|
||||
// 如果已有高亮,则移除
|
||||
return removeHighlightRange(view, from, to);
|
||||
} else {
|
||||
// 如果没有高亮,则添加
|
||||
return addHighlightRange(view, from, to);
|
||||
}
|
||||
}
|
||||
// 如果是光标
|
||||
else {
|
||||
const pos = selection.from;
|
||||
const highlightsAtCursor = findHighlightsAt(view.state, pos);
|
||||
|
||||
if (highlightsAtCursor.length > 0) {
|
||||
// 移除光标位置的高亮
|
||||
const highlight = highlightsAtCursor[0];
|
||||
return removeHighlightRange(view, highlight.from, highlight.to);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 导出文本高亮切换命令,供快捷键系统使用
|
||||
export const textHighlightToggleCommand = toggleHighlight;
|
||||
|
||||
// 创建文本高亮扩展
|
||||
export function createTextHighlighter(config: TextHighlightConfig = {}) {
|
||||
return [
|
||||
highlightConfigFacet.of(config),
|
||||
highlightState
|
||||
];
|
||||
}
|
||||
@@ -283,7 +283,7 @@ function handleClickOutside(e: MouseEvent) {
|
||||
class="cm-translation-copy-btn"
|
||||
@click="copyToClipboard"
|
||||
@mousedown.stop
|
||||
title="复制"
|
||||
title="Copy"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
import { getSearchQuery, RegExpCursor, SearchCursor, SearchQuery, setSearchQuery } from "@codemirror/search";
|
||||
import { CharCategory, EditorState, findClusterBreak, Text } from "@codemirror/state";
|
||||
import { SearchVisibilityEffect } from "./state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import crelt from "crelt";
|
||||
|
||||
type Match = { from: number, to: number };
|
||||
|
||||
export class CustomSearchPanel {
|
||||
dom!: HTMLElement;
|
||||
searchField!: HTMLInputElement;
|
||||
replaceField!: HTMLInputElement;
|
||||
matchCountField!: HTMLElement;
|
||||
currentMatch!: number;
|
||||
matches!: Match[];
|
||||
replaceVisibile: boolean = false;
|
||||
matchWord: boolean = false;
|
||||
matchCase: boolean = false;
|
||||
useRegex: boolean = false;
|
||||
|
||||
private totalMatches: number = 0;
|
||||
searchCursor?: SearchCursor;
|
||||
regexCursor?: RegExpCursor;
|
||||
|
||||
|
||||
private codicon: Record<string, string> = {
|
||||
"downChevron": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.976 10.072l4.357-4.357.62.618L8.284 11h-.618L3 6.333l.619-.618 4.357 4.357z"/></svg>',
|
||||
"rightChevron": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.072 8.024L5.715 3.667l.618-.62L11 7.716v.618L6.333 13l-.618-.619 4.357-4.357z"/></svg>',
|
||||
"matchCase": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z"/><path d="M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z"/></svg>',
|
||||
"wholeWord": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>',
|
||||
"regex": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z"/></svg>',
|
||||
"prevMatch": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.854 7l-5-5h-.707l-5 5 .707.707L8 3.561V14h1V3.56l4.146 4.147.708-.707z"/></svg>',
|
||||
"nextMatch": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.147 9l5 5h.707l5-5-.707-.707L9 12.439V2H8v10.44L3.854 8.292 3.147 9z"/></svg>',
|
||||
"close": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/></svg>',
|
||||
"replace": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z"/></svg>',
|
||||
"replaceAll": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z"/></svg>',
|
||||
};
|
||||
|
||||
constructor(readonly view: EditorView) {
|
||||
try {
|
||||
this.view = view;
|
||||
this.commit = this.commit.bind(this);
|
||||
|
||||
// 从现有查询状态初始化匹配选项
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query) {
|
||||
this.matchCase = query.caseSensitive;
|
||||
this.matchWord = query.wholeWord;
|
||||
this.useRegex = query.regexp;
|
||||
}
|
||||
|
||||
this.buildUI();
|
||||
this.setVisibility(false);
|
||||
|
||||
// 挂载到.cm-editor根容器,这样搜索框不会随内容滚动
|
||||
const editor = this.view.dom.closest('.cm-editor') || this.view.dom.querySelector('.cm-editor');
|
||||
if (editor) {
|
||||
editor.appendChild(this.dom);
|
||||
} else {
|
||||
// 如果当前DOM就是.cm-editor或者找不到.cm-editor,直接挂载到view.dom
|
||||
this.view.dom.appendChild(this.dom);
|
||||
}
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`ERROR: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateMatchCount(): void {
|
||||
if (this.totalMatches > 0) {
|
||||
this.matchCountField.textContent = `${this.currentMatch + 1} of ${this.totalMatches}`;
|
||||
} else {
|
||||
this.matchCountField.textContent = `0 of 0`;
|
||||
}
|
||||
}
|
||||
|
||||
private setSearchFieldError(hasError: boolean): void {
|
||||
if (hasError) {
|
||||
this.searchField.classList.add('error');
|
||||
} else {
|
||||
this.searchField.classList.remove('error');
|
||||
}
|
||||
}
|
||||
|
||||
private charBefore(str: string, index: number) {
|
||||
return str.slice(findClusterBreak(str, index, false), index);
|
||||
}
|
||||
|
||||
private charAfter(str: string, index: number) {
|
||||
return str.slice(index, findClusterBreak(str, index));
|
||||
}
|
||||
|
||||
private stringWordTest(doc: Text, categorizer: (ch: string) => CharCategory) {
|
||||
return (from: number, to: number, buf: string, bufPos: number) => {
|
||||
if (bufPos > from || bufPos + buf.length < to) {
|
||||
bufPos = Math.max(0, from - 2);
|
||||
buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2));
|
||||
}
|
||||
return (categorizer(this.charBefore(buf, from - bufPos)) != CharCategory.Word ||
|
||||
categorizer(this.charAfter(buf, from - bufPos)) != CharCategory.Word) &&
|
||||
(categorizer(this.charAfter(buf, to - bufPos)) != CharCategory.Word ||
|
||||
categorizer(this.charBefore(buf, to - bufPos)) != CharCategory.Word);
|
||||
};
|
||||
}
|
||||
|
||||
private regexpWordTest(categorizer: (ch: string) => CharCategory) {
|
||||
return (_from: number, _to: number, match: RegExpExecArray) =>
|
||||
!match[0].length ||
|
||||
(categorizer(this.charBefore(match.input, match.index)) != CharCategory.Word ||
|
||||
categorizer(this.charAfter(match.input, match.index)) != CharCategory.Word) &&
|
||||
(categorizer(this.charAfter(match.input, match.index + match[0].length)) != CharCategory.Word ||
|
||||
categorizer(this.charBefore(match.input, match.index + match[0].length)) != CharCategory.Word);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds all occurrences of a query, logs the total count,
|
||||
* and selects the closest one to the current cursor position.
|
||||
*
|
||||
* @param view - The CodeMirror editor view.
|
||||
* @param query - The search string to look for.
|
||||
*/
|
||||
findMatchesAndSelectClosest(state: EditorState): void {
|
||||
const cursorPos = state.selection.main.head;
|
||||
const query = getSearchQuery(state);
|
||||
|
||||
if (query.regexp) {
|
||||
try {
|
||||
this.regexCursor = new RegExpCursor(state.doc, query.search);
|
||||
this.searchCursor = undefined;
|
||||
} catch (error) {
|
||||
// 如果正则表达式无效,清空匹配结果并显示错误状态
|
||||
console.warn("Invalid regular expression:", query.search, error);
|
||||
this.matches = [];
|
||||
this.currentMatch = 0;
|
||||
this.totalMatches = 0;
|
||||
this.updateMatchCount();
|
||||
this.regexCursor = undefined;
|
||||
this.searchCursor = undefined;
|
||||
this.setSearchFieldError(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const cursor = new SearchCursor(state.doc, query.search);
|
||||
if (cursor !== this.searchCursor) {
|
||||
this.searchCursor = cursor;
|
||||
this.regexCursor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
this.matches = [];
|
||||
|
||||
if (this.searchCursor) {
|
||||
const matchWord = this.stringWordTest(state.doc, state.charCategorizer(state.selection.main.head));
|
||||
|
||||
while (!this.searchCursor.done) {
|
||||
this.searchCursor.next();
|
||||
if (!this.searchCursor.done) {
|
||||
const { from, to } = this.searchCursor.value;
|
||||
|
||||
if (!query.wholeWord || matchWord(from, to, "", 0)) {
|
||||
this.matches.push({ from, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (this.regexCursor) {
|
||||
try {
|
||||
const matchWord = this.regexpWordTest(state.charCategorizer(state.selection.main.head));
|
||||
|
||||
while (!this.regexCursor.done) {
|
||||
this.regexCursor.next();
|
||||
|
||||
if (!this.regexCursor.done) {
|
||||
const { from, to, match } = this.regexCursor.value;
|
||||
|
||||
if (!query.wholeWord || matchWord(from, to, match)) {
|
||||
this.matches.push({ from, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果正则表达式执行时出错,清空匹配结果
|
||||
console.warn("Error executing regular expression:", error);
|
||||
this.matches = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.currentMatch = 0;
|
||||
this.totalMatches = this.matches.length;
|
||||
|
||||
if (this.matches.length === 0) {
|
||||
this.updateMatchCount();
|
||||
this.setSearchFieldError(false);
|
||||
return;
|
||||
}
|
||||
// Find the match closest to the current cursor
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (let i = 0; i < this.totalMatches; i++) {
|
||||
const dist = Math.abs(this.matches[i].from - cursorPos);
|
||||
if (dist < closestDistance) {
|
||||
closestDistance = dist;
|
||||
this.currentMatch = i;
|
||||
}
|
||||
}
|
||||
this.updateMatchCount();
|
||||
this.setSearchFieldError(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (!match) return;
|
||||
|
||||
this.view.dispatch({
|
||||
selection: { anchor: match.from, head: match.to },
|
||||
scrollIntoView: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
commit() {
|
||||
try {
|
||||
const newQuery = new SearchQuery({
|
||||
search: this.searchField.value,
|
||||
replace: this.replaceField.value,
|
||||
caseSensitive: this.matchCase,
|
||||
regexp: this.useRegex,
|
||||
wholeWord: this.matchWord,
|
||||
});
|
||||
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (!newQuery.eq(query)) {
|
||||
this.view.dispatch({
|
||||
effects: setSearchQuery.of(newQuery)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果创建SearchQuery时出错(通常是无效的正则表达式),记录错误但不中断程序
|
||||
console.warn("Error creating search query:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private svgIcon(name: keyof CustomSearchPanel['codicon']): HTMLDivElement {
|
||||
const div = crelt("div", {},
|
||||
) as HTMLDivElement;
|
||||
|
||||
div.innerHTML = this.codicon[name];
|
||||
return div;
|
||||
}
|
||||
|
||||
public toggleReplace() {
|
||||
this.replaceVisibile = !this.replaceVisibile;
|
||||
const replaceBar = this.dom.querySelector(".replace-bar") as HTMLElement;
|
||||
const replaceButtons = this.dom.querySelector(".replace-buttons") as HTMLElement;
|
||||
const toggleIcon = this.dom.querySelector(".toggle-replace") as HTMLElement;
|
||||
if (replaceBar && toggleIcon && replaceButtons) {
|
||||
replaceBar.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
replaceButtons.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
toggleIcon.innerHTML = this.svgIcon(this.replaceVisibile ? "downChevron" : "rightChevron").innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
public showReplace() {
|
||||
if (!this.replaceVisibile) {
|
||||
this.toggleReplace();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public toggleCase() {
|
||||
this.matchCase = !this.matchCase;
|
||||
const toggleIcon = this.dom.querySelector(".case-sensitive-toggle") as HTMLElement;
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle("active");
|
||||
}
|
||||
this.commit();
|
||||
// 重新搜索以应用新的匹配规则
|
||||
setTimeout(() => {
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public toggleWord() {
|
||||
this.matchWord = !this.matchWord;
|
||||
const toggleIcon = this.dom.querySelector(".whole-word-toggle") as HTMLElement;
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle("active");
|
||||
}
|
||||
this.commit();
|
||||
// 重新搜索以应用新的匹配规则
|
||||
setTimeout(() => {
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public toggleRegex() {
|
||||
this.useRegex = !this.useRegex;
|
||||
const toggleIcon = this.dom.querySelector(".regex-toggle") as HTMLElement;
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle("active");
|
||||
}
|
||||
this.commit();
|
||||
// 重新搜索以应用新的匹配规则
|
||||
setTimeout(() => {
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public matchPrevious() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
this.currentMatch = (this.currentMatch - 1 + this.totalMatches) % this.totalMatches;
|
||||
this.updateMatchCount();
|
||||
|
||||
// 直接跳转到匹配位置,不调用原生函数
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (match) {
|
||||
this.view.dispatch({
|
||||
selection: { anchor: match.from, head: match.to },
|
||||
scrollIntoView: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public matchNext() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
this.currentMatch = (this.currentMatch + 1) % this.totalMatches;
|
||||
this.updateMatchCount();
|
||||
|
||||
// 直接跳转到匹配位置,不调用原生函数
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (match) {
|
||||
this.view.dispatch({
|
||||
selection: { anchor: match.from, head: match.to },
|
||||
scrollIntoView: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public findReplaceMatch() {
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query.replace) {
|
||||
this.replace();
|
||||
} else {
|
||||
this.matchNext();
|
||||
}
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.view.dispatch({ effects: SearchVisibilityEffect.of(false) });
|
||||
}
|
||||
|
||||
public replace() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (match) {
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query.replace) {
|
||||
// 执行替换
|
||||
this.view.dispatch({
|
||||
changes: { from: match.from, to: match.to, insert: query.replace },
|
||||
selection: { anchor: match.from, head: match.from + query.replace.length }
|
||||
});
|
||||
|
||||
// 重新查找匹配项
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public replaceAll() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query.replace) {
|
||||
// 从后往前替换,避免位置偏移问题
|
||||
const changes = this.matches
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(match => ({
|
||||
from: match.from,
|
||||
to: match.to,
|
||||
insert: query.replace
|
||||
}));
|
||||
|
||||
this.view.dispatch({
|
||||
changes: changes
|
||||
});
|
||||
|
||||
// 重新查找匹配项
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}
|
||||
}
|
||||
|
||||
private buildUI(): void {
|
||||
|
||||
const query = getSearchQuery(this.view.state);
|
||||
|
||||
this.searchField = crelt("input", {
|
||||
value: query?.search ?? "",
|
||||
type: "text",
|
||||
placeholder: "Find",
|
||||
class: "find-input",
|
||||
"main-field": "true",
|
||||
onchange: this.commit,
|
||||
onkeyup: this.commit
|
||||
}) as HTMLInputElement;
|
||||
|
||||
this.replaceField = crelt("input", {
|
||||
value: query?.replace ?? "",
|
||||
type: "text",
|
||||
placeholder: "Replace",
|
||||
class: "replace-input",
|
||||
onchange: this.commit,
|
||||
onkeyup: this.commit
|
||||
}) as HTMLInputElement;
|
||||
|
||||
|
||||
const caseField = this.svgIcon("matchCase");
|
||||
caseField.className = "case-sensitive-toggle";
|
||||
caseField.title = "Match Case (Alt+C)";
|
||||
caseField.addEventListener("click", () => {
|
||||
this.toggleCase();
|
||||
});
|
||||
|
||||
const wordField = this.svgIcon("wholeWord");
|
||||
wordField.className = "whole-word-toggle";
|
||||
wordField.title = "Match Whole Word (Alt+W)";
|
||||
wordField.addEventListener("click", () => {
|
||||
this.toggleWord();
|
||||
});
|
||||
|
||||
|
||||
const reField = this.svgIcon("regex");
|
||||
reField.className = "regex-toggle";
|
||||
reField.title = "Use Regular Expression (Alt+R)";
|
||||
reField.addEventListener("click", () => {
|
||||
this.toggleRegex();
|
||||
});
|
||||
|
||||
const toggleReplaceIcon = this.svgIcon(this.replaceVisibile ? "downChevron" : "rightChevron");
|
||||
toggleReplaceIcon.className = "toggle-replace";
|
||||
toggleReplaceIcon.addEventListener("click", () => {
|
||||
this.toggleReplace();
|
||||
});
|
||||
|
||||
this.matchCountField = crelt("span", { class: "match-count" }, "0 of 0");
|
||||
|
||||
const prevMatchButton = this.svgIcon("prevMatch");
|
||||
prevMatchButton.className = "prev-match";
|
||||
prevMatchButton.title = "Previous Match (Shift+Enter)";
|
||||
prevMatchButton.addEventListener("click", () => {
|
||||
this.matchPrevious();
|
||||
});
|
||||
|
||||
const nextMatchButton = this.svgIcon("nextMatch");
|
||||
nextMatchButton.className = "next-match";
|
||||
nextMatchButton.title = "Next Match (Enter)";
|
||||
nextMatchButton.addEventListener("click", () => {
|
||||
this.matchNext();
|
||||
});
|
||||
|
||||
const closeButton = this.svgIcon("close");
|
||||
closeButton.className = "close";
|
||||
closeButton.title = "Close (Escape)";
|
||||
closeButton.addEventListener("click", () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
const replaceButton = this.svgIcon("replace");
|
||||
replaceButton.className = "replace-button";
|
||||
replaceButton.title = "Replace (Enter)";
|
||||
replaceButton.addEventListener("click", () => {
|
||||
this.replace();
|
||||
});
|
||||
|
||||
const replaceAllButton = this.svgIcon("replaceAll");
|
||||
replaceAllButton.className = "replace-button";
|
||||
replaceAllButton.title = "Replace All (Ctrl+Alt+Enter)";
|
||||
replaceAllButton.addEventListener("click", () => {
|
||||
this.replaceAll();
|
||||
});
|
||||
|
||||
const resizeHandle = crelt("div", { class: "resize-handle" });
|
||||
|
||||
const toggleSection = crelt("div", { class: "toggle-section" },
|
||||
resizeHandle,
|
||||
toggleReplaceIcon
|
||||
);
|
||||
|
||||
|
||||
|
||||
let startX: number;
|
||||
let startWidth: number;
|
||||
|
||||
const startResize = (e: MouseEvent) => {
|
||||
startX = e.clientX;
|
||||
startWidth = this.dom.offsetWidth;
|
||||
document.addEventListener('mousemove', resize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
const resize = (e: MouseEvent) => {
|
||||
const width = startWidth + (startX - e.clientX);
|
||||
const container = this.dom as HTMLDivElement;
|
||||
container.style.width = `${Math.max(420, Math.min(800, width))}px`;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
document.removeEventListener('mousemove', resize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
resizeHandle.addEventListener('mousedown', startResize);
|
||||
|
||||
const searchControls = crelt("div", { class: "search-controls" },
|
||||
caseField,
|
||||
wordField,
|
||||
reField
|
||||
);
|
||||
|
||||
const searchBar = crelt("div", { class: "search-bar" },
|
||||
this.searchField,
|
||||
searchControls
|
||||
);
|
||||
|
||||
const replaceBar = crelt("div", {
|
||||
class: "replace-bar",
|
||||
},
|
||||
this.replaceField
|
||||
);
|
||||
|
||||
replaceBar.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
|
||||
const inputSection = crelt("div", { class: "input-section" },
|
||||
searchBar,
|
||||
replaceBar
|
||||
);
|
||||
|
||||
const searchIcons = crelt("div", { class: "search-icons" },
|
||||
prevMatchButton,
|
||||
nextMatchButton,
|
||||
closeButton
|
||||
);
|
||||
|
||||
const searchButtons = crelt("div", { class: "button-group" },
|
||||
this.matchCountField,
|
||||
searchIcons
|
||||
);
|
||||
|
||||
const replaceButtons = crelt("div", {
|
||||
class: "replace-buttons",
|
||||
},
|
||||
replaceButton,
|
||||
replaceAllButton
|
||||
);
|
||||
|
||||
replaceButtons.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
|
||||
const actionSection = crelt("div", { class: "actions-section" },
|
||||
searchButtons,
|
||||
replaceButtons
|
||||
);
|
||||
|
||||
this.dom = crelt("div", {
|
||||
class: "find-replace-container",
|
||||
"data-keymap-scope": "search"
|
||||
},
|
||||
toggleSection,
|
||||
inputSection,
|
||||
actionSection
|
||||
);
|
||||
|
||||
// 根据当前状态设置按钮的active状态
|
||||
if (this.matchCase) {
|
||||
caseField.classList.add("active");
|
||||
}
|
||||
if (this.matchWord) {
|
||||
wordField.classList.add("active");
|
||||
}
|
||||
if (this.useRegex) {
|
||||
reField.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
setVisibility(visible: boolean) {
|
||||
this.dom.style.display = visible ? "flex" : "none";
|
||||
if (visible) {
|
||||
// 使用 setTimeout 确保DOM已经渲染
|
||||
setTimeout(() => {
|
||||
this.searchField.focus();
|
||||
this.searchField.select();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
mount() {
|
||||
this.searchField.select();
|
||||
}
|
||||
|
||||
destroy?(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
get pos() { return 80; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import {
|
||||
getSearchQuery,
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
findNext,
|
||||
findPrevious,
|
||||
replaceNext,
|
||||
replaceAll,
|
||||
closeSearchPanel,
|
||||
SearchCursor,
|
||||
RegExpCursor
|
||||
} from '@codemirror/search';
|
||||
|
||||
const props = defineProps<{ view: EditorView }>();
|
||||
|
||||
// State - options will be initialized from getSearchQuery (uses config defaults)
|
||||
const replaceVisible = ref(false);
|
||||
const searchText = ref('');
|
||||
const replaceText = ref('');
|
||||
const matchCase = ref(false); // Will be set from query in onMounted
|
||||
const matchWord = ref(false); // Will be set from query in onMounted
|
||||
const useRegex = ref(false); // Will be set from query in onMounted
|
||||
const hasError = ref(false);
|
||||
const totalMatches = ref(0);
|
||||
const currentMatchIndex = ref(0);
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// Computed
|
||||
const matchCountText = computed(() => {
|
||||
if (hasError.value) return 'Invalid regex';
|
||||
if (!searchText.value || totalMatches.value === 0) return 'No results';
|
||||
return `${currentMatchIndex.value} of ${totalMatches.value}`;
|
||||
});
|
||||
|
||||
const hasMatches = computed(() => totalMatches.value > 0);
|
||||
const canReplace = computed(() => searchText.value.length > 0 && hasMatches.value);
|
||||
|
||||
// Core functions
|
||||
function commit() {
|
||||
try {
|
||||
const query = new SearchQuery({
|
||||
search: searchText.value,
|
||||
replace: replaceText.value,
|
||||
caseSensitive: matchCase.value,
|
||||
regexp: useRegex.value,
|
||||
wholeWord: matchWord.value,
|
||||
});
|
||||
props.view.dispatch({ effects: setSearchQuery.of(query) });
|
||||
hasError.value = false;
|
||||
updateMatchCount();
|
||||
} catch {
|
||||
hasError.value = true;
|
||||
totalMatches.value = currentMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMatchCount() {
|
||||
const query = getSearchQuery(props.view.state);
|
||||
if (!query.search) {
|
||||
totalMatches.value = currentMatchIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cursorPos = props.view.state.selection.main.from;
|
||||
let total = 0, current = 0, foundCurrent = false;
|
||||
|
||||
const cursor = query.regexp
|
||||
? new RegExpCursor(props.view.state.doc, query.search, { ignoreCase: !query.caseSensitive })
|
||||
: new SearchCursor(props.view.state.doc, query.search, 0, props.view.state.doc.length,
|
||||
query.caseSensitive ? undefined : (s: string) => s.toLowerCase());
|
||||
|
||||
while (!cursor.next().done) {
|
||||
total++;
|
||||
if (!foundCurrent && cursor.value.from >= cursorPos) {
|
||||
current = total;
|
||||
foundCurrent = true;
|
||||
}
|
||||
if (total >= 9999) break;
|
||||
}
|
||||
|
||||
totalMatches.value = total;
|
||||
currentMatchIndex.value = foundCurrent ? current : Math.min(1, total);
|
||||
} catch {
|
||||
totalMatches.value = currentMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Actions - scrollToMatch is handled by search config in plugin.ts
|
||||
const doFindNext = () => { findNext(props.view); updateMatchCount(); };
|
||||
const doFindPrevious = () => { findPrevious(props.view); updateMatchCount(); };
|
||||
const doReplace = () => { if (canReplace.value) { replaceNext(props.view); updateMatchCount(); } };
|
||||
const doReplaceAll = () => { if (canReplace.value) { replaceAll(props.view); updateMatchCount(); } };
|
||||
|
||||
const toggleOption = (opt: 'case' | 'word' | 'regex') => {
|
||||
const map = { case: matchCase, word: matchWord, regex: useRegex };
|
||||
map[opt].value = !map[opt].value;
|
||||
commit();
|
||||
};
|
||||
|
||||
const close = () => closeSearchPanel(props.view);
|
||||
|
||||
// Keyboard handlers
|
||||
const onSearchKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? doFindPrevious() : doFindNext(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
};
|
||||
|
||||
const onReplaceKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); (e.ctrlKey || e.metaKey) ? doReplaceAll() : doReplace(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
};
|
||||
|
||||
// Resize
|
||||
let resizeState = { startX: 0, startWidth: 0 };
|
||||
const onResize = (e: MouseEvent) => {
|
||||
if (!containerRef.value) return;
|
||||
containerRef.value.style.width = `${Math.max(411, Math.min(800, resizeState.startWidth + resizeState.startX - e.clientX))}px`;
|
||||
};
|
||||
const stopResize = () => { document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', stopResize); };
|
||||
const startResize = (e: MouseEvent) => {
|
||||
resizeState = { startX: e.clientX, startWidth: containerRef.value?.offsetWidth ?? 411 };
|
||||
document.addEventListener('mousemove', onResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
// Watch for input changes
|
||||
watch([searchText, replaceText], commit);
|
||||
|
||||
// Init - read options from query (defaults from search config), pre-populate search text
|
||||
onMounted(() => {
|
||||
// Always read options from query - this uses defaults from search() config
|
||||
const query = getSearchQuery(props.view.state);
|
||||
matchCase.value = query.caseSensitive;
|
||||
matchWord.value = query.wholeWord;
|
||||
useRegex.value = query.regexp;
|
||||
|
||||
// Pre-populate search/replace text
|
||||
if (query?.search) {
|
||||
searchText.value = query.search;
|
||||
replaceText.value = query.replace;
|
||||
} else {
|
||||
// Pre-populate from selection if no existing search
|
||||
const { main } = props.view.state.selection;
|
||||
if (!main.empty) {
|
||||
const text = props.view.state.doc.sliceString(main.from, main.to);
|
||||
if (!text.includes('\n') && text.length < 200) searchText.value = text;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus input
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
searchInput.value?.select();
|
||||
if (searchText.value) commit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="search-panel" @keydown.esc="close">
|
||||
<div class="resize-handle" @mousedown="startResize" />
|
||||
|
||||
<div class="toggle-section">
|
||||
<div class="toggle-replace" @click="replaceVisible = !replaceVisible" :title="replaceVisible ? 'Hide Replace' : 'Show Replace'">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path v-if="replaceVisible" fill-rule="evenodd" clip-rule="evenodd" d="M7.976 10.072l4.357-4.357.62.618L8.284 11h-.618L3 6.333l.619-.618 4.357 4.357z"/>
|
||||
<path v-else fill-rule="evenodd" clip-rule="evenodd" d="M10.072 8.024L5.715 3.667l.618-.62L11 7.716v.618L6.333 13l-.618-.619 4.357-4.357z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-section">
|
||||
<div class="search-bar">
|
||||
<input ref="searchInput" v-model="searchText" type="text" class="find-input" :class="{ error: hasError }" placeholder="Find" @keydown="onSearchKeydown" />
|
||||
<div class="search-controls">
|
||||
<div class="control-btn" :class="{ active: matchCase }" title="Match Case (Alt+C)" @click="toggleOption('case')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z"/><path d="M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z"/></svg>
|
||||
</div>
|
||||
<div class="control-btn" :class="{ active: matchWord }" title="Match Whole Word (Alt+W)" @click="toggleOption('word')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>
|
||||
</div>
|
||||
<div class="control-btn" :class="{ active: useRegex }" title="Use Regular Expression (Alt+R)" @click="toggleOption('regex')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="replaceVisible" class="replace-bar">
|
||||
<input v-model="replaceText" type="text" class="replace-input" placeholder="Replace" @keydown="onReplaceKeydown" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-section">
|
||||
<div class="button-group">
|
||||
<span class="match-count">{{ matchCountText }}</span>
|
||||
<div class="search-icons">
|
||||
<div class="icon-btn" :class="{ disabled: !hasMatches }" title="Previous Match (Shift+Enter)" @click="doFindPrevious">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.854 7l-5-5h-.707l-5 5 .707.707L8 3.561V14h1V3.56l4.146 4.147.708-.707z"/></svg>
|
||||
</div>
|
||||
<div class="icon-btn" :class="{ disabled: !hasMatches }" title="Next Match (Enter)" @click="doFindNext">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.147 9l5 5h.707l5-5-.707-.707L9 12.439V2H8v10.44L3.854 8.292 3.147 9z"/></svg>
|
||||
</div>
|
||||
<div class="icon-btn" title="Close (Escape)" @click="close">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="replaceVisible" class="replace-buttons">
|
||||
<div class="icon-btn" :class="{ disabled: !canReplace }" title="Replace (Enter)" @click="doReplace">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z"/></svg>
|
||||
</div>
|
||||
<div class="icon-btn" :class="{ disabled: !canReplace }" title="Replace All (Ctrl+Enter)" @click="doReplaceAll">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 14px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
min-width: 411px;
|
||||
max-width: calc(100% - 28px);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.36);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--search-panel-bg);
|
||||
color: var(--search-panel-text);
|
||||
border: 1px solid var(--search-panel-border);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
&:hover { background-color: var(--search-focus-border); }
|
||||
}
|
||||
|
||||
.toggle-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 3px 2px 3px 3px;
|
||||
}
|
||||
|
||||
.toggle-replace {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
&:hover { background-color: var(--search-btn-hover); }
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.search-bar { display: flex; position: relative; }
|
||||
|
||||
.find-input, .replace-input {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
padding: 3px 70px 3px 6px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
box-sizing: border-box;
|
||||
background: var(--search-input-bg);
|
||||
color: var(--search-input-text);
|
||||
border: 1px solid var(--search-input-border);
|
||||
&:focus {
|
||||
border-color: var(--search-focus-border);
|
||||
outline: 1px solid var(--search-focus-border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
&.error {
|
||||
border-color: var(--search-error-border) !important;
|
||||
background-color: var(--search-error-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.replace-input { padding: 3px 6px; }
|
||||
.replace-bar { display: flex; }
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
svg { width: 16px; height: 16px; }
|
||||
&:hover { background-color: var(--search-btn-hover); }
|
||||
&.active {
|
||||
background-color: var(--search-btn-active-bg);
|
||||
color: var(--search-btn-active-text);
|
||||
border-color: var(--search-focus-border);
|
||||
svg { fill: var(--search-btn-active-text); }
|
||||
}
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.search-icons, .replace-buttons { display: flex; gap: 1px; }
|
||||
.replace-buttons { height: 24px; }
|
||||
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
svg { width: 16px; height: 16px; }
|
||||
&:hover { background-color: var(--search-btn-hover); }
|
||||
&.disabled { opacity: 0.4; pointer-events: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Command } from "@codemirror/view";
|
||||
import { simulateBackspace } from "./utility";
|
||||
import { cursorCharLeft, cursorCharRight, deleteCharBackward, deleteCharForward } from "@codemirror/commands";
|
||||
import { SearchVisibilityEffect } from "./state";
|
||||
import { VSCodeSearch } from "./plugin";
|
||||
|
||||
const isSearchActive = () : boolean => {
|
||||
if (document.activeElement){
|
||||
return document.activeElement.classList.contains('find-input');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isReplaceActive = () : boolean => {
|
||||
if (document.activeElement){
|
||||
return document.activeElement.classList.contains('replace-input');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const selectAllCommand: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
(document.activeElement as HTMLInputElement).select();
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
view.dispatch({
|
||||
selection: { anchor: 0, head: view.state.doc.length }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteCharacterBackwards: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
simulateBackspace(document.activeElement as HTMLInputElement);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
deleteCharBackward(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteCharacterFowards: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
simulateBackspace(document.activeElement as HTMLInputElement, "forward");
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
deleteCharForward(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const showSearchVisibilityCommand: Command = (view) => {
|
||||
view.dispatch({
|
||||
effects: SearchVisibilityEffect.of(true) // Dispatch the effect to show the search
|
||||
});
|
||||
|
||||
// 延迟聚焦,确保DOM已经更新
|
||||
setTimeout(() => {
|
||||
const searchInput = view.dom.querySelector('.find-input') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
}, 10);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchMoveCursorLeft: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
const input = document.activeElement as HTMLInputElement;
|
||||
const pos = input.selectionStart ?? 0;
|
||||
if (pos > 0) {
|
||||
input.selectionStart = input.selectionEnd = pos - 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
cursorCharLeft(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const searchMoveCursorRight: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
const input = document.activeElement as HTMLInputElement;
|
||||
const pos = input.selectionStart ?? 0;
|
||||
if (pos < input.value.length) {
|
||||
input.selectionStart = input.selectionEnd = pos + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
cursorCharRight(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const hideSearchVisibilityCommand: Command = (view) => {
|
||||
view.dispatch({
|
||||
effects: SearchVisibilityEffect.of(false) // Dispatch the effect to hide the search
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchToggleCase: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.toggleCaseInsensitive();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchToggleWholeWord: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.toggleWholeWord();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchToggleRegex: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.toggleRegex();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchShowReplace: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.showReplace();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchFindReplaceMatch: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.findReplaceMatch();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchFindPrevious: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.findPrevious();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchReplaceAll: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.replaceAll();
|
||||
return true;
|
||||
};
|
||||
@@ -1,4 +1 @@
|
||||
export { VSCodeSearch, vscodeSearch} from "./plugin";
|
||||
export { searchVisibilityField, SearchVisibilityEffect } from "./state";
|
||||
export { searchBaseTheme } from "./theme";
|
||||
export * from "./commands";
|
||||
export { vscodeSearch, VSCodeSearch } from "./plugin";
|
||||
@@ -1,80 +1,64 @@
|
||||
import { getSearchQuery, search, SearchQuery } from "@codemirror/search";
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
||||
import { CustomSearchPanel } from "./FindReplaceControl";
|
||||
import { SearchVisibilityEffect } from "./state";
|
||||
import { searchBaseTheme } from "./theme";
|
||||
import { search } from "@codemirror/search";
|
||||
import { EditorView, Panel } from "@codemirror/view";
|
||||
import { StateEffect } from "@codemirror/state";
|
||||
import { createApp, App } from "vue";
|
||||
import SearchPanel from "./SearchPanel.vue";
|
||||
|
||||
|
||||
export class SearchPlugin {
|
||||
private searchControl: CustomSearchPanel;
|
||||
private prevQuery: SearchQuery | null = null;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.searchControl = new CustomSearchPanel(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const currentQuery = getSearchQuery(update.state);
|
||||
if (!this.prevQuery || !currentQuery.eq(this.prevQuery)) {
|
||||
this.searchControl.findMatchesAndSelectClosest(update.state);
|
||||
/**
|
||||
* Create custom search panel using Vue component
|
||||
* This integrates directly with CodeMirror's search extension
|
||||
*/
|
||||
function createSearchPanel(view: EditorView): Panel {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "vscode-search-container";
|
||||
|
||||
let app: App | null = null;
|
||||
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
mount() {
|
||||
// Mount Vue component after panel is added to DOM
|
||||
app = createApp(SearchPanel, { view });
|
||||
app.mount(dom);
|
||||
},
|
||||
destroy() {
|
||||
// Cleanup Vue component
|
||||
app?.unmount();
|
||||
app = null;
|
||||
}
|
||||
this.prevQuery = currentQuery;
|
||||
|
||||
for (const tr of update.transactions) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(SearchVisibilityEffect)) {
|
||||
this.searchControl.setVisibility(e.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.searchControl.dom.remove(); // Clean up
|
||||
}
|
||||
|
||||
toggleCaseInsensitive() {
|
||||
this.searchControl.toggleCase();
|
||||
}
|
||||
|
||||
toggleWholeWord() {
|
||||
this.searchControl.toggleWord();
|
||||
}
|
||||
|
||||
toggleRegex() {
|
||||
this.searchControl.toggleRegex();
|
||||
}
|
||||
|
||||
showReplace() {
|
||||
this.searchControl.setVisibility(true);
|
||||
this.searchControl.showReplace();
|
||||
}
|
||||
|
||||
findReplaceMatch() {
|
||||
this.searchControl.findReplaceMatch();
|
||||
}
|
||||
|
||||
findNext() {
|
||||
this.searchControl.matchNext();
|
||||
}
|
||||
|
||||
replace() {
|
||||
this.searchControl.replace();
|
||||
}
|
||||
|
||||
replaceAll() {
|
||||
this.searchControl.replaceAll();
|
||||
}
|
||||
|
||||
findPrevious() {
|
||||
this.searchControl.matchPrevious();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const VSCodeSearch = ViewPlugin.fromClass(SearchPlugin);
|
||||
/**
|
||||
* Custom scroll behavior - scroll match to center of viewport
|
||||
* This is called automatically by findNext/findPrevious
|
||||
*/
|
||||
function scrollMatchToCenter(range: { from: number }, view: EditorView): StateEffect<unknown> {
|
||||
return EditorView.scrollIntoView(range.from, { y: 'center' });
|
||||
}
|
||||
|
||||
/**
|
||||
* VSCode-style search extension
|
||||
* Uses CodeMirror's built-in search with custom Vue UI
|
||||
*
|
||||
* Config options set default values for search query:
|
||||
* - caseSensitive: false (default) - match case
|
||||
* - wholeWord: false (default) - match whole word
|
||||
* - regexp: false (default) - use regular expression
|
||||
* - literal: false (default) - literal string search
|
||||
*/
|
||||
export const vscodeSearch = [
|
||||
search({}),
|
||||
VSCodeSearch,
|
||||
searchBaseTheme
|
||||
];
|
||||
search({
|
||||
createPanel: createSearchPanel,
|
||||
top: true,
|
||||
scrollToMatch: scrollMatchToCenter,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
regexp: false,
|
||||
literal: false,
|
||||
}),
|
||||
];
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { vscodeSearch as VSCodeSearch };
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { StateEffect, StateField } from "@codemirror/state";
|
||||
|
||||
// Define an effect to update the visibility state
|
||||
export const SearchVisibilityEffect = StateEffect.define<boolean>();
|
||||
|
||||
// Create a state field to store the visibility state
|
||||
export const searchVisibilityField = StateField.define({
|
||||
create() {
|
||||
return false;
|
||||
},
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(SearchVisibilityEffect)) {
|
||||
return e.value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
type Theme = {
|
||||
[key: string]: {
|
||||
[property: string]: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
const sharedTheme: Theme = {
|
||||
".cm-editor": {
|
||||
position: "relative",
|
||||
overflow: "visible",
|
||||
},
|
||||
".find-replace-container": {
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 2px 8px rgba(34, 33, 33, 0.25)",
|
||||
top: "10px",
|
||||
right: "20px",
|
||||
position: "absolute !important",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
minWidth: "420px",
|
||||
maxWidth: "calc(100% - 30px)",
|
||||
display: "flex",
|
||||
height: "auto",
|
||||
zIndex: "9999",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
".resize-handle": {
|
||||
width: "4px",
|
||||
background: "transparent",
|
||||
cursor: "col-resize",
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
top: "0",
|
||||
bottom: "0",
|
||||
},
|
||||
".resize-handle:hover": {
|
||||
background: "#007acc",
|
||||
},
|
||||
".toggle-section": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "8px 4px",
|
||||
position: "relative",
|
||||
flex: "0 0 auto"
|
||||
},
|
||||
".toggle-replace": {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0",
|
||||
width: "15px",
|
||||
height: "100%",
|
||||
},
|
||||
".inputs-section": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
padding: "8px 0",
|
||||
minWidth: "0",
|
||||
},
|
||||
".input-row": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "24px",
|
||||
},
|
||||
".input-section": {
|
||||
alignContent: "center",
|
||||
flex: "1 1 auto"
|
||||
},
|
||||
".input-container": {
|
||||
position: "relative",
|
||||
flex: "1",
|
||||
minWidth: "0",
|
||||
},
|
||||
".search-bar": {
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
margin: "2px",
|
||||
},
|
||||
".find-input, .replace-input": {
|
||||
width: "100%",
|
||||
borderRadius: "4px",
|
||||
padding: "4px 80px 4px 8px",
|
||||
outline: "none",
|
||||
fontSize: "13px",
|
||||
height: "24px",
|
||||
},
|
||||
".replace-input": {
|
||||
padding: "4px 8px 4px 8px",
|
||||
},
|
||||
".find-input:focus, .replace-input:focus": {
|
||||
boxShadow: "none"
|
||||
},
|
||||
".search-controls": {
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
right: "10px",
|
||||
top: "10%"
|
||||
},
|
||||
".search-controls div": {
|
||||
borderRadius: "5px",
|
||||
alignContent: "center",
|
||||
margin: "2px 3px",
|
||||
cursor: "pointer",
|
||||
padding: "2px 4px",
|
||||
border: "1px solid transparent",
|
||||
transition: "all 0.2s ease",
|
||||
},
|
||||
".search-controls svg": {
|
||||
margin: "0px 2px"
|
||||
},
|
||||
".actions-section": {
|
||||
alignContent: "center",
|
||||
marginRight: "10px",
|
||||
flex: "0 0 auto"
|
||||
},
|
||||
".button-group": {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
height: "24px",
|
||||
alignContent: "center",
|
||||
},
|
||||
".search-icons": {
|
||||
display: "flex",
|
||||
},
|
||||
".search-icons div": {
|
||||
cursor: "pointer",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
".replace-bar": {
|
||||
margin: "2px",
|
||||
},
|
||||
".replace-buttons": {
|
||||
display: "flex",
|
||||
height: "24px",
|
||||
},
|
||||
".replace-button": {
|
||||
border: "none",
|
||||
padding: "4px 4px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
height: "24px",
|
||||
},
|
||||
".match-count": {
|
||||
fontSize: "12px",
|
||||
marginLeft: "8px",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
".search-options": {
|
||||
position: "absolute",
|
||||
right: "4px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2px",
|
||||
},
|
||||
};
|
||||
|
||||
const lightTheme: Theme = {
|
||||
".find-replace-container": {
|
||||
backgroundColor: "var(--cm-background, #f3f3f3)",
|
||||
color: "var(--cm-foreground, #454545)",
|
||||
border: "1px solid var(--cm-caret, #d4d4d4)",
|
||||
},
|
||||
".toggle-replace:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)",
|
||||
},
|
||||
".find-input, .replace-input": {
|
||||
background: "var(--cm-gutter-background, #ffffff)",
|
||||
color: "var(--cm-foreground, #454545)",
|
||||
border: "1px solid var(--cm-gutter-foreground, #e1e1e1)",
|
||||
},
|
||||
".find-input:focus, .replace-input:focus": {
|
||||
borderColor: "var(--cm-caret, #1e51db)",
|
||||
},
|
||||
".find-input.error": {
|
||||
borderColor: "#ff4444 !important",
|
||||
backgroundColor: "#fff5f5 !important",
|
||||
},
|
||||
".search-controls div:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)"
|
||||
},
|
||||
".search-controls div.active": {
|
||||
backgroundColor: "#007acc !important",
|
||||
color: "#ffffff !important",
|
||||
border: "1px solid #007acc !important"
|
||||
},
|
||||
".search-controls div.active svg": {
|
||||
fill: "#ffffff !important"
|
||||
},
|
||||
".search-icons div:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)"
|
||||
},
|
||||
".replace-button:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)"
|
||||
},
|
||||
};
|
||||
|
||||
const darkTheme = {
|
||||
".find-replace-container": {
|
||||
backgroundColor: "var(--cm-background, #252526)",
|
||||
color: "var(--cm-foreground, #c4c4c4)",
|
||||
border: "1px solid var(--cm-caret, #454545)",
|
||||
},
|
||||
".toggle-replace:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)",
|
||||
},
|
||||
".find-input, .replace-input": {
|
||||
background: "var(--cm-gutter-background, #3c3c3c)",
|
||||
color: "var(--cm-foreground, #b4b4b4)",
|
||||
border: "1px solid var(--cm-gutter-foreground, #3c3c3c)",
|
||||
},
|
||||
".find-input:focus, .replace-input:focus": {
|
||||
borderColor: "var(--cm-caret, #1e51db)",
|
||||
},
|
||||
".find-input.error": {
|
||||
borderColor: "#ff6b6b !important",
|
||||
backgroundColor: "#3d2626 !important",
|
||||
},
|
||||
".search-controls div:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)"
|
||||
},
|
||||
".search-controls div.active": {
|
||||
backgroundColor: "#007acc !important",
|
||||
color: "#ffffff !important",
|
||||
border: "1px solid #007acc !important"
|
||||
},
|
||||
".search-controls div.active svg": {
|
||||
fill: "#ffffff !important"
|
||||
},
|
||||
".search-icons div:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)"
|
||||
},
|
||||
".replace-button:hover": {
|
||||
backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)"
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const prependThemeSelector = (theme: Theme, selector: string): Theme => {
|
||||
const updatedTheme : Theme= {};
|
||||
|
||||
Object.keys(theme).forEach( (key) => {
|
||||
|
||||
const updatedKey = key.split(',').map(part => `${selector} ${part.trim()}`).join(', ');
|
||||
// Prepend the selector to each key and assign the original style
|
||||
updatedTheme[updatedKey] = theme[key];
|
||||
});
|
||||
|
||||
return updatedTheme;
|
||||
};
|
||||
|
||||
export const searchBaseTheme = EditorView.baseTheme({
|
||||
...sharedTheme,
|
||||
...prependThemeSelector(lightTheme, "&light"),
|
||||
...prependThemeSelector(darkTheme, "&dark"),
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
export function simulateBackspace(input: HTMLInputElement, direction: "backward" | "forward" = "backward") {
|
||||
let start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
|
||||
// Do nothing if at boundaries
|
||||
if (direction === "backward" && start === 0 && end === 0) return;
|
||||
if (direction === "forward" && start === input.value.length && end === input.value.length) return;
|
||||
|
||||
if (start === end) {
|
||||
// No selection - simulate Backspace or Delete
|
||||
if (direction === "backward") {
|
||||
input.value = input.value.slice(0, start - 1) + input.value.slice(end);
|
||||
start -= 1;
|
||||
} else {
|
||||
input.value = input.value.slice(0, start) + input.value.slice(end + 1);
|
||||
}
|
||||
input.selectionStart = input.selectionEnd = start;
|
||||
} else {
|
||||
// Text is selected, remove selection regardless of direction
|
||||
input.value = input.value.slice(0, start) + input.value.slice(end);
|
||||
input.selectionStart = input.selectionEnd = start;
|
||||
}
|
||||
|
||||
// Dispatch input event to notify listeners
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
import {KeyBindingCommand} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {
|
||||
hideSearchVisibilityCommand,
|
||||
searchReplaceAll,
|
||||
searchShowReplace,
|
||||
searchToggleCase,
|
||||
searchToggleRegex,
|
||||
searchToggleWholeWord,
|
||||
showSearchVisibilityCommand
|
||||
} from '../extensions/vscodeSearch/commands';
|
||||
openSearchPanel,
|
||||
closeSearchPanel,
|
||||
} from '@codemirror/search';
|
||||
import {
|
||||
addNewBlockAfterCurrent,
|
||||
addNewBlockAfterLast,
|
||||
@@ -26,7 +21,6 @@ import {deleteLineCommand} from '../extensions/codeblock/deleteLine';
|
||||
import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines';
|
||||
import {transposeChars} from '../extensions/codeblock';
|
||||
import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste';
|
||||
import {textHighlightToggleCommand} from '../extensions/textHighlight';
|
||||
import {
|
||||
copyLineDown,
|
||||
copyLineUp,
|
||||
@@ -68,34 +62,13 @@ const defaultEditorOptions = {
|
||||
*/
|
||||
export const commands = {
|
||||
[KeyBindingCommand.ShowSearchCommand]: {
|
||||
handler: showSearchVisibilityCommand,
|
||||
handler: openSearchPanel,
|
||||
descriptionKey: 'keybindings.commands.showSearch'
|
||||
},
|
||||
[KeyBindingCommand.HideSearchCommand]: {
|
||||
handler: hideSearchVisibilityCommand,
|
||||
handler: closeSearchPanel,
|
||||
descriptionKey: 'keybindings.commands.hideSearch'
|
||||
},
|
||||
[KeyBindingCommand.SearchToggleCaseCommand]: {
|
||||
handler: searchToggleCase,
|
||||
descriptionKey: 'keybindings.commands.searchToggleCase'
|
||||
},
|
||||
[KeyBindingCommand.SearchToggleWordCommand]: {
|
||||
handler: searchToggleWholeWord,
|
||||
descriptionKey: 'keybindings.commands.searchToggleWord'
|
||||
},
|
||||
[KeyBindingCommand.SearchToggleRegexCommand]: {
|
||||
handler: searchToggleRegex,
|
||||
descriptionKey: 'keybindings.commands.searchToggleRegex'
|
||||
},
|
||||
[KeyBindingCommand.SearchShowReplaceCommand]: {
|
||||
handler: searchShowReplace,
|
||||
descriptionKey: 'keybindings.commands.searchShowReplace'
|
||||
},
|
||||
[KeyBindingCommand.SearchReplaceAllCommand]: {
|
||||
handler: searchReplaceAll,
|
||||
descriptionKey: 'keybindings.commands.searchReplaceAll'
|
||||
},
|
||||
|
||||
// 代码块操作命令
|
||||
[KeyBindingCommand.BlockSelectAllCommand]: {
|
||||
handler: selectAll,
|
||||
@@ -285,12 +258,6 @@ export const commands = {
|
||||
handler: deleteGroupForward,
|
||||
descriptionKey: 'keybindings.commands.deleteGroupForward'
|
||||
},
|
||||
|
||||
// 文本高亮扩展命令
|
||||
[KeyBindingCommand.TextHighlightToggleCommand]: {
|
||||
handler: textHighlightToggleCommand,
|
||||
descriptionKey: 'keybindings.commands.textHighlightToggle'
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,16 +2,19 @@ import {Manager} from './manager';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import i18n from '@/i18n';
|
||||
import {ExtensionDefinition} from './types';
|
||||
import {Prec} from '@codemirror/state';
|
||||
|
||||
import index from '../extensions/rainbowBracket';
|
||||
import {createTextHighlighter} from '../extensions/textHighlight';
|
||||
import rainbowBrackets from '../extensions/rainbowBracket';
|
||||
import {color} from '../extensions/colorSelector';
|
||||
import {hyperLink} from '../extensions/hyperlink';
|
||||
import {minimap} from '../extensions/minimap';
|
||||
import {vscodeSearch} from '../extensions/vscodeSearch';
|
||||
import {createCheckboxExtension} from '../extensions/checkbox';
|
||||
import {createTranslatorExtension} from '../extensions/translator';
|
||||
import {foldingOnIndent} from '../extensions/fold/foldExtension';
|
||||
import markdownExtensions from '../extensions/markdown';
|
||||
import {foldGutter} from "@codemirror/language";
|
||||
import {highlightActiveLineGutter, highlightWhitespace, highlightTrailingWhitespace} from "@codemirror/view";
|
||||
import createEditorContextMenu from '../extensions/contextMenu';
|
||||
import {blockLineNumbers} from '../extensions/codeblock';
|
||||
|
||||
type ExtensionEntry = {
|
||||
definition: ExtensionDefinition
|
||||
@@ -28,7 +31,7 @@ const defineExtension = (create: (config: any) => any, defaultConfig: Record<str
|
||||
|
||||
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||
[ExtensionID.ExtensionRainbowBrackets]: {
|
||||
definition: defineExtension(() => index()),
|
||||
definition: defineExtension(() => rainbowBrackets()),
|
||||
displayNameKey: 'extensions.rainbowBrackets.name',
|
||||
descriptionKey: 'extensions.rainbowBrackets.description'
|
||||
},
|
||||
@@ -66,25 +69,34 @@ const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||
descriptionKey: 'extensions.search.description'
|
||||
},
|
||||
[ExtensionID.ExtensionFold]: {
|
||||
definition: defineExtension(() => foldingOnIndent),
|
||||
definition: defineExtension(() => Prec.low(foldGutter())),
|
||||
displayNameKey: 'extensions.fold.name',
|
||||
descriptionKey: 'extensions.fold.description'
|
||||
},
|
||||
[ExtensionID.ExtensionTextHighlight]: {
|
||||
definition: defineExtension((config: any) => createTextHighlighter({
|
||||
backgroundColor: config?.backgroundColor ?? '#FFD700',
|
||||
opacity: config?.opacity ?? 0.3
|
||||
}), {
|
||||
backgroundColor: '#FFD700',
|
||||
opacity: 0.3
|
||||
}),
|
||||
displayNameKey: 'extensions.textHighlight.name',
|
||||
descriptionKey: 'extensions.textHighlight.description'
|
||||
[ExtensionID.ExtensionMarkdown]: {
|
||||
definition: defineExtension(() => markdownExtensions),
|
||||
displayNameKey: 'extensions.markdown.name',
|
||||
descriptionKey: 'extensions.markdown.description'
|
||||
},
|
||||
[ExtensionID.ExtensionCheckbox]: {
|
||||
definition: defineExtension(() => createCheckboxExtension()),
|
||||
displayNameKey: 'extensions.checkbox.name',
|
||||
descriptionKey: 'extensions.checkbox.description'
|
||||
[ExtensionID.ExtensionLineNumbers]: {
|
||||
definition: defineExtension(() => Prec.high([blockLineNumbers, highlightActiveLineGutter()])),
|
||||
displayNameKey: 'extensions.lineNumbers.name',
|
||||
descriptionKey: 'extensions.lineNumbers.description'
|
||||
},
|
||||
[ExtensionID.ExtensionContextMenu]: {
|
||||
definition: defineExtension(() => createEditorContextMenu()),
|
||||
displayNameKey: 'extensions.contextMenu.name',
|
||||
descriptionKey: 'extensions.contextMenu.description'
|
||||
},
|
||||
[ExtensionID.ExtensionHighlightWhitespace]: {
|
||||
definition: defineExtension(() => highlightWhitespace()),
|
||||
displayNameKey: 'extensions.highlightWhitespace.name',
|
||||
descriptionKey: 'extensions.highlightWhitespace.description'
|
||||
},
|
||||
[ExtensionID.ExtensionHighlightTrailingWhitespace]: {
|
||||
definition: defineExtension(() => highlightTrailingWhitespace()),
|
||||
displayNameKey: 'extensions.highlightTrailingWhitespace.name',
|
||||
descriptionKey: 'extensions.highlightTrailingWhitespace.description'
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -62,6 +62,17 @@ export function createBaseTheme(colors: ThemeColors): Extension {
|
||||
outline: `0.5px solid ${colors.matchingBracket}`,
|
||||
},
|
||||
|
||||
// 搜索匹配
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: `${colors.searchMatch} !important`,
|
||||
borderRadius: '2px',
|
||||
},
|
||||
'.cm-searchMatch-selected': {
|
||||
backgroundColor: `${colors.searchMatchSelected} !important`,
|
||||
outline: `1px solid ${colors.searchMatchSelectedOutline}`,
|
||||
borderRadius: '2px',
|
||||
},
|
||||
|
||||
// 代码块层(自定义)
|
||||
'.code-blocks-layer': {
|
||||
width: '100%',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#3b334b',
|
||||
matchingBracket: '#a394f033',
|
||||
|
||||
// 搜索匹配 - Aura 紫青色调
|
||||
searchMatch: 'rgba(162, 119, 255, 0.4)',
|
||||
searchMatchSelected: 'rgba(97, 255, 202, 0.45)',
|
||||
searchMatchSelectedOutline: '#61ffca',
|
||||
|
||||
comment: '#6d6d6d',
|
||||
lineComment: '#5c5c5c',
|
||||
blockComment: '#5a5a5a',
|
||||
|
||||
@@ -22,6 +22,11 @@ export const defaultDarkColors: ThemeColors = {
|
||||
borderColor: '#1e222a',
|
||||
matchingBracket: '#ffffff19',
|
||||
|
||||
// 搜索匹配 - 金黄色调
|
||||
searchMatch: 'rgba(250, 220, 81, 0.7)',
|
||||
searchMatchSelected: 'rgba(255, 140, 0, 0.85)',
|
||||
searchMatchSelectedOutline: '#ff6600',
|
||||
|
||||
// 语法标签色值
|
||||
comment: '#6272a4',
|
||||
lineComment: '#5c6b99',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#191a21',
|
||||
matchingBracket: '#44475a',
|
||||
|
||||
// 搜索匹配 - Dracula 紫粉色调
|
||||
searchMatch: 'rgba(189, 147, 249, 0.45)',
|
||||
searchMatchSelected: 'rgba(255, 121, 198, 0.65)',
|
||||
searchMatchSelectedOutline: '#ff79c6',
|
||||
|
||||
comment: '#6272a4',
|
||||
lineComment: '#55608c',
|
||||
blockComment: '#4f597f',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#1b1f23',
|
||||
matchingBracket: '#17e5e650',
|
||||
|
||||
// 搜索匹配 - GitHub 蓝色调
|
||||
searchMatch: 'rgba(121, 184, 255, 0.4)',
|
||||
searchMatchSelected: 'rgba(51, 146, 255, 0.6)',
|
||||
searchMatchSelectedOutline: '#58a6ff',
|
||||
|
||||
comment: '#6a737d',
|
||||
lineComment: '#596068',
|
||||
blockComment: '#4f555c',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#ffffff10',
|
||||
matchingBracket: '#263238',
|
||||
|
||||
// 搜索匹配 - Material 青绿色调
|
||||
searchMatch: 'rgba(137, 221, 255, 0.4)',
|
||||
searchMatchSelected: 'rgba(130, 170, 255, 0.55)',
|
||||
searchMatchSelectedOutline: '#82aaff',
|
||||
|
||||
comment: '#546e7a',
|
||||
lineComment: '#4b606a',
|
||||
blockComment: '#455962',
|
||||
|
||||
@@ -34,6 +34,11 @@ export const config: ThemeColors = {
|
||||
borderColor: darkBackground,
|
||||
matchingBracket: '#bad0f847',
|
||||
|
||||
// 搜索匹配 - One Dark 蓝橙色调
|
||||
searchMatch: 'rgba(97, 175, 239, 0.4)',
|
||||
searchMatchSelected: 'rgba(229, 192, 123, 0.55)',
|
||||
searchMatchSelectedOutline: '#e5c07b',
|
||||
|
||||
comment: stone,
|
||||
lineComment: '#6c7484',
|
||||
blockComment: '#606775',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#073642',
|
||||
matchingBracket: '#073642',
|
||||
|
||||
// 搜索匹配 - Solarized 黄橙色调
|
||||
searchMatch: 'rgba(181, 137, 0, 0.45)',
|
||||
searchMatchSelected: 'rgba(203, 75, 22, 0.55)',
|
||||
searchMatchSelectedOutline: '#cb4b16',
|
||||
|
||||
comment: '#586e75',
|
||||
lineComment: '#4f646a',
|
||||
blockComment: '#46595e',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#1f2335',
|
||||
matchingBracket: '#1f2335',
|
||||
|
||||
// 搜索匹配 - Tokyo Night Storm 紫蓝色调
|
||||
searchMatch: 'rgba(187, 154, 247, 0.4)',
|
||||
searchMatchSelected: 'rgba(122, 162, 247, 0.55)',
|
||||
searchMatchSelectedOutline: '#7aa2f7',
|
||||
|
||||
comment: '#565f89',
|
||||
lineComment: '#4d567b',
|
||||
blockComment: '#454e6f',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#16161e',
|
||||
matchingBracket: '#16161e',
|
||||
|
||||
// 搜索匹配 - Tokyo Night 紫蓝色调
|
||||
searchMatch: 'rgba(187, 154, 247, 0.4)',
|
||||
searchMatchSelected: 'rgba(122, 162, 247, 0.55)',
|
||||
searchMatchSelectedOutline: '#7aa2f7',
|
||||
|
||||
comment: '#444b6a',
|
||||
lineComment: '#3d4360',
|
||||
blockComment: '#373d55',
|
||||
|
||||
@@ -6,7 +6,7 @@ export const defaultLightColors: ThemeColors = {
|
||||
dark: false,
|
||||
|
||||
background: '#ffffff',
|
||||
backgroundSecondary: '#f4f7fb',
|
||||
backgroundSecondary: '#f1faf1',
|
||||
|
||||
foreground: '#24292e',
|
||||
cursor: '#000000',
|
||||
@@ -20,6 +20,11 @@ export const defaultLightColors: ThemeColors = {
|
||||
borderColor: '#d8dee4',
|
||||
matchingBracket: '#00000019',
|
||||
|
||||
// 搜索匹配 - 金黄色调
|
||||
searchMatch: 'rgba(255, 200, 0, 0.55)',
|
||||
searchMatchSelected: 'rgba(255, 140, 0, 0.75)',
|
||||
searchMatchSelectedOutline: '#ff8c00',
|
||||
|
||||
comment: '#6a737d',
|
||||
lineComment: '#808a95',
|
||||
blockComment: '#5c646f',
|
||||
|
||||
@@ -7,7 +7,7 @@ export const config: ThemeColors = {
|
||||
dark: false,
|
||||
|
||||
background: '#ffffff',
|
||||
backgroundSecondary: '#f1faf1',
|
||||
backgroundSecondary: '##f4f7fb ',
|
||||
|
||||
foreground: '#444d56',
|
||||
cursor: '#044289',
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#e1e4e8',
|
||||
matchingBracket: '#34d05840',
|
||||
|
||||
// 搜索匹配 - GitHub 蓝色调
|
||||
searchMatch: 'rgba(3, 102, 214, 0.25)',
|
||||
searchMatchSelected: 'rgba(3, 102, 214, 0.45)',
|
||||
searchMatchSelectedOutline: '#0366d6',
|
||||
|
||||
comment: '#6a737d',
|
||||
lineComment: '#5e6873',
|
||||
blockComment: '#4f5864',
|
||||
|
||||
@@ -21,6 +21,11 @@ export const config: ThemeColors = {
|
||||
borderColor: '#00000010',
|
||||
matchingBracket: '#fafafa',
|
||||
|
||||
// 搜索匹配 - Material 紫色调
|
||||
searchMatch: 'rgba(124, 77, 255, 0.25)',
|
||||
searchMatchSelected: 'rgba(145, 184, 89, 0.45)',
|
||||
searchMatchSelectedOutline: '#91b859',
|
||||
|
||||
comment: '#90a4ae',
|
||||
lineComment: '#8598a3',
|
||||
blockComment: '#788b97',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user