7 Commits

62 changed files with 6634 additions and 8982 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -49,6 +49,21 @@
--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);
}
/* 亮色主题 */
@@ -96,6 +111,20 @@
--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);
}
/* 跟随系统的浅色偏好 */
@@ -144,5 +173,19 @@
--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);
}
}

View File

@@ -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},

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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'] });
}

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
import { Token } from 'markdown-it';
/**
* Emoji 渲染函数
*/
export default function emoji_html(tokens: Token[], idx: number): string {
return tokens[idx].content;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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),
};
});

View File

@@ -29,8 +29,8 @@ 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';
import markdownExtensions from "@/views/editor/extensions/markdown";
export interface DocumentStats {
lines: number;
@@ -242,10 +242,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 +263,6 @@ export const useEditorStore = defineStore('editor', () => {
const httpExtension = createHttpClientExtension();
// Markdown预览扩展
const previewExtension = markdownPreviewExtension();
// 再次检查操作有效性
if (!operationManager.isOperationValid(operationId, documentId)) {
@@ -298,7 +298,7 @@ export const useEditorStore = defineStore('editor', () => {
codeBlockExtension,
...dynamicExtensions,
...httpExtension,
previewExtension
markdownExtensions
];
// 创建编辑器状态

View File

@@ -138,7 +138,6 @@ export const useThemeStore = defineStore('theme', () => {
const refreshEditorTheme = () => {
applyThemeToDOM(currentTheme.value);
const editorStore = useEditorStore();
editorStore?.applyThemeSettings();
};

View File

@@ -11,8 +11,7 @@ import ContextMenu from './contextMenu/ContextMenu.vue';
import {contextMenuManager} from './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";
const editorStore = useEditorStore();
const documentStore = useDocumentStore();
@@ -39,7 +38,6 @@ onMounted(async () => {
onBeforeUnmount(() => {
contextMenuManager.destroy();
translatorManager.destroy();
markdownPreviewManager.destroy();
});
</script>
@@ -49,13 +47,9 @@ onBeforeUnmount(() => {
<transition name="loading-fade">
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
</transition>
<!-- 编辑器和预览面板的容器 -->
<div class="editor-wrapper">
<!-- 编辑器区域 -->
<div ref="editorElement" class="editor"></div>
<!-- Markdown 预览面板 -->
<PreviewPanel />
</div>
<!-- 工具栏 -->
<Toolbar/>
<!-- 右键菜单 -->
@@ -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%;

View File

@@ -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) => {
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 = adjuster();
const result = onSave();
if (result && typeof (result as Promise<void>).then === 'function') {
(result as Promise<void>).catch((error) => {
console.error('Failed to adjust font size:', error);
console.error('Failed to save font size:', error);
});
}
} catch (error) {
console.error('Failed to adjust font size:', 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;

View File

@@ -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) {

View File

@@ -5,9 +5,14 @@
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 {javaLanguage} from "@codemirror/lang-java";
import {phpLanguage} from "@codemirror/lang-php";
import {cssLanguage} from "@codemirror/lang-css";
@@ -64,6 +69,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 +116,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],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({
matchClosingTags: true,
autoCloseTags: true
}),
addKeymap: true,
codeLanguages: languages,
}).language.parser, ["md"], {
parser: "markdown",
plugins: [markdownPrettierPlugin]
}),

View File

@@ -0,0 +1,41 @@
import { Extension } from '@codemirror/state';
import { blockquote } from './plugins/blockquote';
import { codeblock } from './plugins/code-block';
import { headings } from './plugins/heading';
import { hideMarks } from './plugins/hide-mark';
import { image } from './plugins/image';
import { links } from './plugins/link';
import { lists } from './plugins/list';
import { headingSlugField } from './state/heading-slug';
import { emoji } from './plugins/emoji';
import { horizontalRule } from './plugins/horizontal-rule';
import { inlineCode } from './plugins/inline-code';
import { subscriptSuperscript } from './plugins/subscript-superscript';
import { highlight } from './plugins/highlight';
import { insert } from './plugins/insert';
import { math } from './plugins/math';
import { footnote } from './plugins/footnote';
/**
* markdown extensions
*/
export const markdownExtensions: Extension = [
headingSlugField,
blockquote(),
codeblock(),
headings(),
hideMarks(),
lists(),
links(),
image(),
emoji(),
horizontalRule(),
inlineCode(),
subscriptSuperscript(),
highlight(),
insert(),
math(),
footnote(),
];
export default markdownExtensions;

View File

@@ -0,0 +1,102 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Blockquote plugin.
*
* Features:
* - Decorates blockquote with left border
* - Hides quote marks (>) when cursor is outside
* - Supports nested blockquotes
*/
export function blockquote() {
return [blockQuotePlugin, baseTheme];
}
/**
* Build blockquote decorations.
*/
function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const processedLines = new Set<number>();
syntaxTree(view.state).iterate({
enter(node) {
if (node.type.name !== 'Blockquote') return;
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
// Only add decorations when cursor is outside the blockquote
// This allows selection highlighting to be visible when editing
if (!cursorInBlockquote) {
// Add line decoration for each line in the blockquote
const startLine = view.state.doc.lineAt(node.from).number;
const endLine = view.state.doc.lineAt(node.to).number;
for (let i = startLine; i <= endLine; i++) {
if (!processedLines.has(i)) {
processedLines.add(i);
const line = view.state.doc.line(i);
decorations.push(
Decoration.line({ class: 'cm-blockquote' }).range(line.from)
);
}
}
// Hide quote marks when cursor is outside
const cursor = node.node.cursor();
cursor.iterate((child) => {
if (child.type.name === 'QuoteMark') {
decorations.push(
invisibleDecoration.range(child.from, child.to)
);
}
});
}
// Don't recurse into nested blockquotes (handled by outer iteration)
return false;
}
});
return Decoration.set(decorations, true);
}
/**
* Blockquote plugin class.
*/
class BlockQuotePlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildBlockQuoteDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = buildBlockQuoteDecorations(update.view);
}
}
}
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for blockquotes.
*/
const baseTheme = EditorView.baseTheme({
'.cm-blockquote': {
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
color: 'var(--cm-blockquote-color, #666)'
}
});

View File

@@ -0,0 +1,297 @@
import { Extension, Range } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
/** Code block node types in syntax tree */
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
/** Copy button icon SVGs (size controlled by CSS) */
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>`;
/** Cache for code block metadata */
interface CodeBlockData {
from: number;
to: number;
language: string | null;
content: string;
}
/**
* Code block extension with language label and copy button.
*
* Features:
* - Adds background styling to code blocks
* - Shows language label + copy button when language is specified
* - Hides markers when cursor is outside block
* - Optimized with viewport-only rendering
*/
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
/**
* Widget for displaying language label and copy button.
* Handles click events directly on the button element.
*/
class CodeBlockInfoWidget extends WidgetType {
constructor(
readonly data: CodeBlockData,
readonly view: EditorView
) {
super();
}
eq(other: CodeBlockInfoWidget): boolean {
return other.data.from === this.data.from &&
other.data.language === this.data.language;
}
toDOM(): HTMLElement {
const container = document.createElement('span');
container.className = 'cm-code-block-info';
// Only show language label if specified
if (this.data.language) {
const lang = document.createElement('span');
lang.className = 'cm-code-block-lang';
lang.textContent = this.data.language;
container.append(lang);
}
const btn = document.createElement('button');
btn.className = 'cm-code-block-copy-btn';
btn.title = 'Copy';
btn.innerHTML = ICON_COPY;
// Direct click handler - more reliable than eventHandlers
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.handleCopy(btn);
});
// Prevent mousedown from affecting editor
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
});
container.append(btn);
return container;
}
private handleCopy(btn: HTMLButtonElement): void {
const content = getCodeContent(this.view, this.data.from, this.data.to);
if (!content) return;
navigator.clipboard.writeText(content).then(() => {
btn.innerHTML = ICON_CHECK;
setTimeout(() => {
btn.innerHTML = ICON_COPY;
}, 1500);
});
}
// Ignore events to prevent editor focus changes
ignoreEvent(): boolean {
return true;
}
}
/**
* Extract language from code block node.
*/
function getLanguage(view: EditorView, node: any, offset: number): string | null {
let lang: string | null = null;
node.toTree().iterate({
enter: ({ type, from, to }) => {
if (type.name === 'CodeInfo') {
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
}
}
});
return lang;
}
/**
* Extract code content (without fence markers).
*/
function getCodeContent(view: EditorView, from: number, to: number): string {
const lines = view.state.doc.sliceString(from, to).split('\n');
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
}
/**
* Build decorations for visible code blocks.
*/
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
const decorations: Range<Decoration>[] = [];
const blocks = new Map<number, CodeBlockData>();
const seen = new Set<string>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
const key = `${nodeFrom}:${nodeTo}`;
if (seen.has(key)) return;
seen.add(key);
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
if (inBlock) return;
const language = getLanguage(view, node, nodeFrom);
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
for (let num = startLine.number; num <= endLine.number; num++) {
const line = view.state.doc.line(num);
const pos: string[] = ['cm-codeblock'];
if (num === startLine.number) pos.push('cm-codeblock-begin');
if (num === endLine.number) pos.push('cm-codeblock-end');
decorations.push(
Decoration.line({ class: pos.join(' ') }).range(line.from)
);
}
// Info widget with copy button (always show, language label only if specified)
const content = getCodeContent(view, nodeFrom, nodeTo);
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
blocks.set(nodeFrom, data);
decorations.push(
Decoration.widget({
widget: new CodeBlockInfoWidget(data, view),
side: 1
}).range(startLine.to)
);
// Hide markers
node.toTree().iterate({
enter: ({ type: t, from: f, to: t2 }) => {
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
}
}
});
}
});
}
return { decorations: Decoration.set(decorations, true), blocks };
}
/**
* Code block plugin with optimized updates.
*/
class CodeBlockPluginClass {
decorations: DecorationSet;
blocks: Map<number, CodeBlockData>;
private lastHead = -1;
constructor(view: EditorView) {
const result = buildDecorations(view);
this.decorations = result.decorations;
this.blocks = result.blocks;
this.lastHead = view.state.selection.main.head;
}
update(update: ViewUpdate): void {
const { docChanged, viewportChanged, selectionSet } = update;
// Skip rebuild if cursor stayed on same line
if (selectionSet && !docChanged && !viewportChanged) {
const newHead = update.state.selection.main.head;
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
const newLine = update.state.doc.lineAt(newHead).number;
if (oldLine === newLine) {
this.lastHead = newHead;
return;
}
}
if (docChanged || viewportChanged || selectionSet) {
const result = buildDecorations(update.view);
this.decorations = result.decorations;
this.blocks = result.blocks;
this.lastHead = update.state.selection.main.head;
}
}
}
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
decorations: (v) => v.decorations
});
/**
* Base theme for code blocks.
*/
const baseTheme = EditorView.baseTheme({
'.cm-codeblock': {
backgroundColor: 'var(--cm-codeblock-bg)'
},
'.cm-codeblock-begin': {
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
borderTopRightRadius: 'var(--cm-codeblock-radius)',
position: 'relative',
boxShadow: 'inset 0 1px 0 var(--text-primary)'
},
'.cm-codeblock-end': {
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
boxShadow: 'inset 0 -1px 0 var(--text-primary)'
},
'.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'
}
});

View File

@@ -0,0 +1,181 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { emojies } from '@/common/constant/emojies';
/**
* Emoji plugin that converts :emoji_name: to actual emoji characters.
*
* Features:
* - Detects emoji patterns like :smile:, :heart:, etc.
* - Replaces them with actual emoji characters
* - Shows the original text when cursor is nearby
* - Uses RangeSetBuilder for optimal performance
* - Supports 1900+ emojis from the comprehensive emoji dictionary
*/
export const emoji = (): Extension => [emojiPlugin, baseTheme];
/**
* Emoji regex pattern for matching :emoji_name: syntax.
*/
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
/**
* Emoji widget with optimized rendering.
*/
class EmojiWidget extends WidgetType {
constructor(
readonly emoji: string,
readonly name: string
) {
super();
}
eq(other: EmojiWidget): boolean {
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;
}
}
/**
* Match result for emoji patterns.
*/
interface EmojiMatch {
from: number;
to: number;
name: string;
emoji: string;
}
/**
* Find all emoji matches in a text range.
*/
function findEmojiMatches(text: string, offset: number): EmojiMatch[] {
const matches: EmojiMatch[] = [];
let match: RegExpExecArray | null;
// Reset regex state
EMOJI_REGEX.lastIndex = 0;
while ((match = EMOJI_REGEX.exec(text)) !== null) {
const name = match[1].toLowerCase();
const emoji = emojies[name];
if (emoji) {
matches.push({
from: offset + match.index,
to: offset + match.index + match[0].length,
name,
emoji
});
}
}
return matches;
}
/**
* Build emoji decorations using RangeSetBuilder.
*/
function buildEmojiDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
for (const { from, to } of view.visibleRanges) {
const text = doc.sliceString(from, to);
const matches = findEmojiMatches(text, from);
for (const match of matches) {
// Skip if cursor is in this range
if (isCursorInRange(view.state, [match.from, match.to])) {
continue;
}
builder.add(
match.from,
match.to,
Decoration.replace({
widget: new EmojiWidget(match.emoji, match.name)
})
);
}
}
return builder.finish();
}
/**
* Emoji plugin with optimized update detection.
*/
class EmojiPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildEmojiDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
// Always rebuild on doc or viewport change
if (update.docChanged || update.viewportChanged) {
this.decorations = buildEmojiDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
// For selection changes, check if we moved significantly
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
// Only rebuild if cursor moved to a different position
if (newHead !== this.lastSelectionHead) {
this.decorations = buildEmojiDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for emoji.
* Inherits font size and line height from parent element.
*/
const baseTheme = EditorView.baseTheme({
'.cm-emoji': {
verticalAlign: 'middle',
cursor: 'default'
}
});
/**
* Get all available emoji names.
*/
export function getEmojiNames(): string[] {
return Object.keys(emojies);
}
/**
* Get emoji by name.
*/
export function getEmoji(name: string): string | undefined {
return emojies[name.toLowerCase()];
}

View File

@@ -0,0 +1,782 @@
/**
* Footnote plugin for CodeMirror.
*
* Features:
* - Renders footnote references as superscript numbers/labels
* - Renders inline footnotes as superscript numbers with embedded content
* - Shows footnote content on hover (tooltip)
* - Click to jump between reference and definition
* - Hides syntax marks when cursor is outside
*
* Syntax (MultiMarkdown/PHP Markdown Extra):
* - Reference: [^id] → renders as superscript
* - Definition: [^id]: content
* - Inline footnote: ^[content] → renders as superscript with embedded content
*/
import { Extension, Range, StateField, EditorState } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType,
hoverTooltip,
Tooltip,
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
// ============================================================================
// Types
// ============================================================================
/**
* Information about a footnote definition.
*/
interface FootnoteDefinition {
/** The footnote identifier (e.g., "1", "note") */
id: string;
/** The content of the footnote */
content: string;
/** Start position in document */
from: number;
/** End position in document */
to: number;
}
/**
* Information about a footnote reference.
*/
interface FootnoteReference {
/** The footnote identifier */
id: string;
/** Start position in document */
from: number;
/** End position in document */
to: number;
/** Numeric index (1-based, for display) */
index: number;
}
/**
* Information about an inline footnote.
*/
interface InlineFootnoteInfo {
/** The content of the inline footnote */
content: string;
/** Start position in document */
from: number;
/** End position in document */
to: number;
/** Numeric index (1-based, for display) */
index: number;
}
/**
* Collected footnote data from the document.
* Uses Maps for O(1) lookup by position and id.
*/
interface FootnoteData {
definitions: Map<string, FootnoteDefinition>;
references: FootnoteReference[];
inlineFootnotes: InlineFootnoteInfo[];
// Index maps for O(1) lookup
referencesByPos: Map<number, FootnoteReference>;
inlineByPos: Map<number, InlineFootnoteInfo>;
firstRefById: Map<string, FootnoteReference>;
}
// ============================================================================
// Footnote Collection
// ============================================================================
/**
* Collect all footnote definitions, references, and inline footnotes from the document.
* Builds index maps for O(1) lookup during decoration and tooltip handling.
*/
function collectFootnotes(state: EditorState): FootnoteData {
const definitions = new Map<string, FootnoteDefinition>();
const references: FootnoteReference[] = [];
const inlineFootnotes: InlineFootnoteInfo[] = [];
// Index maps for fast lookup
const referencesByPos = new Map<number, FootnoteReference>();
const inlineByPos = new Map<number, InlineFootnoteInfo>();
const firstRefById = new Map<string, FootnoteReference>();
const seenIds = new Map<string, number>();
let inlineIndex = 0;
syntaxTree(state).iterate({
enter: ({ type, from, to, node }) => {
if (type.name === 'FootnoteDefinition') {
const labelNode = node.getChild('FootnoteDefinitionLabel');
const contentNode = node.getChild('FootnoteDefinitionContent');
if (labelNode) {
const id = state.sliceDoc(labelNode.from, labelNode.to);
const content = contentNode
? state.sliceDoc(contentNode.from, contentNode.to).trim()
: '';
definitions.set(id, { id, content, from, to });
}
} else if (type.name === 'FootnoteReference') {
const labelNode = node.getChild('FootnoteReferenceLabel');
if (labelNode) {
const id = state.sliceDoc(labelNode.from, labelNode.to);
if (!seenIds.has(id)) {
seenIds.set(id, seenIds.size + 1);
}
const ref: FootnoteReference = {
id,
from,
to,
index: seenIds.get(id)!,
};
references.push(ref);
referencesByPos.set(from, ref);
// Track first reference for each id
if (!firstRefById.has(id)) {
firstRefById.set(id, ref);
}
}
} else if (type.name === 'InlineFootnote') {
const contentNode = node.getChild('InlineFootnoteContent');
if (contentNode) {
const content = state.sliceDoc(contentNode.from, contentNode.to).trim();
inlineIndex++;
const info: InlineFootnoteInfo = {
content,
from,
to,
index: inlineIndex,
};
inlineFootnotes.push(info);
inlineByPos.set(from, info);
}
}
},
});
return {
definitions,
references,
inlineFootnotes,
referencesByPos,
inlineByPos,
firstRefById,
};
}
// ============================================================================
// State Field
// ============================================================================
/**
* State field to track footnote data across the document.
* This allows efficient lookup for tooltips and navigation.
*/
export const footnoteDataField = StateField.define<FootnoteData>({
create(state) {
return collectFootnotes(state);
},
update(value, tr) {
if (tr.docChanged) {
return collectFootnotes(tr.state);
}
return value;
},
});
// ============================================================================
// Widget
// ============================================================================
/**
* Widget to display footnote reference as superscript.
*/
class FootnoteRefWidget extends WidgetType {
constructor(
readonly id: string,
readonly index: number,
readonly hasDefinition: boolean
) {
super();
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-footnote-ref';
span.textContent = `[${this.index}]`;
span.dataset.footnoteId = this.id;
if (!this.hasDefinition) {
span.classList.add('cm-footnote-ref-undefined');
}
return span;
}
eq(other: FootnoteRefWidget): boolean {
return this.id === other.id && this.index === other.index;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Widget to display inline footnote as superscript.
*/
class InlineFootnoteWidget extends WidgetType {
constructor(
readonly content: string,
readonly index: number
) {
super();
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-inline-footnote-ref';
span.textContent = `[${this.index}]`;
span.dataset.footnoteContent = this.content;
span.dataset.footnoteIndex = String(this.index);
return span;
}
eq(other: InlineFootnoteWidget): boolean {
return this.content === other.content && this.index === other.index;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Widget to display footnote definition label.
*/
class FootnoteDefLabelWidget extends WidgetType {
constructor(readonly id: string) {
super();
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-footnote-def-label';
span.textContent = `[${this.id}]`;
span.dataset.footnoteId = this.id;
return span;
}
eq(other: FootnoteDefLabelWidget): boolean {
return this.id === other.id;
}
ignoreEvent(): boolean {
return false;
}
}
// ============================================================================
// Decorations
// ============================================================================
/**
* Build decorations for footnote references and inline footnotes.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const data = view.state.field(footnoteDataField);
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle footnote references
if (type.name === 'FootnoteReference') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const labelNode = node.getChild('FootnoteReferenceLabel');
const marks = node.getChildren('FootnoteReferenceMark');
if (!labelNode || marks.length < 2) return;
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
const ref = data.referencesByPos.get(nodeFrom);
if (!cursorInRange && ref && ref.id === id) {
// Hide the entire syntax and show widget
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
// Add widget at the end
const widget = new FootnoteRefWidget(
id,
ref.index,
data.definitions.has(id)
);
decorations.push(
Decoration.widget({
widget,
side: 1,
}).range(nodeTo)
);
}
}
// Handle footnote definitions
if (type.name === 'FootnoteDefinition') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('FootnoteDefinitionMark');
const labelNode = node.getChild('FootnoteDefinitionLabel');
if (!cursorInRange && marks.length >= 2 && labelNode) {
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
// Hide the entire [^id]: part
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
// Add widget to show [id]
const widget = new FootnoteDefLabelWidget(id);
decorations.push(
Decoration.widget({
widget,
side: 1,
}).range(marks[1].to)
);
}
}
// Handle inline footnotes
if (type.name === 'InlineFootnote') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const contentNode = node.getChild('InlineFootnoteContent');
const marks = node.getChildren('InlineFootnoteMark');
if (!contentNode || marks.length < 2) return;
const inlineNote = data.inlineByPos.get(nodeFrom);
if (!cursorInRange && inlineNote) {
// Hide the entire syntax and show widget
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
// Add widget at the end
const widget = new InlineFootnoteWidget(
inlineNote.content,
inlineNote.index
);
decorations.push(
Decoration.widget({
widget,
side: 1,
}).range(nodeTo)
);
}
}
},
});
}
return Decoration.set(decorations, true);
}
// ============================================================================
// Plugin Class
// ============================================================================
/**
* Footnote view plugin with optimized update detection.
*/
class FootnotePlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
decorations: (v) => v.decorations,
});
// ============================================================================
// Hover Tooltip
// ============================================================================
/**
* Hover tooltip that shows footnote content.
*/
const footnoteHoverTooltip = hoverTooltip(
(view, pos): Tooltip | null => {
const data = view.state.field(footnoteDataField);
// Check if hovering over a footnote reference widget
const target = document.elementFromPoint(
view.coordsAtPos(pos)?.left ?? 0,
view.coordsAtPos(pos)?.top ?? 0
) as HTMLElement | null;
if (target?.classList.contains('cm-footnote-ref')) {
const id = target.dataset.footnoteId;
if (id) {
const def = data.definitions.get(id);
if (def) {
return {
pos,
above: true,
arrow: true,
create: () => createTooltipDom(id, def.content),
};
}
}
}
// Check if hovering over an inline footnote widget
if (target?.classList.contains('cm-inline-footnote-ref')) {
const content = target.dataset.footnoteContent;
const index = target.dataset.footnoteIndex;
if (content && index) {
return {
pos,
above: true,
arrow: true,
create: () => createInlineTooltipDom(parseInt(index), content),
};
}
}
// Check if position is within a footnote reference node
let foundId: string | null = null;
let foundPos: number = pos;
let foundInlineContent: string | null = null;
let foundInlineIndex: number | null = null;
syntaxTree(view.state).iterate({
from: pos,
to: pos,
enter: ({ type, from, to, node }) => {
if (type.name === 'FootnoteReference') {
const labelNode = node.getChild('FootnoteReferenceLabel');
if (labelNode && pos >= from && pos <= to) {
foundId = view.state.sliceDoc(labelNode.from, labelNode.to);
foundPos = to;
}
} else if (type.name === 'InlineFootnote') {
const contentNode = node.getChild('InlineFootnoteContent');
if (contentNode && pos >= from && pos <= to) {
foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to);
const inlineNote = data.inlineByPos.get(from);
if (inlineNote) {
foundInlineIndex = inlineNote.index;
}
foundPos = to;
}
}
},
});
if (foundId) {
const def = data.definitions.get(foundId);
if (def) {
const tooltipId = foundId;
const tooltipPos = foundPos;
return {
pos: tooltipPos,
above: true,
arrow: true,
create: () => createTooltipDom(tooltipId, def.content),
};
}
}
if (foundInlineContent && foundInlineIndex !== null) {
const tooltipContent = foundInlineContent;
const tooltipIndex = foundInlineIndex;
const tooltipPos = foundPos;
return {
pos: tooltipPos,
above: true,
arrow: true,
create: () => createInlineTooltipDom(tooltipIndex, tooltipContent),
};
}
return null;
},
{ hoverTime: 300 }
);
/**
* Create tooltip DOM element for regular footnote.
*/
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
const dom = document.createElement('div');
dom.className = 'cm-footnote-tooltip';
const header = document.createElement('div');
header.className = 'cm-footnote-tooltip-header';
header.textContent = `[^${id}]`;
const body = document.createElement('div');
body.className = 'cm-footnote-tooltip-body';
body.textContent = content || '(Empty footnote)';
dom.appendChild(header);
dom.appendChild(body);
return { dom };
}
/**
* Create tooltip DOM element for inline footnote.
*/
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
const dom = document.createElement('div');
dom.className = 'cm-footnote-tooltip';
const header = document.createElement('div');
header.className = 'cm-footnote-tooltip-header';
header.textContent = `Inline Footnote [${index}]`;
const body = document.createElement('div');
body.className = 'cm-footnote-tooltip-body';
body.textContent = content || '(Empty footnote)';
dom.appendChild(header);
dom.appendChild(body);
return { dom };
}
// ============================================================================
// Click Handler
// ============================================================================
/**
* Click handler for footnote navigation.
* Uses mousedown to intercept before editor moves cursor.
* - Click on reference → jump to definition
* - Click on definition label → jump to first reference
*/
const footnoteClickHandler = EditorView.domEventHandlers({
mousedown(event, view) {
const target = event.target as HTMLElement;
// Handle click on footnote reference widget
if (target.classList.contains('cm-footnote-ref')) {
const id = target.dataset.footnoteId;
if (id) {
const data = view.state.field(footnoteDataField);
const def = data.definitions.get(id);
if (def) {
// Prevent default to stop cursor from moving to widget position
event.preventDefault();
// Use setTimeout to dispatch after mousedown completes
setTimeout(() => {
view.dispatch({
selection: { anchor: def.from },
scrollIntoView: true,
});
view.focus();
}, 0);
return true;
}
}
}
// Handle click on definition label
if (target.classList.contains('cm-footnote-def-label')) {
const pos = view.posAtDOM(target);
if (pos !== null) {
const data = view.state.field(footnoteDataField);
// Find which definition this belongs to
for (const [id, def] of data.definitions) {
if (pos >= def.from && pos <= def.to) {
// O(1) lookup for first reference
const firstRef = data.firstRefById.get(id);
if (firstRef) {
event.preventDefault();
setTimeout(() => {
view.dispatch({
selection: { anchor: firstRef.from },
scrollIntoView: true,
});
view.focus();
}, 0);
return true;
}
break;
}
}
}
}
return false;
},
});
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for footnotes.
*/
const baseTheme = EditorView.baseTheme({
// Footnote reference (superscript)
'.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',
cursor: 'pointer',
transition: 'all 0.15s ease',
textDecoration: 'none',
},
'.cm-footnote-ref:hover': {
color: 'var(--cm-footnote-hover-color, #1557b0)',
backgroundColor: 'var(--cm-footnote-hover-bg, rgba(26, 115, 232, 0.2))',
},
'.cm-footnote-ref-undefined': {
color: 'var(--cm-footnote-undefined-color, #d93025)',
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
},
// Inline footnote reference (superscript) - uses distinct color
'.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',
cursor: 'pointer',
transition: 'all 0.15s ease',
textDecoration: 'none',
},
'.cm-inline-footnote-ref:hover': {
color: 'var(--cm-inline-footnote-hover-color, #d35400)',
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
},
// Footnote definition label
'.cm-footnote-def-label': {
color: 'var(--cm-footnote-def-color, #1a73e8)',
fontWeight: '600',
cursor: 'pointer',
},
'.cm-footnote-def-label:hover': {
textDecoration: 'underline',
},
// Tooltip
'.cm-footnote-tooltip': {
maxWidth: '400px',
padding: '0',
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
},
'.cm-footnote-tooltip-header': {
padding: '6px 12px',
fontSize: '0.8em',
fontWeight: '600',
fontFamily: 'monospace',
color: 'var(--cm-footnote-color, #1a73e8)',
backgroundColor: 'var(--bg-tertiary, rgba(0, 0, 0, 0.05))',
borderBottom: '1px solid var(--border-color)',
},
'.cm-footnote-tooltip-body': {
padding: '10px 12px',
fontSize: '0.9em',
lineHeight: '1.5',
color: 'var(--text-primary)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
// Tooltip animation
'.cm-tooltip:has(.cm-footnote-tooltip)': {
animation: 'cm-footnote-fade-in 0.15s ease-out',
},
'@keyframes cm-footnote-fade-in': {
from: { opacity: '0', transform: 'translateY(4px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
});
// ============================================================================
// Export
// ============================================================================
/**
* Footnote extension.
*
* Features:
* - Parses footnote references [^id] and definitions [^id]: content
* - Parses inline footnotes ^[content]
* - Renders references and inline footnotes as superscript numbers
* - Shows definition/content on hover
* - Click to navigate between reference and definition
*/
export const footnote = (): Extension => [
footnoteDataField,
footnotePlugin,
footnoteHoverTooltip,
footnoteClickHandler,
baseTheme,
];
export default footnote;

View File

@@ -0,0 +1,96 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField, Range } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
/**
* Hidden mark decoration - uses visibility: hidden to hide content
*/
const hiddenMarkDecoration = Decoration.mark({
class: 'cm-heading-mark-hidden'
});
/**
* Check if selection overlaps with a range.
*/
function isSelectionInRange(state: EditorState, from: number, to: number): boolean {
return state.selection.ranges.some(
(range) => from <= range.to && to >= range.from
);
}
/**
* Build heading decorations.
* Hides # marks when cursor is not on the heading line.
*/
function buildHeadingDecorations(state: EditorState): DecorationSet {
const decorations: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter(node) {
// Skip if cursor is in this node's range
if (isSelectionInRange(state, node.from, node.to)) return;
// Handle ATX headings (# Heading)
if (node.type.name.startsWith('ATXHeading')) {
const header = node.node.firstChild;
if (header && header.type.name === 'HeaderMark') {
const from = header.from;
// Include the space after #
const to = Math.min(header.to + 1, node.to);
decorations.push(hiddenMarkDecoration.range(from, to));
}
}
// Handle Setext headings (underline style)
else if (node.type.name.startsWith('SetextHeading')) {
// Hide the underline marks (=== or ---)
const cursor = node.node.cursor();
cursor.iterate((child) => {
if (child.type.name === 'HeaderMark') {
decorations.push(
hiddenMarkDecoration.range(child.from, child.to)
);
}
});
}
}
});
return Decoration.set(decorations, true);
}
/**
* Heading StateField - manages # mark visibility.
*/
const headingField = StateField.define<DecorationSet>({
create(state) {
return buildHeadingDecorations(state);
},
update(deco, tr) {
if (tr.docChanged || tr.selection) {
return buildHeadingDecorations(tr.state);
}
return deco.map(tr.changes);
},
provide: (f) => EditorView.decorations.from(f)
});
/**
* Theme for hidden heading marks.
*
* Uses fontSize: 0 to hide the # mark without leaving whitespace.
* This works correctly now because blockLayer uses lineBlockAt()
* which calculates coordinates based on the entire line, not
* individual characters, so fontSize: 0 doesn't affect boundaries.
*/
const headingTheme = EditorView.baseTheme({
'.cm-heading-mark-hidden': {
fontSize: '0'
}
});
/**
* Headings plugin.
*/
export const headings = () => [headingField, headingTheme];

View File

@@ -0,0 +1,140 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, isCursorInRange } from '../util';
/**
* Node types that contain markers as child elements.
*/
const TYPES_WITH_MARKS = new Set([
'Emphasis',
'StrongEmphasis',
'InlineCode',
'Strikethrough'
]);
/**
* Node types that are markers themselves.
*/
const MARK_TYPES = new Set([
'EmphasisMark',
'CodeMark',
'StrikethroughMark'
]);
// Export for external use
export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
export const markTypes = Array.from(MARK_TYPES);
/**
* Build mark hiding decorations using RangeSetBuilder for optimal performance.
*/
function buildHideMarkDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const replaceDecoration = Decoration.replace({});
// Track processed ranges to avoid duplicate processing of nested marks
let currentParentRange: [number, number] | null = null;
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!TYPES_WITH_MARKS.has(type.name)) return;
// Skip if this is a nested element within a parent we're already processing
if (currentParentRange && checkRangeOverlap([nodeFrom, nodeTo], currentParentRange)) {
return;
}
// Update current parent range
currentParentRange = [nodeFrom, nodeTo];
// Skip if cursor is in this range
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
// Iterate through child marks
const innerTree = node.toTree();
innerTree.iterate({
enter({ type: markType, from: markFrom, to: markTo }) {
if (!MARK_TYPES.has(markType.name)) return;
// Add decoration to hide the mark
builder.add(
nodeFrom + markFrom,
nodeFrom + markTo,
replaceDecoration
);
}
});
}
});
}
return builder.finish();
}
/**
* Hide marks plugin with optimized update detection.
*
* This plugin:
* - Hides emphasis marks (*, **, ~~ etc.) when cursor is outside
* - Uses RangeSetBuilder for efficient decoration construction
* - Optimizes selection change detection
*/
class HideMarkPlugin {
decorations: DecorationSet;
private lastSelectionRanges: string = '';
constructor(view: EditorView) {
this.decorations = buildHideMarkDecorations(view);
this.lastSelectionRanges = this.serializeSelection(view);
}
update(update: ViewUpdate) {
// Always rebuild on doc or viewport change
if (update.docChanged || update.viewportChanged) {
this.decorations = buildHideMarkDecorations(update.view);
this.lastSelectionRanges = this.serializeSelection(update.view);
return;
}
// For selection changes, check if selection actually changed positions
if (update.selectionSet) {
const newRanges = this.serializeSelection(update.view);
if (newRanges !== this.lastSelectionRanges) {
this.decorations = buildHideMarkDecorations(update.view);
this.lastSelectionRanges = newRanges;
}
}
}
/**
* Serialize selection ranges for comparison.
*/
private serializeSelection(view: EditorView): string {
return view.state.selection.ranges
.map(r => `${r.from}:${r.to}`)
.join(',');
}
}
/**
* Hide marks plugin.
*
* This plugin:
* - Hides marks when they are not in the editor selection
* - Supports emphasis, strong, inline code, and strikethrough
*/
export const hideMarks = () => [
ViewPlugin.fromClass(HideMarkPlugin, {
decorations: (v) => v.decorations
})
];

View File

@@ -0,0 +1,115 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Highlight plugin using syntax tree.
*
* Uses the custom Highlight extension to detect:
* - Highlight: ==text== → renders as highlighted text
*
* Examples:
* - This is ==important== text → This is <mark>important</mark> text
* - Please ==review this section== carefully
*/
export const highlight = (): Extension => [
highlightPlugin,
baseTheme
];
/**
* Build decorations for highlight using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Highlight nodes
if (type.name === 'Highlight') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the == characters)
const marks = node.getChildren('HighlightMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing == marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply highlight style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-highlight'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class HighlightPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const highlightPlugin = ViewPlugin.fromClass(
HighlightPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for highlight.
* Uses mark decoration with a subtle background color.
*/
const baseTheme = EditorView.baseTheme({
'.cm-highlight': {
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
borderRadius: '2px',
}
});

View File

@@ -0,0 +1,110 @@
import { Extension, StateField, EditorState, Range } from '@codemirror/state';
import {
DecorationSet,
Decoration,
EditorView,
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { syntaxTree } from '@codemirror/language';
/**
* Horizontal rule plugin that renders beautiful horizontal lines.
*
* This plugin:
* - Replaces markdown horizontal rules (---, ***, ___) with styled <hr> elements
* - Shows the original text when cursor is on the line
* - Uses inline widget to avoid affecting block system boundaries
*/
export const horizontalRule = (): Extension => [
horizontalRuleField,
baseTheme
];
/**
* Widget to display a horizontal rule (inline version).
*/
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(_other: HorizontalRuleWidget) {
return true;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Build horizontal rule decorations.
* Uses Decoration.replace WITHOUT block: true to avoid affecting block system.
*/
function buildHorizontalRuleDecorations(state: EditorState): DecorationSet {
const decorations: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== 'HorizontalRule') return;
// Skip if cursor is on this line
if (isCursorInRange(state, [from, to])) return;
// Replace the entire horizontal rule with a styled widget
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
decorations.push(
Decoration.replace({
widget: new HorizontalRuleWidget()
}).range(from, to)
);
}
});
return Decoration.set(decorations, true);
}
/**
* StateField for horizontal rule decorations.
*/
const horizontalRuleField = StateField.define<DecorationSet>({
create(state) {
return buildHorizontalRuleDecorations(state);
},
update(value, tx) {
if (tx.docChanged || tx.selection) {
return buildHorizontalRuleDecorations(tx.state);
}
return value.map(tx.changes);
},
provide(field) {
return EditorView.decorations.from(field);
}
});
/**
* Base theme for horizontal rules.
* Uses inline-block display to render properly without block: true.
*/
const baseTheme = 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'
}
});

View File

@@ -0,0 +1,208 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import DOMPurify from 'dompurify';
import { isCursorInRange } from '../util';
interface EmbedBlockData {
from: number;
to: number;
content: string;
}
/**
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag).
* Returns all blocks regardless of cursor position.
*/
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
const blocks = new Array<EmbedBlockData>();
syntaxTree(state).iterate({
enter({ from, to, name }) {
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
const html = state.sliceDoc(from, to);
const content = DOMPurify.sanitize(html);
// Skip empty content after sanitization
if (!content.trim()) return;
blocks.push({ from, to, content });
}
});
return blocks;
}
/**
* Build decorations for HTML blocks.
* Only shows preview for blocks where cursor is not inside.
*/
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const block of blocks) {
// Skip if cursor is in range
if (isCursorInRange(state, [block.from, block.to])) continue;
// Hide the original HTML source code
decorations.push(Decoration.replace({}).range(block.from, block.to));
// Add the preview widget at the end
decorations.push(
Decoration.widget({
widget: new HTMLBlockWidget(block),
side: 1
}).range(block.to)
);
}
return Decoration.set(decorations, true);
}
/**
* Check if selection affects any HTML block (cursor moved in/out of a block).
*/
function selectionAffectsBlocks(
state: EditorState,
prevState: EditorState,
blocks: EmbedBlockData[]
): boolean {
for (const block of blocks) {
const wasInRange = isCursorInRange(prevState, [block.from, block.to]);
const isInRange = isCursorInRange(state, [block.from, block.to]);
if (wasInRange !== isInRange) return true;
}
return false;
}
/**
* ViewPlugin for HTML block preview.
* Uses smart caching to avoid unnecessary updates during text selection.
*/
class HTMLBlockPlugin {
decorations: DecorationSet;
blocks: EmbedBlockData[];
constructor(view: EditorView) {
this.blocks = extractAllHTMLBlocks(view.state);
this.decorations = buildDecorations(view.state, this.blocks);
}
update(update: ViewUpdate) {
// If document changed, re-extract all blocks
if (update.docChanged) {
this.blocks = extractAllHTMLBlocks(update.state);
this.decorations = buildDecorations(update.state, this.blocks);
return;
}
// If selection changed, only rebuild if cursor moved in/out of a block
if (update.selectionSet) {
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) {
this.decorations = buildDecorations(update.state, this.blocks);
}
}
}
}
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
decorations: (v) => v.decorations
});
class HTMLBlockWidget extends WidgetType {
constructor(public data: EmbedBlockData) {
super();
}
toDOM(view: EditorView): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cm-html-block-widget';
// Content container
const content = document.createElement('span');
content.className = 'cm-html-block-content';
// This is sanitized!
content.innerHTML = this.data.content;
// Edit button
const editBtn = document.createElement('button');
editBtn.className = 'cm-html-block-edit-btn';
editBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>`;
editBtn.title = 'Edit HTML';
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
selection: { anchor: this.data.from }
});
view.focus();
});
wrapper.appendChild(content);
wrapper.appendChild(editBtn);
return wrapper;
}
eq(widget: HTMLBlockWidget): boolean {
return JSON.stringify(widget.data) === JSON.stringify(this.data);
}
}
/**
* Base theme for HTML blocks.
*/
const baseTheme = EditorView.baseTheme({
'.cm-html-block-widget': {
display: 'inline-block',
position: 'relative',
maxWidth: '100%',
overflow: 'auto',
verticalAlign: 'middle'
},
'.cm-html-block-content': {
display: 'inline-block'
},
// Ensure images are properly sized
'.cm-html-block-content img': {
maxWidth: '100%',
height: 'auto',
display: 'block'
},
'.cm-html-block-edit-btn': {
position: 'absolute',
top: '4px',
right: '4px',
padding: '4px',
border: 'none',
borderRadius: '4px',
background: 'rgba(128, 128, 128, 0.2)',
color: 'inherit',
cursor: 'pointer',
opacity: '0',
transition: 'opacity 0.2s, background 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '10'
},
'.cm-html-block-widget:hover .cm-html-block-edit-btn': {
opacity: '1'
},
'.cm-html-block-edit-btn:hover': {
background: 'rgba(128, 128, 128, 0.4)'
}
});
// Export the extension with theme
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];

View File

@@ -0,0 +1,220 @@
import { syntaxTree } from '@codemirror/language';
import { Extension, Range } from '@codemirror/state';
import {
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate,
hoverTooltip,
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/');
}
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;
}
class IndicatorWidget extends WidgetType {
constructor(readonly info: ImageInfo) {
super();
}
toDOM(): HTMLElement {
const el = document.createElement('span');
el.className = 'cm-image-indicator';
el.innerHTML = ICON;
return el;
}
eq(other: IndicatorWidget): boolean {
return this.info.from === other.info.from && this.info.src === other.info.src;
}
}
class ImagePlugin {
decorations: DecorationSet;
images: ImageInfo[] = [];
constructor(view: EditorView) {
this.images = extractImages(view);
this.decorations = this.build();
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.images = extractImages(update.view);
this.decorations = this.build();
}
}
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);
}
getImageAt(pos: number): ImageInfo | null {
for (const img of this.images) {
if (pos >= img.to && pos <= img.to + 1) {
return img;
}
}
return null;
}
}
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
decorations: (v) => v.decorations
});
const imageHoverTooltip = hoverTooltip(
(view, pos): Tooltip | null => {
const plugin = view.plugin(imagePlugin);
if (!plugin) return null;
const img = plugin.getImageAt(pos);
if (!img) return null;
return {
pos: img.to,
above: true,
arrow: 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);
return { dom };
}
};
},
{ hoverTime: 300 }
);
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: 'var(--text-muted)'
},
'.cm-tooltip-arrow:before': {
borderTopColor: 'var(--border-color) !important',
borderBottomColor: 'var(--border-color) !important'
},
'.cm-tooltip-arrow:after': {
borderTopColor: '#fff !important',
borderBottomColor: '#fff !important'
}
});
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];

View File

@@ -0,0 +1,111 @@
import { Extension, Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
/**
* Inline code styling plugin.
*
* This plugin adds visual styling to inline code (`code`):
* - Background color
* - Border radius
* - Padding effect via marks
*/
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
/**
* Build inline code decorations.
*/
function buildInlineCodeDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'InlineCode') return;
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Skip background decoration when cursor is in the code
// This allows selection highlighting to be visible when editing
if (cursorInCode) return;
// Get the actual code content (excluding backticks)
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
// Find backtick positions
let codeStart = nodeFrom;
let codeEnd = nodeTo;
// Skip opening backticks
let i = 0;
while (i < text.length && text[i] === '`') {
codeStart++;
i++;
}
// Skip closing backticks
let j = text.length - 1;
while (j >= 0 && text[j] === '`') {
codeEnd--;
j--;
}
// Only add decoration if there's actual content
if (codeStart < codeEnd) {
// Add mark decoration for the code content
decorations.push(
Decoration.mark({
class: 'cm-inline-code'
}).range(codeStart, codeEnd)
);
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Inline code plugin class.
*/
class InlineCodePlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildInlineCodeDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = buildInlineCodeDecorations(update.view);
}
}
}
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for inline code.
* Uses CSS variables from variables.css for consistent theming.
*/
const baseTheme = EditorView.baseTheme({
'.cm-inline-code': {
backgroundColor: 'var(--cm-inline-code-bg)',
borderRadius: '0.25rem',
padding: '0.1rem 0.3rem',
fontFamily: 'var(--voidraft-font-mono)'
}
});

View File

@@ -0,0 +1,114 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Insert plugin using syntax tree.
*
* Uses the custom Insert extension to detect:
* - Insert: ++text++ → renders as inserted text (underline)
*
* Examples:
* - This is ++inserted++ text → This is <ins>inserted</ins> text
* - Please ++review this section++ carefully
*/
export const insert = (): Extension => [
insertPlugin,
baseTheme
];
/**
* Build decorations for insert using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Insert nodes
if (type.name === 'Insert') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ++ characters)
const marks = node.getChildren('InsertMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ++ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply insert style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-insert'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class InsertPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const insertPlugin = ViewPlugin.fromClass(
InsertPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for insert.
* Uses underline decoration for inserted text.
*/
const baseTheme = EditorView.baseTheme({
'.cm-insert': {
textDecoration: 'underline',
}
});

View File

@@ -0,0 +1,142 @@
import { syntaxTree } from '@codemirror/language';
import { Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
/**
* Pattern for auto-link markers (< and >).
*/
const AUTO_LINK_MARK_RE = /^<|>$/g;
/**
* Parent node types that should not process.
* - Image: handled by image plugin
* - LinkReference: reference link definitions like [label]: url should be fully visible
*/
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
/**
* Links plugin.
*
* Features:
* - Hides link markup when cursor is outside
* - Link icons and click events are handled by hyperlink extension
*/
export const links = () => [goToLinkPlugin];
/**
* Build link decorations.
* Only hides markdown syntax marks, no icons added.
* Uses array + Decoration.set() for automatic sorting.
*/
function buildLinkDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const selectionRanges = view.state.selection.ranges;
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'URL') return;
const parent = node.parent;
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
const marks = parent.getChildren('LinkMark');
const linkTitle = parent.getChild('LinkTitle');
// Find the ']' mark position to distinguish between link text and link target
// Link structure: [display text](url)
// We should only hide the URL in the () part, not in the [] part
const closeBracketMark = marks.find((mark) => {
const text = view.state.sliceDoc(mark.from, mark.to);
return text === ']';
});
// If URL is before ']', it's part of the display text, don't hide it
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
return;
}
// Check if cursor overlaps with the link
const cursorOverlaps = selectionRanges.some((range) =>
checkRangeOverlap([range.from, range.to], [parent.from, parent.to])
);
// Hide link marks and URL when cursor is outside
if (!cursorOverlaps && marks.length > 0) {
for (const mark of marks) {
decorations.push(invisibleDecoration.range(mark.from, mark.to));
}
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
if (linkTitle) {
decorations.push(invisibleDecoration.range(linkTitle.from, linkTitle.to));
}
}
// Get link content
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
// Handle auto-links with < > markers
if (AUTO_LINK_MARK_RE.test(linkContent)) {
if (!isCursorInRange(view.state, [node.from, node.to])) {
decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1));
decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo));
}
}
}
});
}
// Use Decoration.set with sort=true to handle unsorted ranges
return Decoration.set(decorations, true);
}
/**
* Link plugin with optimized update detection.
*/
class LinkPlugin {
decorations: DecorationSet;
private lastSelectionRanges: string = '';
constructor(view: EditorView) {
this.decorations = buildLinkDecorations(view);
this.lastSelectionRanges = this.serializeSelection(view);
}
update(update: ViewUpdate) {
// Always rebuild on doc or viewport change
if (update.docChanged || update.viewportChanged) {
this.decorations = buildLinkDecorations(update.view);
this.lastSelectionRanges = this.serializeSelection(update.view);
return;
}
// For selection changes, check if selection actually changed
if (update.selectionSet) {
const newRanges = this.serializeSelection(update.view);
if (newRanges !== this.lastSelectionRanges) {
this.decorations = buildLinkDecorations(update.view);
this.lastSelectionRanges = newRanges;
}
}
}
private serializeSelection(view: EditorView): string {
return view.state.selection.ranges
.map((r) => `${r.from}:${r.to}`)
.join(',');
}
}
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
decorations: (v) => v.decorations
});

View File

@@ -0,0 +1,273 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { Range, StateField, Transaction } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
/**
* Pattern for bullet list markers.
*/
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
/**
* Lists plugin.
*
* Features:
* - Custom bullet mark rendering (- → •)
* - Interactive task list checkboxes
*/
export const lists = () => [listBulletPlugin, taskListField, baseTheme];
// ============================================================================
// List Bullet Plugin
// ============================================================================
/**
* Widget to render list bullet mark.
*/
class ListBulletWidget extends WidgetType {
constructor(readonly bullet: string) {
super();
}
eq(other: ListBulletWidget): boolean {
return other.bullet === this.bullet;
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-list-bullet';
span.textContent = '•';
return span;
}
}
/**
* Build list bullet decorations.
*/
function buildListBulletDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'ListMark') return;
// Skip if this is part of a task list (has Task sibling)
const parent = node.parent;
if (parent) {
const task = parent.getChild('Task');
if (task) return;
}
// Skip if cursor is in this range
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
const listMark = view.state.sliceDoc(nodeFrom, nodeTo);
if (BULLET_LIST_MARKER_RE.test(listMark)) {
decorations.push(
Decoration.replace({
widget: new ListBulletWidget(listMark)
}).range(nodeFrom, nodeTo)
);
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* List bullet plugin.
*/
class ListBulletPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildListBulletDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildListBulletDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
const oldLine = update.startState.doc.lineAt(this.lastSelectionHead);
const newLine = update.state.doc.lineAt(newHead);
if (oldLine.number !== newLine.number) {
this.decorations = buildListBulletDecorations(update.view);
}
this.lastSelectionHead = newHead;
}
}
}
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
decorations: (v) => v.decorations
});
// ============================================================================
// Task List Plugin (using StateField to avoid flickering)
// ============================================================================
/**
* Widget to render checkbox for a task list item.
*/
class TaskCheckboxWidget extends WidgetType {
constructor(
readonly checked: boolean,
readonly pos: number // Position of the checkbox character in document
) {
super();
}
eq(other: TaskCheckboxWidget): boolean {
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;
// Handle click directly in the widget
checkbox.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const newValue = !this.checked;
view.dispatch({
changes: {
from: this.pos,
to: this.pos + 1,
insert: newValue ? 'x' : ' '
}
});
});
wrap.appendChild(checkbox);
return wrap;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Build task list decorations from state.
*/
function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet {
const decorations: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter: ({ type, from: taskFrom, to: taskTo, node }) => {
if (type.name !== 'Task') return;
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;
const replaceFrom = listMark.from;
const replaceTo = taskMarker.to;
// Check if cursor is in this range
if (isCursorInRange(state, [replaceFrom, replaceTo])) return;
// Check if task is checked - position of x or space is taskMarker.from + 1
const markerText = state.sliceDoc(taskMarker.from, taskMarker.to);
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
const checkboxPos = taskMarker.from + 1; // Position of the x or space
if (isChecked) {
decorations.push(
Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo)
);
}
// Replace "- [x]" or "- [ ]" with checkbox widget
decorations.push(
Decoration.replace({
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
}).range(replaceFrom, replaceTo)
);
}
});
return Decoration.set(decorations, true);
}
/**
* Task list StateField - uses incremental updates to avoid flickering.
*/
const taskListField = StateField.define<DecorationSet>({
create(state) {
return buildTaskListDecorations(state);
},
update(value, tr: Transaction) {
// Only rebuild when document or selection changes
if (tr.docChanged || tr.selection) {
return buildTaskListDecorations(tr.state);
}
return value;
},
provide(field) {
return EditorView.decorations.from(field);
}
});
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for lists.
*/
const baseTheme = 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'
}
});

View File

@@ -0,0 +1,358 @@
/**
* Math plugin for CodeMirror using KaTeX.
*
* Features:
* - Renders inline math $...$ as inline formula
* - Renders block math $$...$$ as block formula
* - Block math: lines remain, content hidden, formula overlays on top
* - Shows source when cursor is inside
*/
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { isCursorInRange, invisibleDecoration } from '../util';
// ============================================================================
// Inline Math Widget
// ============================================================================
/**
* Widget to display inline math formula.
*/
class InlineMathWidget extends WidgetType {
private html: string;
private error: string | null = null;
constructor(readonly latex: string) {
super();
try {
this.html = katex.renderToString(latex, {
throwOnError: true,
displayMode: false,
output: 'html'
});
} catch (e) {
this.error = e instanceof Error ? e.message : 'Render error';
this.html = '';
}
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-inline-math';
if (this.error) {
span.textContent = this.latex;
span.title = this.error;
} else {
span.innerHTML = this.html;
}
return span;
}
eq(other: InlineMathWidget): boolean {
return this.latex === other.latex;
}
ignoreEvent(): boolean {
return false;
}
}
// ============================================================================
// Block Math Widget
// ============================================================================
/**
* Widget to display block math formula.
* Uses absolute positioning to overlay on source lines.
*/
class BlockMathWidget extends WidgetType {
private html: string;
private error: string | null = null;
constructor(
readonly latex: string,
readonly lineCount: number = 1,
readonly lineHeight: number = 22
) {
super();
try {
this.html = katex.renderToString(latex, {
throwOnError: false,
displayMode: true,
output: 'html'
});
} catch (e) {
this.error = e instanceof Error ? e.message : 'Render error';
this.html = '';
}
}
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-block-math-container';
// Set height to cover all source lines
const height = this.lineCount * this.lineHeight;
container.style.height = `${height}px`;
const inner = document.createElement('div');
inner.className = 'cm-block-math';
if (this.error) {
inner.textContent = this.latex;
inner.title = this.error;
} else {
inner.innerHTML = this.html;
}
container.appendChild(inner);
return container;
}
eq(other: BlockMathWidget): boolean {
return this.latex === other.latex && this.lineCount === other.lineCount;
}
ignoreEvent(): boolean {
return false;
}
}
// ============================================================================
// Decorations
// ============================================================================
/**
* Build decorations for math formulas.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle inline math
if (type.name === 'InlineMath') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('InlineMathMark');
if (!cursorInRange && marks.length >= 2) {
// Get latex content (without $ marks)
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
// Hide the entire syntax
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
// Add widget at the end
decorations.push(
Decoration.widget({
widget: new InlineMathWidget(latex),
side: 1
}).range(nodeTo)
);
}
}
// Handle block math ($$...$$)
if (type.name === 'BlockMath') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('BlockMathMark');
if (!cursorInRange && marks.length >= 2) {
// Get latex content (without $$ marks)
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
// Calculate line info
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
const lineCount = endLine.number - startLine.number + 1;
const lineHeight = view.defaultLineHeight;
// Check if block math spans multiple lines
const hasLineBreak = lineCount > 1;
if (hasLineBreak) {
// For multi-line: use line decorations to hide content
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
decorations.push(
Decoration.line({
class: 'cm-block-math-line'
}).range(line.from)
);
}
// Add widget on the first line (positioned absolutely)
decorations.push(
Decoration.widget({
widget: new BlockMathWidget(latex, lineCount, lineHeight),
side: -1
}).range(startLine.from)
);
} else {
// Single line: make content transparent, overlay widget
decorations.push(
Decoration.mark({
class: 'cm-block-math-content-hidden'
}).range(nodeFrom, nodeTo)
);
// Add widget at the start (positioned absolutely)
decorations.push(
Decoration.widget({
widget: new BlockMathWidget(latex, 1, lineHeight),
side: -1
}).range(nodeFrom)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
// ============================================================================
// Plugin
// ============================================================================
/**
* Math plugin with optimized update detection.
*/
class MathPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const mathPlugin = ViewPlugin.fromClass(
MathPlugin,
{
decorations: (v) => v.decorations
}
);
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for math.
*/
const baseTheme = EditorView.baseTheme({
// Inline math
'.cm-inline-math': {
display: 'inline',
verticalAlign: 'baseline',
},
'.cm-inline-math .katex': {
fontSize: 'inherit',
},
// Block math container - absolute positioned to overlay on source
'.cm-block-math-container': {
position: 'absolute',
left: '0',
right: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'none',
zIndex: '1',
},
// Block math inner
'.cm-block-math': {
display: 'inline-block',
textAlign: 'center',
pointerEvents: 'auto',
},
'.cm-block-math .katex-display': {
margin: '0',
},
'.cm-block-math .katex': {
fontSize: '1.1em',
},
// Hidden line content for block math (text transparent but line preserved)
// Use high specificity to override rainbow brackets and other plugins
'.cm-line.cm-block-math-line': {
color: 'transparent !important',
caretColor: 'transparent',
},
'.cm-line.cm-block-math-line span': {
color: 'transparent !important',
},
// Override rainbow brackets in hidden math lines
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
},
// Hidden content for single-line block math
'.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',
},
});
// ============================================================================
// Export
// ============================================================================
/**
* Math extension.
*
* Features:
* - Parses inline math $...$ and block math $$...$$
* - Renders formulas using KaTeX
* - Block math preserves line structure, overlays rendered formula
* - Shows source when cursor is inside
*/
export const math = (): Extension => [
mathPlugin,
baseTheme
];
export default math;

View File

@@ -0,0 +1,152 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Subscript and Superscript plugin using syntax tree.
*
* Uses lezer-markdown's Subscript and Superscript extensions to detect:
* - Superscript: ^text^ → renders as superscript
* - Subscript: ~text~ → renders as subscript
*
* Note: Inline footnotes ^[content] are handled by the Footnote extension
* which parses InlineFootnote before Superscript in the syntax tree.
*
* Examples:
* - 19^th^ → 19ᵗʰ (superscript)
* - H~2~O → H₂O (subscript)
*/
export const subscriptSuperscript = (): Extension => [
subscriptSuperscriptPlugin,
baseTheme
];
/**
* Build decorations for subscript and superscript using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Superscript nodes
// Note: InlineFootnote ^[content] is parsed before Superscript,
// so we don't need to check for bracket patterns here
if (type.name === 'Superscript') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ^ characters)
const marks = node.getChildren('SuperscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ^ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply superscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-superscript'
}).range(contentStart, contentEnd)
);
}
}
}
// Handle Subscript nodes
if (type.name === 'Subscript') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ~ characters)
const marks = node.getChildren('SubscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ~ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply subscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-subscript'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class SubscriptSuperscriptPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
SubscriptSuperscriptPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for subscript and superscript.
* Uses mark decoration instead of widget to avoid layout issues.
* fontSize uses smaller value as subscript/superscript are naturally smaller.
*/
const baseTheme = EditorView.baseTheme({
'.cm-superscript': {
verticalAlign: 'super',
fontSize: '0.75em',
color: 'var(--cm-superscript-color, inherit)'
},
'.cm-subscript': {
verticalAlign: 'sub',
fontSize: '0.75em',
color: 'var(--cm-subscript-color, inherit)'
}
});

View File

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

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

View File

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

View File

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

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

View 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();
}
}

View File

@@ -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>

View File

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

View File

@@ -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();

View File

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

View File

@@ -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',
})
}

View File

@@ -6,19 +6,16 @@ import { useUpdateStore } from '@/stores/updateStore';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import markdownit from 'markdown-it'
import { marked } from 'marked';
const { t } = useI18n();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
// 初始化Remarkable实例并配置
const md = markdownit({
html: true, // 允许HTML
linkify: false, // 不解析链接
typographer: true, // 开启智能引号
xhtmlOut: true, // 使用xhtml语法输出
// 配置marked
marked.setOptions({
breaks: true, // 允许换行
gfm: true, // GitHub风格Markdown
});
// 计算属性
@@ -29,10 +26,10 @@ const autoCheckUpdates = computed({
}
});
// 使用Remarkable解析Markdown
// 使用marked解析Markdown
const parseMarkdown = (markdown: string) => {
if (!markdown) return '';
return md.render(markdown);
return marked.parse(markdown) as string;
};
// 处理更新按钮点击